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?) val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.37" val defaultVersionName = "0.3.38"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -11,10 +11,8 @@ package com.ponzischeme89.memby.ui.settings
import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler 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.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
@@ -473,14 +472,29 @@ fun SettingsSheet(
} }
val firstFocus = remember { FocusRequester() } 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) } var shown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
shown = true 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, firstFocus,
attempts = 3, attempts = 5,
frameDelayMillis = 85L, frameDelayMillis = 48L,
) )
if (!landed) {
navigationFocusRequester?.let { requestFocusWithRetries(it, attempts = 3) }
}
} }
val state = SettingsPanelState( val state = SettingsPanelState(
@@ -719,14 +733,21 @@ fun SettingsSheet(
Box(Modifier.fillMaxSize().background(Canvas)) Box(Modifier.fillMaxSize().background(Canvas))
} }
AnimatedVisibility( // Visual entrance only. Unlike AnimatedVisibility this keeps SettingsPanelContent —
visible = shown, // and the rail node firstFocus names — composed and focusable from frame one, so
enter = if (overlay) { // the focus hand-off above never has to wait on composition.
slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150)) val reveal by animateFloatAsState(
} else { targetValue = if (shown) 1f else 0f,
fadeIn(tween(150)) animationSpec = tween(150),
}, label = "settings-reveal",
modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center), )
Box(
modifier = Modifier
.align(if (overlay) Alignment.CenterEnd else Alignment.Center)
.graphicsLayer {
alpha = reveal
if (overlay) translationX = size.width * (1f - reveal)
},
) { ) {
SettingsPanelContent( SettingsPanelContent(
state = state, state = state,
@@ -4,8 +4,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue 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.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused import androidx.compose.ui.test.assertIsNotFocused
@@ -71,10 +78,49 @@ class SettingsRailFocusTest {
compose.onNodeWithTag("settings-rail-playback").assertIsFocused() 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 @Composable
private fun Fixture() { private fun Fixture(firstFocus: FocusRequester = remember { FocusRequester() }) {
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) } var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
val firstFocus = remember { FocusRequester() }
PreviewSurface { PreviewSurface {
SettingsPanelContent( SettingsPanelContent(
state = SettingsPanelState( state = SettingsPanelState(