This commit is contained in:
ponzischeme89
2026-08-27 21:06:27 +12:00
parent 07004bd2e2
commit 0b12c09deb
3 changed files with 84 additions and 17 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.37"
val defaultVersionName = "0.3.38"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -11,10 +11,8 @@ package com.ponzischeme89.memby.ui.settings
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -62,6 +60,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
@@ -473,14 +472,29 @@ fun SettingsSheet(
}
val firstFocus = remember { FocusRequester() }
val sheetFocusManager = LocalFocusManager.current
// Drives the entrance animation only — the panel and its rail are composed from the
// first frame regardless, so [firstFocus] is attached and can be requested immediately.
// Gating the focusable tree behind an AnimatedVisibility was what made the hand-off
// race composition: from a heavy content pane (the genre grid) the rail node was not
// yet attached when the retries ran out, leaving focus null and every D-pad key
// falling through to the evicted IME.
var shown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
shown = true
requestFocusWithRetries(
// The launcher's content pane stays composed behind Settings and still holds the
// node that had focus. Clear it so a covered card cannot keep absorbing D-pad,
// then land on the rail — falling back to the app's main rail rather than leaving
// focus with no owner.
sheetFocusManager.clearFocus(force = true)
val landed = requestFocusWithRetries(
firstFocus,
attempts = 3,
frameDelayMillis = 85L,
attempts = 5,
frameDelayMillis = 48L,
)
if (!landed) {
navigationFocusRequester?.let { requestFocusWithRetries(it, attempts = 3) }
}
}
val state = SettingsPanelState(
@@ -719,14 +733,21 @@ fun SettingsSheet(
Box(Modifier.fillMaxSize().background(Canvas))
}
AnimatedVisibility(
visible = shown,
enter = if (overlay) {
slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150))
} else {
fadeIn(tween(150))
// Visual entrance only. Unlike AnimatedVisibility this keeps SettingsPanelContent —
// and the rail node firstFocus names — composed and focusable from frame one, so
// the focus hand-off above never has to wait on composition.
val reveal by animateFloatAsState(
targetValue = if (shown) 1f else 0f,
animationSpec = tween(150),
label = "settings-reveal",
)
Box(
modifier = Modifier
.align(if (overlay) Alignment.CenterEnd else Alignment.Center)
.graphicsLayer {
alpha = reveal
if (overlay) translationX = size.width * (1f - reveal)
},
modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center),
) {
SettingsPanelContent(
state = state,
@@ -4,8 +4,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
@@ -71,10 +78,49 @@ class SettingsRailFocusTest {
compose.onNodeWithTag("settings-rail-playback").assertIsFocused()
}
// The launcher's content pane stays composed behind Settings; a covered card can hold
// focus and, without the rail's explicit edge targets, absorb D-pad presses. This
// stands in for that: a focusable that grabs focus first, off to the side.
@Test
fun `rail claims focus and traps the d-pad over a covered focus stealer`() {
val railFocus = FocusRequester()
compose.setContent {
val stealer = remember { FocusRequester() }
Row {
Box(
Modifier
.testTag("covered-stealer")
.focusRequester(stealer)
.focusable(),
)
Fixture(firstFocus = railFocus)
}
LaunchedEffect(Unit) { stealer.requestFocus() }
}
compose.waitForIdle()
compose.onNodeWithTag("covered-stealer").assertIsFocused()
// The hand-off SettingsSheet performs once the panel is composed.
compose.runOnUiThread { railFocus.requestFocus() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-appearance").assertIsFocused()
// Up from the first rail item is Cancel — it must not leak back to the stealer.
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-appearance").assertIsFocused()
compose.onNodeWithTag("covered-stealer").assertIsNotFocused()
// Down stays inside the rail.
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-playback").assertIsFocused()
compose.onNodeWithTag("covered-stealer").assertIsNotFocused()
}
@Composable
private fun Fixture() {
private fun Fixture(firstFocus: FocusRequester = remember { FocusRequester() }) {
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
val firstFocus = remember { FocusRequester() }
PreviewSurface {
SettingsPanelContent(
state = SettingsPanelState(