fix(android): the console answers its first field test — the glass, the drum, and the room to breathe
Every item from the on-glass review on the Nothing Phone 3, which found the refresh technically
correct and visually wrong.
**The glass was structurally broken, twice.** The focus bloom was drawn OUTSIDE the clip on
purpose ("a glow that stops at the edge is just a brighter border") — but unclipped drawing does
not stop at the row's neighbours either: in a list it painted over the rows above and below, and in
the carousel it escaped the card entirely. And the focus drop shadow is drawn UNDER the surface,
which is translucent — so the shadow showed straight through the fill as a dark rectangle floating
inside every card and field. Both are gone, and the comment forbids their return: the Apple glass
(`GlassStyle.swift`) is material + an animatable tint, full stop. Focus is now the fill and border
brightening (tint up to 0.28, near Apple's 0.30) — which is also why it finally animates like the
Apple client: one interpolating fill instead of four stacked effects arriving on separate curves.
**The select field is now the Apple drum.** `ConsoleOptionBand` ports `GamepadOptionBand.swift`
whole: options ride a turning cylinder segment, position driven by one spring whose retargeting
preserves velocity — rapid steps accumulate into one accelerating travel instead of five restarted
fades. Linear, not a ring; neighbours exist only mid-flight; the soft edge is per-option opacity,
never a mask (a mask rasterises the projection away — the Apple file's own field verdict). The
band's width is fixed by the row, so a step can never reflow the chevrons; portrait narrows it
(132 dp) because at 156 the LABELS truncated, and a clipped label loses meaning where a drum value
only loses its tail into the edge fade. Chevrons are icons now, not '‹' text glyphs.
**Landscape got room.** Rows cap at the Apple client's 620 dp and sit left; the focused row's
description moved into the width a wide phone was wasting — a side pane on the right, with the
row's label anchoring it — instead of a band floating over the list's tail. Portrait keeps the
band. Add-host fields take the same cap.
**The list is no longer guillotined.** The safe area now applies to the CHROME (strip, sides, top)
only; the list runs to the physical bottom edge with the bottom inset folded into its
contentPadding, so scrolled rows glide off the screen instead of being cut at an invisible line
above it.
**"Default settings" stopped shouting.** The headline that repeated itself on every tab is a quiet
"Defaults" chip at the strip's end — same honesty (this screen edits the base layer only), no
second heading repeating the tab pill's own word.
**Tab switching is finally directional.** The old cut slid a single list's contents 24 dp under an
85 % fade — the same crossfade whichever shoulder was pressed. It is now an AnimatedContent whose
incoming section slides from the side the press pointed at while the outgoing leaves the other way.
Each pane owns its own LazyListState (one state cannot attach to two lists — the constraint that
motivated the single-list hack), seeded at the section's restored cursor.
**The carousel ignores the safe area again, on purpose.** Insetting the pager clipped the fanned
neighbours at the cutout edge; cards visibly cut off is worse than cards behind a camera. Only the
centred card matters and it sits mid-screen — the title and legend keep their insets, they are
content.
**The legend stopped jumping.** The connect takeover parked its pill at bottom-CENTRE, so pressing
Connect made the one piece of chrome that should read as fixed leap halfway across the screen. It
now sits at the same bottom-start inset as every console screen.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -241,10 +243,19 @@ internal fun ConnectTakeover(
|
||||
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||
}
|
||||
GamepadHintBar(
|
||||
hints,
|
||||
Modifier.align(Alignment.BottomCenter).consoleSafeArea().padding(bottom = 28.dp),
|
||||
)
|
||||
// The SAME bottom-start spot every console screen pins its legend at — this takeover sat
|
||||
// its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is
|
||||
// supposed to read as fixed jump halfway across the screen (second on-glass verdict).
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -295,7 +295,8 @@ fun GamepadAddHostScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
@@ -316,7 +317,9 @@ fun GamepadAddHostScreen(
|
||||
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
// Same 620 dp cap as the settings rows: a field stretched across a wide
|
||||
// landscape phone is a ribbon, not an input.
|
||||
Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
@@ -51,15 +52,16 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -75,6 +77,7 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
@@ -95,8 +98,14 @@ import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -473,7 +482,10 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
|
||||
label = "consoleFocus",
|
||||
)
|
||||
val background by animateColorAsState(
|
||||
if (active) ink.accent(0.20f) else ink.glass,
|
||||
// 0.28, not the 0.20 it launched with: with the shadow and bloom gone (see consoleGlass),
|
||||
// the tint IS the whole focus statement, and Apple's rows carry accent(0.30) for the same
|
||||
// reason. Slightly under Apple's because this fill also rides a luminance gradient.
|
||||
if (active) ink.accent(0.28f) else ink.glass,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "consoleBg",
|
||||
)
|
||||
@@ -492,18 +504,21 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
|
||||
/**
|
||||
* The console's glass: what every row, field, card and tile is cut from, in one place.
|
||||
*
|
||||
* Four things separate it from the flat translucent fill it replaces, all of them the desktop
|
||||
* console's `theme::panel()` in Compose terms:
|
||||
* Two things separate it from a flat translucent fill:
|
||||
* * a vertical LUMINANCE gradient in the fill, so the pane has a lit top and a settled bottom
|
||||
* instead of one even wash;
|
||||
* * a 1 px top-edge HIGHLIGHT that fades down into the border — the cue that reads as "this is a
|
||||
* physical pane catching the light above it";
|
||||
* * a drop SHADOW that grows with focus, so the focused row genuinely sits above its neighbours;
|
||||
* * a soft accent BLOOM behind it, drawn outside the clip, so focus reads as a lens over the
|
||||
* backdrop rather than a recolour of the row.
|
||||
* physical pane catching the light above it".
|
||||
*
|
||||
* The bloom and shadow are driven by [ConsoleFocusVisuals.focus], so they arrive and leave with the
|
||||
* same curve as the fill — one focus change, not four independent animations.
|
||||
* Focus is the FILL and the BORDER brightening — nothing else, which matches the Apple client's
|
||||
* glass (`GlassStyle.swift`: material + an animatable tint, full stop). Two earlier additions were
|
||||
* removed after the first on-glass review, and neither may come back:
|
||||
* * a drop shadow. An elevation shadow is drawn UNDER the surface, and this surface is
|
||||
* translucent — the shadow showed straight through the fill as a dark rectangle floating
|
||||
* inside every row and card.
|
||||
* * an accent bloom drawn outside the clip. Unclipped drawing does not stop at the row's
|
||||
* neighbours either: in a list the glow painted over the rows above and below, and in the
|
||||
* carousel it escaped the card entirely.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.consoleGlass(
|
||||
@@ -519,7 +534,6 @@ fun Modifier.consoleGlass(
|
||||
): Modifier {
|
||||
val ink = LocalGamepadInk.current
|
||||
val focus = visuals.focus
|
||||
val accent = ink.accent
|
||||
val highlight = ink.highlight
|
||||
val fill = visuals.background
|
||||
val border = visuals.border
|
||||
@@ -558,22 +572,6 @@ fun Modifier.consoleGlass(
|
||||
}
|
||||
return this
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
// BEFORE the clip, so the bloom can spill past the row's own rectangle — a glow that stops
|
||||
// at the edge is just a brighter border.
|
||||
.drawBehind {
|
||||
if (focus <= 0.01f) return@drawBehind
|
||||
val r = size.height * 1.35f
|
||||
drawRect(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(accent.copy(alpha = 0.18f * focus), Color.Transparent),
|
||||
center = Offset(size.width / 2f, size.height / 2f),
|
||||
radius = r,
|
||||
),
|
||||
topLeft = Offset(-r, -r),
|
||||
size = Size(size.width + 2f * r, size.height + 2f * r),
|
||||
)
|
||||
}
|
||||
.shadow(elevation = ConsoleGlassLift * focus, shape = shape, clip = false)
|
||||
.clip(shape)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
@@ -594,9 +592,6 @@ fun Modifier.consoleGlass(
|
||||
)
|
||||
}
|
||||
|
||||
/** How far a fully focused console surface lifts off the field. */
|
||||
private val ConsoleGlassLift = 8.dp
|
||||
|
||||
/**
|
||||
* A MODAL card's surface. Unlike [consoleGlass] this one is near-opaque: a dialog's job is to
|
||||
* occlude the screen it covers, and it carries body text that has to stay readable over a moving
|
||||
@@ -821,6 +816,134 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
// --- The option band ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A choice row's value as a REAL band — the Android port of the Apple client's
|
||||
* `GamepadOptionBand.swift`, and the fix for "the select field is nowhere near the Apple client":
|
||||
* the options sit side by side on a drum segment curving about a vertical axis. The current one
|
||||
* faces you flat; a step rotates the next one in with perspective. The drum's position is ONE
|
||||
* continuous value driven by a spring, and Compose's `Animatable` retargets with velocity — rapid
|
||||
* presses accumulate into one accelerating travel instead of five restarted crossfades.
|
||||
*
|
||||
* The band is LINEAR, not a ring (the Apple field verdict, inherited whole): a ring showed the
|
||||
* first option waiting to the right of the last one, which left/right can't reach — a promise the
|
||||
* navigation doesn't keep. Positions are fixed, the ends are the ends, and A's wrap from the last
|
||||
* option travels BACK across the list to the first. Neighbours exist only while the drum is
|
||||
* actually moving — at rest a row shows exactly its value (a resting neighbour under a long label
|
||||
* rendered as overlapping, unreadable text on Apple, and would here too).
|
||||
*
|
||||
* [width] is FIXED by the row: a step must never reflow the row (the free-width value shifted the
|
||||
* chevrons with every label), and the drum needs its stage even when the facing label is short.
|
||||
*
|
||||
* Purely presentational: stepping semantics (clamp with a boundary thud, A cycles wrapping,
|
||||
* disabled rows refuse) stay in the settings screen's row closures.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleOptionBand(
|
||||
options: List<String>,
|
||||
selection: Int,
|
||||
focused: Boolean,
|
||||
width: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val animated = animationsEnabled()
|
||||
val color = ink.fg(if (focused) 1f else 0.6f)
|
||||
val drum = remember { Animatable(selection.toFloat()) }
|
||||
// Where the spring is headed (jumps instantly on a step; only the drum chases it). The
|
||||
// distance between them is "how mid-flight are we" — the neighbours exist exactly as long as
|
||||
// the drum is moving.
|
||||
var target by remember { mutableIntStateOf(selection) }
|
||||
LaunchedEffect(selection, options.size, animated) {
|
||||
val old = target
|
||||
target = selection
|
||||
// A step (or A's wrap — on a linear band a fast travel back to the start) springs the
|
||||
// drum; anything else (an external write from the touch settings, a re-derived options
|
||||
// list) re-seats it — a travel to a value the user didn't step to would read as the UI
|
||||
// acting on its own.
|
||||
val wrapped = options.size > 1 && old == options.size - 1 && selection == 0
|
||||
if (animated && (abs(selection - old) == 1 || wrapped)) {
|
||||
drum.animateTo(
|
||||
selection.toFloat(),
|
||||
// Apple's `.spring(response: 0.32, dampingFraction: 0.78)`; stiffness is
|
||||
// (2π / response)² ≈ 385.
|
||||
spring(dampingRatio = 0.78f, stiffness = 385f),
|
||||
)
|
||||
} else {
|
||||
drum.snapTo(selection.toFloat())
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.width(width)
|
||||
.clipToBounds()
|
||||
// One element to a screen reader — the neighbour texts are rendering, not content.
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = options.getOrNull(selection).orEmpty()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val rotation = drum.value
|
||||
val flight = min(1f, abs(rotation - target) * 3f)
|
||||
val widthPx = with(LocalDensity.current) { width.toPx() }
|
||||
// Puts the ±1 neighbour ~40 % of the band off-centre, curling toward the edge.
|
||||
val radius = widthPx * 0.72f
|
||||
options.forEachIndexed { i, label ->
|
||||
// Plain signed distance — option i has ONE home and nothing waits beyond the ends.
|
||||
val d = i - rotation
|
||||
// Only the facing option at rest; its neighbours join it for the travel.
|
||||
if (abs(d) < 0.5f || (flight > 0.001f && abs(d) <= 2.5f)) {
|
||||
val angle = d * DRUM_STEP_RAD
|
||||
val depth = cos(angle)
|
||||
val x = radius * sin(angle)
|
||||
val alpha = max(depth, 0f).let { it * it * it } *
|
||||
(if (abs(d) < 0.5f) 1f else flight) * drumEdgeFade(x, widthPx)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
// The Apple `fixedSize()`: never let a turning label re-wrap to the
|
||||
// band's width mid-flight — it clips at the band's edge fade instead.
|
||||
.wrapContentWidth(unbounded = true)
|
||||
.zIndex(depth)
|
||||
.graphicsLayer {
|
||||
translationX = x
|
||||
val s = 0.70f + 0.30f * depth
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
// Foreshorten the label as it turns away — what sells the cylinder.
|
||||
rotationY = Math.toDegrees(angle.toDouble()).toFloat()
|
||||
cameraDistance = 6f * density
|
||||
this.alpha = alpha
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Angular pitch between adjacent options on the drum — Apple's 34°. */
|
||||
private const val DRUM_STEP_RAD = 34f * (PI.toFloat() / 180f)
|
||||
|
||||
/**
|
||||
* The soft edge, per option rather than as a container mask: full strength through the middle of
|
||||
* the band, dissolving to nothing by an option's rim, so the drum never ends on a hard cut. (The
|
||||
* Apple file documents why it must not be a mask: a mask rasterises what it covers, which throws
|
||||
* the 3D projection away every frame — the same trap exists in Compose via `graphicsLayer` alpha
|
||||
* masking a parent.)
|
||||
*/
|
||||
private fun drumEdgeFade(x: Float, width: Float): Float {
|
||||
val half = width / 2f
|
||||
if (half <= 0f) return 1f
|
||||
val fadeStart = half * 0.55f
|
||||
if (abs(x) <= fadeStart) return 1f
|
||||
return ((half - abs(x)) / (half - fadeStart)).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
// --- Menu haptics -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -208,7 +208,12 @@ fun GamepadHome(
|
||||
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||
Box(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
// The carousel deliberately IGNORES the safe area (first on-glass verdict): only the
|
||||
// CENTRED card matters, and it sits mid-screen; the fanned neighbours running under
|
||||
// the hole punch is ambience, while insetting the pager CLIPPED them at the cutout
|
||||
// edge — cards visibly cut off is worse than cards behind a camera. The title and the
|
||||
// legend keep their insets; they are content.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(cardWidth),
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
@@ -23,15 +24,28 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -59,6 +73,7 @@ import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -116,6 +131,13 @@ internal class GpRow(
|
||||
* row that opens the Controllers view was then advertised as pinning something.
|
||||
*/
|
||||
val actionHint: String = "Open",
|
||||
/**
|
||||
* A choice row's full option list + where [value] sits in it — what the [ConsoleOptionBand]
|
||||
* drum turns through. Null (with [selectedIndex] -1) on everything that is not a stepped
|
||||
* choice: toggles are a switch, and the flat rows keep the quiet text slip.
|
||||
*/
|
||||
val options: List<String>? = null,
|
||||
val selectedIndex: Int = -1,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -252,7 +274,6 @@ fun GamepadSettingsScreen(
|
||||
// (the value gives a little and springs back). A press always gets an answer, even "no".
|
||||
var stepToken by remember { mutableIntStateOf(0) }
|
||||
var refusalToken by remember { mutableIntStateOf(0) }
|
||||
val listState = rememberLazyListState()
|
||||
val haptics = rememberConsoleHaptics()
|
||||
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
@@ -302,82 +323,72 @@ fun GamepadSettingsScreen(
|
||||
// The shoulders work from either place — a real pad never has to visit the strip.
|
||||
onShoulder = { delta -> stepTab(delta) },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
val legendClearancePx = with(LocalDensity.current) { ConsoleLegendClearance.roundToPx() }
|
||||
LaunchedEffect(focus, tab) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
// The SAME constant the list pads its bottom with, rather than a literal that has
|
||||
// to be remembered when the legend zone grows — which is exactly how a row ended up
|
||||
// scrolling to a position the legend then covered.
|
||||
item.offset + item.size > info.viewportEndOffset - legendClearancePx
|
||||
if (offScreen) listState.animateScrollToItem(itemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// The section slide: one shared list whose CONTENT slides, rather than an AnimatedContent
|
||||
// holding two LazyColumns — two lists would mean two scroll states fighting over one cursor.
|
||||
val animated = animationsEnabled()
|
||||
val tabSlide = remember { Animatable(0f) }
|
||||
LaunchedEffect(tab, animated) {
|
||||
if (!animated) { tabSlide.snapTo(0f); return@LaunchedEffect }
|
||||
tabSlide.snapTo(1f)
|
||||
tabSlide.animateTo(0f, ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
}
|
||||
val tabSlidePx = with(LocalDensity.current) { ConsoleMotion.TAB_SLIDE.toPx() }
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
val ink = LocalGamepadInk.current
|
||||
// The list runs to the PHYSICAL bottom edge (see the column's insets below), so the legend
|
||||
// zone's clearance has to carry the bottom bar inset itself. Landscape's zone is only the
|
||||
// pill — its detail lives in the side pane — so it clears less.
|
||||
val bottomInset = with(LocalDensity.current) {
|
||||
WindowInsets.systemBars.getBottom(this).toDp()
|
||||
}
|
||||
val legendClearance = (if (landscape) 92.dp else ConsoleLegendClearance) + bottomInset
|
||||
val legendClearancePx = with(LocalDensity.current) { legendClearance.roundToPx() }
|
||||
// The drum's fixed stage: a portrait phone is the one place the full width starves the row's
|
||||
// label, so it alone narrows it — the Apple band makes the same single exception. 132, not the
|
||||
// 156 of the first cut: at 156 the LABELS truncated ("Resoluti…"), and a clipped label loses
|
||||
// meaning where a drum value only loses its tail into the edge fade.
|
||||
val bandWidth = if (landscape) 220.dp else 132.dp
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
|
||||
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The backdrop stays full-bleed under the cutout and the bars — it is ambience. Only
|
||||
// the CONTENT column takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation now, and a switcher you have to scroll back up to find isn't one. The
|
||||
// title stays in the scrolling list (landscape has no height to spare, and the
|
||||
// selected pill already says which section you are in).
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
focused = tabFocused,
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = tabSlidePx * tabSlide.value * tabDir
|
||||
alpha = 1f - 0.85f * tabSlide.value
|
||||
},
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
top = 8.dp,
|
||||
// Clears the whole floating legend ZONE — the detail band as well as the pill.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item(key = "__title") {
|
||||
// "Default settings", not "Settings": this screen edits the base layer only. The
|
||||
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
|
||||
// bare "Settings" would quietly imply it changes whatever that host streams with.
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
/**
|
||||
* One section's rows as a scrolling pane. A composable local rather than inline because the
|
||||
* tab transition composes TWO of these at once (incoming and outgoing), and each needs its own
|
||||
* [LazyListState] — Compose refuses one state attached to two lists, which is why the previous
|
||||
* cut animated a single list's contents and read as the same fade in every direction.
|
||||
*/
|
||||
val tabPane: @Composable (GpTab, Modifier) -> Unit = { paneTab, paneModifier ->
|
||||
val paneRows = if (paneTab == tab) rows else allRows.filter { it.tab == paneTab }
|
||||
val paneFocus = if (paneTab == tab) focus else (tabFocus[paneTab] ?: 0)
|
||||
// Seeded at the restored cursor, so re-entering a section lands where it was left without
|
||||
// a visible catch-up scroll on the first frame.
|
||||
val paneListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = paneFocus.coerceIn(0, paneRows.lastIndex.coerceAtLeast(0)),
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen. Only the
|
||||
// LIVE pane tracks the cursor; the outgoing one is a photograph on its way out.
|
||||
if (paneTab == tab) {
|
||||
LaunchedEffect(focus) {
|
||||
runCatching {
|
||||
val info = paneListState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == focus }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
// The SAME clearance the list pads its bottom with, rather than a literal
|
||||
// that has to be remembered when the legend zone grows.
|
||||
item.offset + item.size > info.viewportEndOffset - legendClearancePx
|
||||
if (offScreen) paneListState.animateScrollToItem(focus)
|
||||
}
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
val rowFocused = index == focus && !tabFocused
|
||||
}
|
||||
LazyColumn(
|
||||
state = paneListState,
|
||||
// Capped at the Apple client's 620 row width: a landscape phone is WIDER than it is
|
||||
// useful, and a settings row stretched across 900 dp reads as a ribbon, not a control.
|
||||
// Start-aligned (not centred) so the rows and the side detail pane split the screen
|
||||
// rather than both crowding the middle.
|
||||
modifier = paneModifier.widthIn(max = 620.dp + ConsoleEdgeInset * 2),
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
top = 8.dp,
|
||||
// Clears the whole floating legend ZONE, bottom bar included — the list itself
|
||||
// runs to the screen edge now.
|
||||
bottom = legendClearance,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(paneRows, key = { _, r -> r.id }) { index, row ->
|
||||
val rowFocused = paneTab == tab && index == focus && !tabFocused
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = rowFocused,
|
||||
@@ -385,6 +396,7 @@ fun GamepadSettingsScreen(
|
||||
// Only the focused row can be stepped, so only it needs to answer one.
|
||||
stepToken = if (rowFocused) stepToken else 0,
|
||||
refusalToken = if (rowFocused) refusalToken else 0,
|
||||
bandWidth = bandWidth,
|
||||
onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it
|
||||
// (so its detail explains itself) but never flips it.
|
||||
@@ -394,7 +406,127 @@ fun GamepadSettingsScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The section switcher with its directional content — shared by both orientations below. */
|
||||
val tabbedContent: @Composable (Modifier) -> Unit = { contentModifier ->
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
modifier = contentModifier,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else {
|
||||
// DIRECTION-driven, with a real exit: the incoming section slides in from the
|
||||
// side the press pointed at while the outgoing leaves the other way — paging
|
||||
// along a strip. The previous cut slid a single list's contents 24 dp under an
|
||||
// 85 % fade, which read as the same crossfade whichever shoulder was pressed.
|
||||
val dir = tabDir
|
||||
(
|
||||
slideInHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { it / 6 * dir } +
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
) togetherWith (
|
||||
slideOutHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { -it / 6 * dir } +
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "settingsTab",
|
||||
) { t ->
|
||||
tabPane(t, Modifier.fillMaxHeight())
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The backdrop stays full-bleed — it is ambience. The CHROME (strip, rows' start
|
||||
// edge) takes the safe area on the sides and top only: the LIST deliberately runs to
|
||||
// the physical bottom of the screen, with the bottom inset folded into its
|
||||
// contentPadding, so scrolled rows glide off the edge instead of being guillotined at
|
||||
// an invisible inset line 24 px above it (third on-glass verdict).
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
|
||||
),
|
||||
) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation, and a switcher you have to scroll back up to find isn't one.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.weight(1f),
|
||||
focused = tabFocused,
|
||||
)
|
||||
// The base-layer marker, where a full "Default settings" heading used to eat a
|
||||
// headline row on EVERY tab. The honesty it carried stays: this screen edits
|
||||
// the defaults only — the console honours a host's profile but doesn't edit
|
||||
// profiles (design §5.4) — and this quiet chip at the strip's end says so
|
||||
// without a second heading repeating the tab pill's own word.
|
||||
Text(
|
||||
"Defaults",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ink.fg(0.45f),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 10.dp, end = ConsoleEdgeInset),
|
||||
)
|
||||
}
|
||||
if (landscape) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
tabbedContent(Modifier.weight(0.6f))
|
||||
// The focused row's description, in the width a wide phone wastes — beside
|
||||
// the rows instead of floating over the list's tail (the portrait band).
|
||||
// Presentation only: the row already merges this text into its own
|
||||
// announcement, so the pane is hidden from a screen reader like the band.
|
||||
val focusedRow = rows.getOrNull(focus)
|
||||
AnimatedContent(
|
||||
targetState = if (tabFocused) null else focusedRow,
|
||||
transitionSpec = {
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.semantics { hideFromAccessibility() },
|
||||
label = "sideDetail",
|
||||
) { r ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(start = 6.dp, end = ConsoleEdgeInset, top = 22.dp),
|
||||
) {
|
||||
if (r != null && r.detail.isNotBlank()) {
|
||||
Text(
|
||||
r.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
r.detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.6f),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tabbedContent(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,13 +557,17 @@ fun GamepadSettingsScreen(
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ConsoleDetailBand(
|
||||
// On the strip there is no row to describe, and the pills already name the
|
||||
// sections — a stale row's description there would describe the wrong thing.
|
||||
text = if (tabFocused) "" else focused?.detail.orEmpty(),
|
||||
key = if (tabFocused) "__strip" else focused?.id,
|
||||
hazeState = hazeState,
|
||||
)
|
||||
// Portrait only: landscape's description lives in the side pane, where the wide
|
||||
// aspect has room for it — a band AND a pane would say the same thing twice.
|
||||
if (!landscape) {
|
||||
ConsoleDetailBand(
|
||||
// On the strip there is no row to describe, and the pills already name the
|
||||
// sections — a stale row's description there would describe the wrong thing.
|
||||
text = if (tabFocused) "" else focused?.detail.orEmpty(),
|
||||
key = if (tabFocused) "__strip" else focused?.id,
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
GamepadHintBar(
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', PadGlyph.Arrow, "Section"),
|
||||
@@ -497,6 +633,8 @@ private fun SettingRowView(
|
||||
adjustDir: Int,
|
||||
stepToken: Int,
|
||||
refusalToken: Int,
|
||||
/** The option drum's fixed stage — sized by the SCREEN (orientation decides how much a row can spare). */
|
||||
bandWidth: Dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
@@ -598,24 +736,31 @@ private fun SettingRowView(
|
||||
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text(
|
||||
"‹ ",
|
||||
color = ink.fg,
|
||||
Icon(
|
||||
Icons.Filled.ChevronLeft,
|
||||
// Decoration: it says "this value steps", which the row's Switch/Button
|
||||
// role already says. Left in the tree it is read out on every focused row.
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
// Decoration: it says "this value steps", which the row's Switch/Button
|
||||
// role already says. Left in the tree it is read out as punctuation on
|
||||
// every single focused row.
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
)
|
||||
Box(
|
||||
Modifier.widthIn(min = 96.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
// The value slides in the direction it was stepped, so cycling a choice
|
||||
// reads as motion through a list rather than a text swap — but its slot
|
||||
// does NOT resize with it (`snap`), which is what used to jiggle the row.
|
||||
if (row.options != null && row.selectedIndex in row.options.indices) {
|
||||
// The drum — see ConsoleOptionBand. Its width is FIXED by the row, so a
|
||||
// step can never reflow the chevrons, and the tabular-figures concern
|
||||
// dissolves with it: nothing about the row's layout depends on the label.
|
||||
ConsoleOptionBand(
|
||||
options = row.options,
|
||||
selection = row.selectedIndex,
|
||||
focused = focused,
|
||||
width = bandWidth,
|
||||
)
|
||||
} else {
|
||||
// The flat rows (profile pin counts, the empty-catalog placeholder) keep
|
||||
// the quiet slip: the changed string slides in following the motion.
|
||||
AnimatedContent(
|
||||
targetState = row.value,
|
||||
transitionSpec = {
|
||||
@@ -636,10 +781,7 @@ private fun SettingRowView(
|
||||
) { value ->
|
||||
Text(
|
||||
value,
|
||||
// Tabular figures: every digit the same width, so stepping a
|
||||
// resolution or a bitrate cannot change the text's width.
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
.copy(fontFeatureSettings = "tnum"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = valueColor,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
@@ -647,10 +789,12 @@ private fun SettingRowView(
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
" ›",
|
||||
color = ink.fg,
|
||||
Icon(
|
||||
Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
@@ -699,6 +843,8 @@ internal fun buildSettingsRows(
|
||||
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||
options.getOrNull(i)?.let { write(it.first) }
|
||||
},
|
||||
options = options.map { it.second },
|
||||
selectedIndex = idx,
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
|
||||
@@ -124,6 +124,16 @@ class ScreenshotTest {
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
* on-glass review: rows capped and left-aligned, the focused row's description in a side pane
|
||||
* on the right instead of the floating band.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
|
||||
Reference in New Issue
Block a user