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?)
|
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",
|
||||||
|
)
|
||||||
|
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(
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user