0.3.38
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user