From 0b12c09deb4411bde1488b763c71952d8571cec1 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Thu, 27 Aug 2026 21:06:27 +1200 Subject: [PATCH] 0.3.38 --- app/build.gradle.kts | 2 +- .../memby/ui/settings/SettingsSheet.kt | 49 ++++++++++++------ .../ui/settings/SettingsRailFocusTest.kt | 50 ++++++++++++++++++- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d04745d..51aa9d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 72f5e7e..6f9a2d2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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)) - }, - modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center), + // 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) + }, ) { SettingsPanelContent( state = state, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt index 5dca4e7..6b23a8b 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt @@ -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(