Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -353,12 +353,13 @@ private fun DownloadsSidebar(
val focusRequester = focusRequesters.getValue(selectedSection)
repeat(3) {
try {
focusRequester.requestFocus()
requestedInitialFocus = true
return@LaunchedEffect
if (focusRequester.requestFocus()) {
requestedInitialFocus = true
return@LaunchedEffect
}
} catch (_: Exception) {
delay(80)
}
delay(80)
}
}
}
Expand All @@ -370,6 +371,11 @@ private fun DownloadsTabRow(
onSectionSelected: (DownloadsSection) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequesters = remember {
sections.associateWith { FocusRequester() }
}
var requestedInitialFocus by remember { mutableStateOf(false) }
Comment thread
VinceBT marked this conversation as resolved.

Row(
modifier = modifier
.fillMaxWidth()
Expand All @@ -384,6 +390,7 @@ private fun DownloadsTabRow(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.focusRequester(focusRequesters.getValue(section))
.selectable(
selected = isSelected,
onClick = { onSectionSelected(section) },
Expand Down Expand Up @@ -425,6 +432,21 @@ private fun DownloadsTabRow(
}
}
}

LaunchedEffect(selectedSection, requestedInitialFocus) {
if (requestedInitialFocus) return@LaunchedEffect
val focusRequester = focusRequesters.getValue(selectedSection)
repeat(3) {
try {
if (focusRequester.requestFocus()) {
requestedInitialFocus = true
return@LaunchedEffect
}
} catch (_: Exception) {
}
delay(80)
}
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber

Expand Down Expand Up @@ -594,8 +595,16 @@ internal fun AppScreenContent(
scrollState.animateScrollTo(0)
}

LaunchedEffect(Unit) {
playButtonFocusRequester.requestFocus()
LaunchedEffect(displayInfo.appId) {
// Node may not be placed on the first frame, and requestFocus() returns false until it is;
// retry until it takes so the controller lands on Play (re-runs when switching games).
repeat(5) {
try {
if (playButtonFocusRequester.requestFocus()) return@LaunchedEffect
} catch (_: IllegalStateException) {
}
delay(32)
}
}

// Button state calculations (needed by key event handler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ private fun LibraryScreenContent(
// buttons would otherwise light up for ~100ms and then lose focus).
var tabBarHasFocus by remember { mutableStateOf(false) }
var lastBootstrapAtMs by remember { mutableLongStateOf(0L) }
// One-shot guard so initial-entry focus is requested only once and doesn't fight the tab-change effect.
var didInitialContentFocus by remember { mutableStateOf(false) }

fun firstVisibleContentIndex(): Int {
val lastIndex = state.appInfoList.lastIndex
Expand Down Expand Up @@ -614,6 +616,22 @@ private fun LibraryScreenContent(
previousAppCount = currentCount
}

// Focus content on first entry once the library has loaded (one-shot).
LaunchedEffect(state.appInfoList.isNotEmpty()) {
if (!didInitialContentFocus &&
state.appInfoList.isNotEmpty() &&
selectedAppId == null &&
!isSystemMenuOpen &&
!state.isOptionsPanelOpen &&
!state.isSearching
) {
didInitialContentFocus = true
// Brief delay to let the list lay out before requesting focus.
kotlinx.coroutines.delay(150)
requestContentFocusOrDefer(targetListIndex = 0)
}
}

// Restore focus when System Menu or Options Panel closes
LaunchedEffect(isSystemMenuOpen, state.isOptionsPanelOpen) {
val systemMenuJustClosed = wasSystemMenuOpen && !isSystemMenuOpen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
Expand All @@ -52,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale
import app.gamenative.R
import app.gamenative.data.RecommendedGame
import app.gamenative.ui.screen.library.components.VideoHero
import app.gamenative.ui.util.requestInitialFocus
import app.gamenative.PrefManager
import com.posthog.PostHog
import com.skydoves.landscapist.ImageOptions
Expand All @@ -67,6 +69,7 @@ internal fun RecommendedGameScreen(
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }

val media = remember(game) {
val list = mutableListOf<Pair<Boolean, String>>()
Expand Down Expand Up @@ -399,7 +402,9 @@ internal fun RecommendedGameScreen(
val browserIntent = Intent(Intent.ACTION_VIEW, game.affiliateUrl.toUri())
context.startActivity(browserIntent)
},
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.requestInitialFocus(focusRequester),
shape = RoundedCornerShape(12.dp),
) {
Icon(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -130,8 +131,15 @@ private fun TwoFactorTextField(
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current

LaunchedEffect(true) {
focusRequester.requestFocus()
LaunchedEffect(Unit) {
// Node may not be placed on the first frame; retry until requestFocus() takes.
repeat(5) {
try {
if (focusRequester.requestFocus()) return@LaunchedEffect
} catch (_: IllegalStateException) {
}
delay(32)
}
}

Column(modifier = Modifier.fillMaxWidth()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,18 @@ private fun CredentialsForm(
val passwordFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }

// Focus the username field on entry so the first controller/D-pad press is not wasted.
// Retry: the field may not be attached on the first frame.
LaunchedEffect(Unit) {
repeat(5) {
try {
if (usernameFocusRequester.requestFocus()) return@LaunchedEffect
} catch (_: IllegalStateException) {
}
delay(32)
}
}
Comment on lines +621 to +629

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems problematic - it'll bring up the keyboard on opening this screen, which will cover the QR code and isn't what we'd want by default.


Comment thread
coderabbitai[bot] marked this conversation as resolved.
Column(
modifier = Modifier
.fillMaxWidth(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
Expand All @@ -58,6 +59,7 @@ import app.gamenative.PrefManager
import app.gamenative.R
import app.gamenative.enums.AppTheme
import app.gamenative.ui.theme.PluviaTheme
import app.gamenative.ui.util.requestInitialFocus
import com.materialkolor.PaletteStyle

@Composable
Expand Down Expand Up @@ -231,6 +233,7 @@ private fun BackButton(
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val focusRequester = remember { FocusRequester() }

val scale by animateFloatAsState(
targetValue = if (isFocused) 1.1f else 1f,
Expand Down Expand Up @@ -268,6 +271,7 @@ private fun BackButton(
)
},
)
.requestInitialFocus(focusRequester)
.selectable(
selected = isFocused,
interactionSource = interactionSource,
Expand Down
34 changes: 34 additions & 0 deletions app/src/main/java/app/gamenative/ui/util/FocusModifiers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package app.gamenative.ui.util

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import kotlinx.coroutines.delay

/**
* Attaches [focusRequester] and requests focus once laid out, so a freshly opened screen already
* has a focused element. Apply BEFORE `.focusable()`/`.selectable()`/`.clickable()`.
*
* [enabled] gates the request; focus is (re)requested each time it becomes `true`.
*/
@Composable
fun Modifier.requestInitialFocus(
focusRequester: FocusRequester,
enabled: Boolean = true,
): Modifier {
LaunchedEffect(focusRequester, enabled) {
Comment thread
VinceBT marked this conversation as resolved.
if (!enabled) return@LaunchedEffect
// Node may not be placed on the first frame, and requestFocus() returns false when the
// request is not honored yet; retry until it actually takes.
repeat(5) {
try {
if (focusRequester.requestFocus()) return@LaunchedEffect
} catch (_: IllegalStateException) {
}
delay(32)
}
}
return this.focusRequester(focusRequester)
}
Loading