diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index 06c35669ce..4ad893e09a 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -5,8 +5,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later // import android.annotation.SuppressLint -import com.android.build.gradle.api.ApplicationVariant -import kotlin.collections.setOf import org.jlleitschuh.gradle.ktlint.reporter.ReporterType import com.github.triplet.gradle.androidpublisher.ReleaseStatus import org.gradle.api.tasks.Copy @@ -15,7 +13,8 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("kotlin-parcelize") - kotlin("plugin.serialization") version "1.9.20" + id("org.jetbrains.kotlin.plugin.compose") + kotlin("plugin.serialization") version "2.3.0" id("androidx.navigation.safeargs.kotlin") id("org.jlleitschuh.gradle.ktlint") version "11.4.0" id("com.github.triplet.play") version "3.8.6" @@ -43,6 +42,7 @@ android { buildFeatures { viewBinding = true + compose = true } compileOptions { @@ -50,8 +50,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } } packaging { @@ -65,7 +67,7 @@ android { defaultConfig { applicationId = "dev.eden.eden_emulator" - minSdk = 24 + minSdk = 26 targetSdk = 36 versionName = getGitVersion() versionCode = autoVersion @@ -267,6 +269,10 @@ android { } } +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") +} + idea { module { // Inclusion to exclude build/ dir from non-Android @@ -292,7 +298,7 @@ tasks.getByPath("ktlintMainSourceSetCheck").doFirst { showFormatHelp.invoke() } tasks.getByPath("loadKtlintReporters").dependsOn("ktlintReset") ktlint { - version.set("0.47.1") + version.set("0.50.0") android.set(true) ignoreFailures.set(false) disabledRules.set( @@ -317,29 +323,42 @@ play { } dependencies { - implementation("androidx.core:core-ktx:1.15.0") - implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.compose:compose-bom:2026.01.01") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3:1.4.0") + implementation("androidx.activity:activity-compose:1.12.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0") + implementation("androidx.navigation:navigation-compose:2.9.7") + implementation("io.coil-kt:coil-compose:2.7.0") + implementation("io.coil-kt:coil-svg:2.7.0") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.recyclerview:recyclerview:1.4.0") implementation("androidx.constraintlayout:constraintlayout:2.2.1") - implementation("androidx.fragment:fragment-ktx:1.8.6") - implementation("androidx.documentfile:documentfile:1.0.1") - implementation("com.google.android.material:material:1.12.0") + implementation("androidx.fragment:fragment-ktx:1.8.9") + implementation("androidx.documentfile:documentfile:1.1.0") + implementation("com.google.android.material:material:1.13.0") implementation("androidx.preference:preference-ktx:1.2.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("io.coil-kt:coil:2.2.2") - implementation("androidx.core:core-splashscreen:1.0.1") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2") - implementation("androidx.window:window:1.3.0") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - implementation("org.commonmark:commonmark:0.22.0") - implementation("androidx.navigation:navigation-fragment-ktx:2.8.9") - implementation("androidx.navigation:navigation-ui-ktx:2.8.9") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("io.coil-kt:coil:2.7.0") + implementation("androidx.core:core-splashscreen:1.2.0") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0") + implementation("androidx.window:window:1.5.1") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0") + implementation("org.commonmark:commonmark:0.27.1") + implementation("androidx.navigation:navigation-fragment-ktx:2.9.7") + implementation("androidx.navigation:navigation-ui-ktx:2.9.7") implementation("info.debatty:java-string-similarity:2.0.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") - implementation("androidx.compose.ui:ui-graphics-android:1.7.8") - implementation("androidx.compose.ui:ui-text-android:1.7.8") - implementation("net.swiftzer.semver:semver:2.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") + implementation("androidx.compose.ui:ui-graphics-android:1.10.2") + implementation("androidx.compose.ui:ui-text-android:1.10.2") + implementation("net.swiftzer.semver:semver:2.1.0") } fun runGitCommand(command: List): String { diff --git a/src/android/app/src/main/assets/base.svg b/src/android/app/src/main/assets/base.svg new file mode 100644 index 0000000000..f88b52f625 --- /dev/null +++ b/src/android/app/src/main/assets/base.svg @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/background/RetroGridBackground.kt b/src/android/app/src/main/java/dev/eden/emu/ui/background/RetroGridBackground.kt new file mode 100644 index 0000000000..6a60212f7f --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/background/RetroGridBackground.kt @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.background + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.decode.SvgDecoder +import coil.request.ImageRequest +import dev.eden.emu.ui.theme.EdenTheme +import kotlin.math.pow + +@Composable +fun RetroGridBackground( + modifier: Modifier = Modifier, + lineColor: Color = EdenTheme.colors.primary.copy(alpha = 0.2f), + spacing: Dp = 40.dp, + lineWidth: Dp = 2.dp, + speedDpPerSecond: Float = 12f, + fadeColor: Color = EdenTheme.colors.background, + logoScale: Float = 0.35f, + logoAlpha: Float = 0.25f, + horizonRatio: Float = 0.45f, + logoAssetPath: String = "file:///android_asset/base.svg", // correct way? + logoWidth: Dp? = null, + logoHeight: Dp? = null, + logoCropBottomRatio: Float = 0.33f, +) { + val context = LocalContext.current + val density = LocalDensity.current + val durationMs = remember(spacing, speedDpPerSecond, density) { + val spacingPx = spacing.value * density.density + val speedPxPerSecond = speedDpPerSecond * density.density + ((spacingPx / speedPxPerSecond) * 1000f).toInt().coerceAtLeast(300) + } + + val transition = rememberInfiniteTransition(label = "grid") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = durationMs, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "gridPhase", + ) + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val baseSize = maxWidth * logoScale + val resolvedWidth = logoWidth ?: baseSize + val resolvedHeight = logoHeight ?: baseSize + val cropRatio = logoCropBottomRatio.coerceIn(0f, 0.9f) + val visibleHeight = resolvedHeight * (1f - cropRatio) + val visibleHeightPx = with(density) { visibleHeight.toPx() } + val horizonY = maxHeight * horizonRatio + val logoOffsetY = horizonY - (resolvedHeight * 0.5f) - 50.dp + + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val spacingPx = spacing.toPx().coerceAtLeast(8f) + val strokePx = lineWidth.toPx().coerceAtLeast(1f) + val horizonScale = 0.18f + val overscan = 5.0f + val perspectivePower = 2.2f + onDrawBehind { + val width = size.width + val height = size.height + val centerX = width * 0.5f + val horizonYpx = height * horizonRatio + val depth = (height - horizonYpx).coerceAtLeast(1f) + val offset = (1f - phase) * spacingPx + val baseHalf = centerX * overscan + val horizonHalf = baseHalf * horizonScale + val lineCount = (depth / (spacingPx * 0.65f)) + .toInt() + .coerceAtLeast(20) + + var x = centerX - baseHalf - spacingPx * 6f + while (x <= centerX + baseHalf + spacingPx * 6f) { + val endX = centerX + (x - centerX) * horizonScale + drawLine( + color = lineColor, + start = Offset(x, height), + end = Offset(endX, horizonYpx), + strokeWidth = strokePx, + ) + x += spacingPx + } + + var i = 0 + while (i <= lineCount) { + val z = ((i + (offset / spacingPx)) / lineCount.toFloat()) + .coerceIn(0f, 1f) + val zCurve = z.toDouble().pow(perspectivePower.toDouble()).toFloat() + val y = horizonYpx + depth * zCurve + val halfWidth = horizonHalf + (baseHalf - horizonHalf) * z + drawLine( + color = lineColor, + start = Offset(centerX - halfWidth, y), + end = Offset(centerX + halfWidth, y), + strokeWidth = strokePx, + ) + i += 1 + } + + val fadeHeight = height * 0.12f + val solidHeight = (horizonYpx - fadeHeight).coerceAtLeast(0f) + if (solidHeight > 0f) { + drawRect( + color = fadeColor, + topLeft = Offset(0f, 0f), + size = Size(width, solidHeight), + ) + } + drawRect( + brush = Brush.verticalGradient( + colors = listOf(fadeColor, Color.Transparent), + startY = horizonYpx - fadeHeight, + endY = horizonYpx + fadeHeight, + ), + topLeft = Offset(0f, horizonYpx - fadeHeight), + size = Size(width, fadeHeight * 2f), + ) + } + }, + ) + + AsyncImage( + model = ImageRequest.Builder(context) + .data(logoAssetPath) + .decoderFactory(SvgDecoder.Factory()) + .build(), + contentDescription = null, + modifier = Modifier + .align(Alignment.TopCenter) + .requiredWidth(resolvedWidth) + .requiredHeight(resolvedHeight) + .offset(y = logoOffsetY) + .drawWithContent { + clipRect( + left = 0f, + top = 0f, + right = size.width, + bottom = visibleHeightPx, + ) { + this@drawWithContent.drawContent() + } + } + .graphicsLayer(alpha = logoAlpha), + ) + } + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/EdenTile.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/EdenTile.kt new file mode 100644 index 0000000000..6a8a5eea91 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/EdenTile.kt @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.MarqueeAnimationMode +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +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.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.GameIconUtils +import dev.eden.emu.ui.theme.EdenTheme +import dev.eden.emu.ui.theme.Shapes +import dev.eden.emu.ui.utils.ConfirmKeys +import dev.eden.emu.ui.utils.MenuKeys +import java.util.Locale + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EdenTile( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + imageUri: String? = null, + iconSize: Dp = 140.dp, + tileHeight: Dp = 168.dp, + useLargerFont: Boolean = false, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused = interactionSource.collectIsFocusedAsState().value + val borderColor = if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface + + Column( + modifier = modifier + .width(iconSize) + .height(tileHeight) + .semantics { role = Role.Button } + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyUp) { + when { + e.key in ConfirmKeys -> { onClick(); true } + e.key in MenuKeys -> { onLongClick?.invoke(); true } + else -> false + } + } else false + } + .focusable(interactionSource = interactionSource) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + onLongClick = onLongClick, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Game icon + Box( + modifier = Modifier + .size(iconSize) + .background(EdenTheme.colors.surface, Shapes.medium) + .border(BorderStroke(3.dp, borderColor), Shapes.medium) + .clip(Shapes.medium), + contentAlignment = Alignment.Center, + ) { + if (imageUri.isNullOrBlank()) { + BasicText( + text = title.take(1).uppercase(Locale.getDefault()), + style = EdenTheme.typography.title.copy(color = EdenTheme.colors.onSurface), + ) + } else { + AndroidView( + factory = { ctx -> + android.widget.ImageView(ctx).apply { + layoutParams = android.view.ViewGroup.LayoutParams(-1, -1) + scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + setImageResource(R.drawable.default_icon) + GameIconUtils.loadGameIcon(Game(title, imageUri, "0", "", "", false), this) + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + + // Game title with marquee + MarqueeText(title, isFocused, 32.dp, useLargerFont, Modifier.width(iconSize)) + } +} + +@Composable +private fun MarqueeText( + text: String, + isAnimating: Boolean, + height: Dp, + useLargerFont: Boolean, + modifier: Modifier = Modifier, +) { + val style = if (useLargerFont) { + EdenTheme.typography.body.copy(Color.White, 18.sp, fontWeight = FontWeight.Bold) + } else { + EdenTheme.typography.label.copy(Color.White) + } + + Box(modifier.height(height).clipToBounds(), Alignment.Center) { + BasicText( + text = text, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = if (isAnimating) { + Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + initialDelayMillis = 150, // does not seem to take effect + repeatDelayMillis = 1000, + velocity = 30.dp, + ) + } else Modifier, + ) + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/ExpandableSearchBar.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/ExpandableSearchBar.kt new file mode 100644 index 0000000000..c9d33f6bc5 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/ExpandableSearchBar.kt @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import org.yuzu.yuzu_emu.R +import dev.eden.emu.ui.theme.Dimens +import dev.eden.emu.ui.theme.EdenTheme +import dev.eden.emu.ui.utils.ConfirmKeys + +/** + * Expandable search bar for controller-based navigation. + * ________________________________________________________________ + * Note: Focus Manager on this doesn't work good. Need a hide impl. + * when it looses focus for better Controller Experience + * ________________________________________________________________ + */ +@Composable +fun ExpandableSearchBar( + query: String, + onQueryChange: (String) -> Unit, + isExpanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val searchHint = context.getString(R.string.home_search_games) + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + val iconInteractionSource = remember { MutableInteractionSource() } + val isIconFocused by iconInteractionSource.collectIsFocusedAsState() + + // Track if we have an active filter (submitted search, if keyboard just hides, it kinda breaks) + val hasActiveFilter = query.isNotEmpty() && !isExpanded + val textFieldFocusRequester = remember { FocusRequester() } + + // Focus text field when expanded + LaunchedEffect(isExpanded) { + if (isExpanded) { + delay(100) + try { + textFieldFocusRequester.requestFocus() + keyboardController?.show() + } catch (_: Exception) {} + } + } + + // Use a fixed width container to prevent layout shift. + Box( + modifier = modifier.width(if (isExpanded) 300.dp else 48.dp), + contentAlignment = Alignment.CenterEnd, + ) { + if (isExpanded) { + Row( + modifier = Modifier + .width(300.dp) + .clip(RoundedCornerShape(24.dp)) + .background(EdenTheme.colors.surface) + .border( + width = 2.dp, + color = EdenTheme.colors.primary, + shape = RoundedCornerShape(24.dp) + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(16.dp)) + + // Text input - NO onFocusChanged to prevent auto-close issues + // (Needs to be "submitted", hiding keyboard breaks focus flow) + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .focusRequester(textFieldFocusRequester) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyUp) { + when (keyEvent.key) { + Key.Escape, Key.Back -> { + onQueryChange("") + onExpandedChange(false) + keyboardController?.hide() + focusManager.clearFocus() + true + } + Key.Enter, Key.NumPadEnter -> { + keyboardController?.hide() + onExpandedChange(false) + focusManager.clearFocus() + true + } + else -> false + } + } else false + }, + textStyle = TextStyle( + color = Color.White, + fontSize = 16.sp, + ), + cursorBrush = SolidColor(EdenTheme.colors.primary), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + onExpandedChange(false) + focusManager.clearFocus() + } + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.padding(vertical = 14.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (query.isEmpty()) { + androidx.compose.foundation.text.BasicText( + text = searchHint, + style = TextStyle( + color = Color.White.copy(alpha = 0.5f), + fontSize = 16.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + innerTextField() + } + } + ) + + // Clear/Close button (X icon) + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .clickable { + onQueryChange("") + onExpandedChange(false) + keyboardController?.hide() + focusManager.clearFocus() + } + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_clear), + contentDescription = context.getString(R.string.home_clear), + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp), + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + } + } else { + // Collapsed state: search icon + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background( + if (isIconFocused) EdenTheme.colors.primary + else EdenTheme.colors.surface + ) + .focusRequester(focusRequester) + .focusable(interactionSource = iconInteractionSource) + .clickable( + interactionSource = iconInteractionSource, + indication = null, + ) { + if (hasActiveFilter) { + onQueryChange("") + } else { + onExpandedChange(true) + } + } + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyUp && keyEvent.key in ConfirmKeys) { + if (hasActiveFilter) { + onQueryChange("") + } else { + onExpandedChange(true) + } + true + } else false + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_search), + contentDescription = context.getString(R.string.home_search), + tint = if (hasActiveFilter) EdenTheme.colors.primary else Color.White, + modifier = Modifier.size(24.dp), + ) + + // Active filter indicator dot + if (hasActiveFilter) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(10.dp) + .clip(CircleShape) + .background(EdenTheme.colors.primary) + ) + } + } + } + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/GameCarousel.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/GameCarousel.kt new file mode 100644 index 0000000000..f18b0142b0 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/GameCarousel.kt @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import androidx.compose.foundation.gestures.snapping.SnapPosition +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlin.math.abs +import kotlin.math.cos +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun GameCarousel( + gameTiles: List, + onGameClick: (GameTile) -> Unit, + onGameLongClick: (GameTile) -> Unit = {}, + focusRequester: FocusRequester, + onNavigateUp: () -> Unit = {}, + modifier: Modifier = Modifier, + initialFocusedIndex: Int = 0, + onFocusedIndexChanged: (Int) -> Unit = {}, + onShowGameInfo: (GameTile) -> Unit = {}, +) { + if (gameTiles.isEmpty()) return + + val safeInitialIndex = initialFocusedIndex.coerceIn(0, (gameTiles.size - 1).coerceAtLeast(0)) + + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = safeInitialIndex, + initialFirstVisibleItemScrollOffset = 0 + ) + val snapFlingBehavior = rememberSnapFlingBehavior( + lazyListState = listState, + snapPosition = SnapPosition.Center + ) + val coroutineScope = rememberCoroutineScope() + + var centerItemIndex by remember { mutableIntStateOf(safeInitialIndex) } + + LaunchedEffect(centerItemIndex) { + onFocusedIndexChanged(centerItemIndex) + } + + suspend fun centerOnIndex(index: Int, animate: Boolean = true) { + if (gameTiles.isEmpty()) return + if (listState.layoutInfo.visibleItemsInfo.none { it.index == index }) { + if (animate) { + listState.animateScrollToItem(index) + } else { + listState.scrollToItem(index) + } + } + delay(10) + val layoutInfo = listState.layoutInfo + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return + val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2 + val desiredStart = viewportCenter - itemInfo.size / 2 + if (itemInfo.offset != desiredStart) { + val scrollOffset = -(viewportCenter - itemInfo.size / 2) + if (animate) { + listState.animateScrollToItem(index, scrollOffset = scrollOffset) + } else { + listState.scrollToItem(index, scrollOffset = scrollOffset) + } + } + } + + LaunchedEffect(Unit) { + listState.scrollToItem(safeInitialIndex) + delay(50) + centerOnIndex(safeInitialIndex, animate = false) + } + + val focusRequesters = remember(gameTiles.size) { + List(gameTiles.size) { FocusRequester() } + } + + var isFocusTriggeredScroll by remember { mutableStateOf(false) } + + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .collect { isScrolling -> + if (!isScrolling && !isFocusTriggeredScroll && gameTiles.isNotEmpty()) { + val firstVisible = listState.firstVisibleItemIndex + val scrollOffset = listState.firstVisibleItemScrollOffset + val layoutInfo = listState.layoutInfo + val firstVisibleItem = layoutInfo.visibleItemsInfo.firstOrNull() + val itemSize = firstVisibleItem?.size ?: 1 + val newCenterIndex = if (scrollOffset > itemSize / 2) { + (firstVisible + 1).coerceAtMost(gameTiles.size - 1) + } else { + firstVisible + } + if (newCenterIndex != centerItemIndex && newCenterIndex in focusRequesters.indices) { + centerItemIndex = newCenterIndex + try { + focusRequesters[newCenterIndex].requestFocus() + } catch (_: Exception) { } + } + } + // Reset the flag when scroll stops + if (!isScrolling) { + isFocusTriggeredScroll = false + } + } + } + + // Carousel settings (matching original CarouselRecyclerView) + val borderScale = 0.6f + val borderAlpha = 0.35f + val overlapFactor = 0.15f + + BoxWithConstraints( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val density = LocalDensity.current + val screenWidthPx = with(density) { maxWidth.toPx() } + val screenHeightPx = with(density) { maxHeight.toPx() } + + val tileTextHeight = 40.dp + val tileSpacing = 6.dp + + // Card size + val totalTextAreaPx = with(density) { (tileTextHeight + tileSpacing).toPx() } + val cardSizePx = (screenHeightPx - totalTextAreaPx) * 0.7f + val cardSize = with(density) { cardSizePx.toDp() } + val tileHeight = cardSize + tileSpacing + tileTextHeight + + // Horizontal padding to center first/last card + val horizontalPaddingPx = (screenWidthPx - cardSizePx) / 2f + val horizontalPadding = with(density) { horizontalPaddingPx.toDp() } + + // Negative spacing for overlap, no overlap currently tho + val overlapPx = cardSizePx * overlapFactor + val itemSpacing = with(density) { (-overlapPx).toDp() } + + val centerPushPx = cardSizePx * 0.12f + + val screenCenterPx = screenWidthPx / 2f + + val itemWidthWithSpacing = cardSizePx - overlapPx + + LazyRow( + state = listState, + modifier = Modifier.fillMaxSize(), + flingBehavior = snapFlingBehavior, + horizontalArrangement = Arrangement.spacedBy(itemSpacing), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, top = 30.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + itemsIndexed(gameTiles, key = { _, tile -> tile.id }) { index, tile -> + val distanceFromCenter by remember { + derivedStateOf { + val itemInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == index } + if (itemInfo == null) { + 1f + } else { + val itemLeftInScreen = itemInfo.offset.toFloat() + horizontalPaddingPx + val itemCenterInScreen = itemLeftInScreen + itemInfo.size / 2f + + val distance = abs(itemCenterInScreen - screenCenterPx) + + (distance / screenCenterPx).coerceIn(0f, 1f) + } + } + } + + val shapedScale = cos(distanceFromCenter * Math.PI * 0.5).toFloat().coerceIn(0f, 1f) + val scale = borderScale + (1f - borderScale) * shapedScale + + val shapedAlpha = cos(distanceFromCenter * Math.PI * 0.5).toFloat().coerceIn(0f, 1f) + val alpha = borderAlpha + (1f - borderAlpha) * shapedAlpha + + val zIndex = 100f * (1f - distanceFromCenter) + + val itemInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == index } + val isLeftOfCenter = if (itemInfo != null) { + val itemLeftInScreen = itemInfo.offset.toFloat() + horizontalPaddingPx + val itemCenterInScreen = itemLeftInScreen + itemInfo.size / 2f + itemCenterInScreen < screenCenterPx + } else { + index < (gameTiles.size / 2) + } + val pushFactor = (1f - distanceFromCenter) * distanceFromCenter * 4f + val horizontalPush = centerPushPx * pushFactor * (if (isLeftOfCenter) -1f else 1f) + + val tileModifier = if (index == centerItemIndex) { + Modifier + .focusRequester(focusRequester) + .focusRequester(focusRequesters[index]) + } else { + Modifier.focusRequester(focusRequesters[index]) + } + + Box( + modifier = Modifier + .size(width = cardSize, height = tileHeight) + .zIndex(zIndex) + .onFocusChanged { focusState -> + if (focusState.hasFocus && centerItemIndex != index) { + isFocusTriggeredScroll = true + centerItemIndex = index + coroutineScope.launch { + centerOnIndex(index, animate = true) + } + } + } + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown) { + when (keyEvent.key) { + Key.ButtonX -> { + onShowGameInfo(tile) + true + } + Key.DirectionUp, Key.DirectionUpLeft, Key.DirectionUpRight -> { + onNavigateUp() + true + } + Key.DirectionLeft -> { + if (index > 0) { + focusRequesters[index - 1].requestFocus() + } + true + } + Key.DirectionRight -> { + if (index < gameTiles.size - 1) { + focusRequesters[index + 1].requestFocus() + } + true + } + else -> false + } + } else { + false + } + } + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + translationX = horizontalPush + }, + contentAlignment = Alignment.Center, + ) { + EdenTile( + title = tile.title, + iconSize = cardSize, + tileHeight = tileHeight, + onClick = { + if (index == centerItemIndex) { + onGameClick(tile) + } else { + isFocusTriggeredScroll = true + centerItemIndex = index + coroutineScope.launch { + centerOnIndex(index, animate = true) + } + focusRequesters[index].requestFocus() + } + }, + onLongClick = { + if (index == centerItemIndex) { + onGameLongClick(tile) + } else { + isFocusTriggeredScroll = true + centerItemIndex = index + coroutineScope.launch { + centerOnIndex(index, animate = true) + onGameLongClick(tile) + } + focusRequesters[index].requestFocus() + } + }, + modifier = tileModifier, + imageUri = tile.iconUri, + useLargerFont = true, + ) + } + } + } + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/GameGrid.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/GameGrid.kt new file mode 100644 index 0000000000..4e87c7b2c2 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/GameGrid.kt @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import org.yuzu.yuzu_emu.R +import dev.eden.emu.ui.theme.EdenTheme +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun GameGrid( + gameTiles: List, + onGameClick: (GameTile) -> Unit, + onGameLongClick: (GameTile) -> Unit = {}, + onNavigateToSettings: () -> Unit = {}, + focusRequester: FocusRequester, + onNavigateUp: () -> Unit = {}, + rowCount: Int = 2, + tileIconSize: Dp = 100.dp, + paddingTop: Dp = 0.dp, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + emptyMessage: String? = null, + initialFocusedIndex: Int = 0, + onFocusedIndexChanged: (Int) -> Unit = {}, + onShowGameInfo: (GameTile) -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxSize() + ) { + + when { + isLoading -> LoadingMessage() + emptyMessage != null -> EmptyMessage(emptyMessage) + else -> { + GameGridContent( + gameTiles = gameTiles, + onGameClick = onGameClick, + onGameLongClick = onGameLongClick, + focusRequester = focusRequester, + onNavigateUp = onNavigateUp, + rowCount = rowCount, + tileIconSize = tileIconSize, + paddingTop = paddingTop, + initialFocusedIndex = initialFocusedIndex, + onFocusedIndexChanged = onFocusedIndexChanged, + onXPress = { index -> + gameTiles.getOrNull(index)?.let { tile -> + onShowGameInfo(tile) + } + }, + ) + } + } + } +} + +@Composable +private fun GameGridContent( + gameTiles: List, + onGameClick: (GameTile) -> Unit, + onGameLongClick: (GameTile) -> Unit = {}, + focusRequester: FocusRequester, + onNavigateUp: () -> Unit, + rowCount: Int, + tileIconSize: Dp, + paddingTop: Dp, + initialFocusedIndex: Int = 0, + onFocusedIndexChanged: (Int) -> Unit = {}, + onXPress: (Int) -> Unit = {}, +) { + val safeInitialIndex = initialFocusedIndex.coerceIn(0, (gameTiles.size - 1).coerceAtLeast(0)) + var focusedIndex by remember { mutableIntStateOf(safeInitialIndex) } + + val gridState = rememberLazyGridState( + initialFirstVisibleItemIndex = safeInitialIndex + ) + + LaunchedEffect(gameTiles.size) { + if (focusedIndex >= gameTiles.size) { + focusedIndex = 0 + gridState.scrollToItem(0) + } + } + + val tileFocusRequesters = remember(gameTiles.size) { + List(gameTiles.size) { FocusRequester() } + } + + LaunchedEffect(focusRequester) { + if (tileFocusRequesters.isNotEmpty()) { + focusRequester.requestFocus() + } + } + + LaunchedEffect(Unit) { + if (tileFocusRequesters.isNotEmpty() && safeInitialIndex in tileFocusRequesters.indices) { + gridState.scrollToItem(safeInitialIndex) + tileFocusRequesters[safeInitialIndex].requestFocus() + } + } + + LaunchedEffect(focusedIndex) { + if (focusedIndex in tileFocusRequesters.indices) { + tileFocusRequesters[focusedIndex].requestFocus() + onFocusedIndexChanged(focusedIndex) + } + } + + val outerPaddingTop = 62.dp + val outerPaddingBottom = 16.dp + val outerPaddingHorizontal = 0.dp + val gridVerticalSpacing = 0.dp + val gridVerticalPadding = 12.dp + val spacerHeight = 2.dp + val minTextHeight = 42.dp + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding( + top = outerPaddingTop, + start = outerPaddingHorizontal, + end = outerPaddingHorizontal, + bottom = outerPaddingBottom + ) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown && gameTiles.isNotEmpty()) { + val currentRow = focusedIndex % rowCount + + when (keyEvent.key) { + Key.ButtonX -> { + onXPress(focusedIndex) + true + } + Key.DirectionRight, Key.D -> { + val nextIndex = focusedIndex + rowCount + if (nextIndex < gameTiles.size) { + focusedIndex = nextIndex + } + true + } + Key.DirectionLeft, Key.A -> { + val prevIndex = focusedIndex - rowCount + if (prevIndex >= 0) { + focusedIndex = prevIndex + } + true + } + Key.DirectionDown, Key.S -> { + val nextIndex = focusedIndex + 1 + if (nextIndex < gameTiles.size && nextIndex % rowCount != 0) { + focusedIndex = nextIndex + } + true + } + Key.DirectionUp, Key.W -> { + if (currentRow > 0) { + focusedIndex -= 1 + true + } else { + onNavigateUp() + true + } + } + else -> false + } + } else { + false + } + }, + contentAlignment = Alignment.TopStart + ) { + val availableHeight = maxHeight + val heightForRows = (availableHeight - (gridVerticalPadding * 2) - + (gridVerticalSpacing * (rowCount - 1))).coerceAtLeast(0.dp) + val perRowHeight = heightForRows.safeDiv(rowCount) + val iconMaxSize = tileIconSize * 2 + val iconSizeForGrid = (perRowHeight - spacerHeight - minTextHeight) + .coerceAtLeast(96.dp) + .coerceAtMost(iconMaxSize) + val tileHeight = perRowHeight.coerceAtLeast(iconSizeForGrid + spacerHeight + minTextHeight) + + LazyHorizontalGrid( + rows = GridCells.Fixed(rowCount), + state = gridState, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = gridVerticalPadding), + ) { + itemsIndexed(gameTiles, key = { _, tile -> tile.id }) { index, tile -> + val tileModifier = if (index == focusedIndex) { + Modifier + .focusRequester(focusRequester) + .focusRequester(tileFocusRequesters[index]) + } else { + Modifier.focusRequester(tileFocusRequesters[index]) + } + + EdenTile( + title = tile.title, + iconSize = iconSizeForGrid, + tileHeight = tileHeight, + onClick = { + focusedIndex = index + onGameClick(tile) + }, + onLongClick = { + focusedIndex = index + onGameLongClick(tile) + }, + modifier = tileModifier, + imageUri = tile.iconUri, + ) + } + } + } +} + +@Composable +private fun LoadingMessage() { + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.padding(bottom = 16.dp), + color = EdenTheme.colors.primary, + ) + BasicText( + text = context.getString(R.string.loading), + style = EdenTheme.typography.body.copy(color = EdenTheme.colors.onBackground), + ) + } +} + +@Composable +private fun EmptyMessage(message: String) { + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BasicText( + text = message, + style = EdenTheme.typography.body.copy(color = EdenTheme.colors.onBackground), + ) + BasicText( + text = context.getString(R.string.manage_game_folders), + style = EdenTheme.typography.label.copy(color = EdenTheme.colors.onBackground), + modifier = Modifier.padding(top = 8.dp) + ) + } +} + +private fun Dp.safeDiv(divisor: Int): Dp = if (divisor > 0) this / divisor else 0.dp diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/GameTile.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/GameTile.kt new file mode 100644 index 0000000000..d5119bf150 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/GameTile.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import org.yuzu.yuzu_emu.model.Game + +data class GameTile( + val id: String, + val title: String, + val uri: String? = null, + val iconUri: String? = null, + val programId: Long = 0L, + val developer: String = "", + val version: String = "", + val isHomebrew: Boolean = false, +) { + companion object { + fun fromGame(game: Game): GameTile = GameTile( + id = game.path, + title = game.title, + uri = game.path, + iconUri = game.path, + programId = game.programId.toLongOrNull() ?: 0L, + developer = game.developer, + version = game.version, + isHomebrew = game.isHomebrew, + ) + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/HomeFooter.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/HomeFooter.kt new file mode 100644 index 0000000000..8e6ea915eb --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/HomeFooter.kt @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.eden.emu.ui.theme.Dimens +import org.yuzu.yuzu_emu.R + +enum class FooterButton(@DrawableRes val iconRes: Int) { + A(R.drawable.facebutton_a), + B(R.drawable.facebutton_b), + X(R.drawable.facebutton_x), + Y(R.drawable.facebutton_y), +} + +@Composable +fun HomeFooter( + actions: List, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = Dimens.paddingLg, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(Dimens.paddingXl), + verticalAlignment = Alignment.CenterVertically, + ) { + actions.forEach { action -> + Row( + modifier = Modifier.padding(horizontal = Dimens.paddingXs, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = action.button.iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(Color.White.copy(alpha = 0.9f)), + ) + BasicText(action.text, style = TextStyle(Color.White.copy(0.7f), 14.sp)) + } + } + } +} + +data class FooterAction(val button: FooterButton, val text: String) +data class FooterFocusRequesters(val primaryAction: FocusRequester, val secondaryAction: FocusRequester) diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/HomeHeader.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/HomeHeader.kt new file mode 100644 index 0000000000..7d050f6720 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/HomeHeader.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import dev.eden.emu.ui.theme.Dimens +import dev.eden.emu.ui.theme.EdenTheme +import dev.eden.emu.ui.theme.Shapes +import dev.eden.emu.ui.utils.ConfirmKeys +import java.io.File + +@Composable +fun HomeHeader( + currentUser: String, + onUserClick: () -> Unit, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSortClick: () -> Unit, + onFilterClick: () -> Unit, + onSettingsClick: () -> Unit, + focusRequesters: HeaderFocusRequesters, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var username by remember { mutableStateOf("Eden") } + var imagePath by remember { mutableStateOf(null) } + + LaunchedEffect(currentUser) { + val uuid = currentUser.ifEmpty { NativeLibrary.getCurrentUser() ?: "" } + if (uuid.isNotEmpty()) { + username = NativeLibrary.getUserUsername(uuid) ?: "Eden" + imagePath = NativeLibrary.getUserImagePath(uuid) + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = Dimens.paddingXl, vertical = Dimens.paddingMd), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + UserProfileButton(username, imagePath, onUserClick, focusRequesters.userButton) + + Row( + horizontalArrangement = Arrangement.spacedBy(Dimens.paddingLg), + verticalAlignment = Alignment.CenterVertically, + ) { + ExpandableSearchBar(searchQuery, onSearchQueryChange, isSearchExpanded, onSearchExpandedChange, focusRequesters.searchButton) + HeaderIconButton(R.drawable.ic_sort, context.getString(R.string.home_sort), onSortClick, focusRequesters.sortButton) + HeaderIconButton(R.drawable.ic_filter, context.getString(R.string.home_layout), onFilterClick, focusRequesters.filterButton) + HeaderIconButton(R.drawable.ic_settings, context.getString(R.string.home_settings), onSettingsClick, focusRequesters.settingsButton) + } + } +} + +@Composable +private fun UserProfileButton( + username: String, + imagePath: String?, + onClick: () -> Unit, + focusRequester: FocusRequester, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val bitmap = remember(imagePath) { + imagePath?.takeIf { it.isNotEmpty() }?.let { path -> + File(path).takeIf { it.exists() }?.let { BitmapFactory.decodeFile(path) } + } + } + + val defaultBitmap = remember { + NativeLibrary.getDefaultAccountBackupJpeg().let { BitmapFactory.decodeByteArray(it, 0, it.size) } + } + + Row( + modifier = Modifier + .focusRequester(focusRequester) + .focusable(interactionSource = interactionSource) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyDown && e.key in ConfirmKeys) { onClick(); true } else false + } + .clickable(interactionSource, null, onClick = onClick) + .background(if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface, Shapes.medium) + .border(Dimens.borderWidth, if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface, Shapes.medium) + .padding(horizontal = Dimens.paddingMd, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(Dimens.paddingMd), + verticalAlignment = Alignment.CenterVertically, + ) { + (bitmap ?: defaultBitmap)?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(46.dp).clip(CircleShape) + ) + } + BasicText(username, style = EdenTheme.typography.body.copy(fontSize = 18.sp, color = Color.White)) + } +} + +@Composable +fun HeaderIconButton( + iconRes: Int, + contentDescription: String, + onClick: () -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + Box( + modifier = modifier + .size(Dimens.iconLg) + .focusRequester(focusRequester) + .focusable(interactionSource = interactionSource) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyDown && e.key in ConfirmKeys) { onClick(); true } else false + } + .clip(CircleShape) + .background(if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface) + .clickable(interactionSource, null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(iconRes), + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(Dimens.iconSm), + ) + } +} + +data class HeaderFocusRequesters( + val userButton: FocusRequester, + val searchButton: FocusRequester, + val sortButton: FocusRequester, + val filterButton: FocusRequester, + val settingsButton: FocusRequester, +) diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/LayoutSelectionDialog.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/LayoutSelectionDialog.kt new file mode 100644 index 0000000000..ad5346df04 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/LayoutSelectionDialog.kt @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import org.yuzu.yuzu_emu.R +import dev.eden.emu.ui.theme.Dimens +import dev.eden.emu.ui.theme.EdenTheme +import dev.eden.emu.ui.theme.Shapes +import dev.eden.emu.ui.utils.DialogButton + +enum class LayoutMode { TWO_ROW, CAROUSEL /*, ONE_ROW */ } + +@Composable +fun LayoutSelectionDialog( + onDismiss: () -> Unit, + onSelectGrid: () -> Unit, + onSelectCarousel: () -> Unit, +) { + val context = LocalContext.current + val focusRequesters = remember { List(3) { FocusRequester() } } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)) { + Column( + modifier = Modifier + .clip(Shapes.large) + .background(EdenTheme.colors.surface) + .padding(Dimens.paddingXl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BasicText( + text = context.getString(R.string.home_layout), + style = EdenTheme.typography.title.copy(color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + ) + + Spacer(Modifier.height(Dimens.paddingXl)) + + DialogButton( + text = context.getString(R.string.view_grid), + onClick = { onSelectGrid(); onDismiss() }, + focusRequester = focusRequesters[0], + ) + + Spacer(Modifier.height(Dimens.paddingMd)) + + DialogButton( + text = context.getString(R.string.view_carousel), + onClick = { onSelectCarousel(); onDismiss() }, + focusRequester = focusRequesters[1], + ) + + Spacer(Modifier.height(Dimens.paddingXl)) + + DialogButton( + text = context.getString(R.string.cancel), + onClick = onDismiss, + focusRequester = focusRequesters[2], + isSecondary = true, + ) + } + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/components/SortSelectionDialog.kt b/src/android/app/src/main/java/dev/eden/emu/ui/components/SortSelectionDialog.kt new file mode 100644 index 0000000000..30c5b87882 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/components/SortSelectionDialog.kt @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import org.yuzu.yuzu_emu.R +import dev.eden.emu.ui.theme.Dimens +import dev.eden.emu.ui.theme.EdenTheme +import dev.eden.emu.ui.theme.Shapes +import dev.eden.emu.ui.utils.DialogButton + +enum class SortMode { LAST_PLAYED, ALPHABETICAL } + +@Composable +fun SortSelectionDialog( + currentSortMode: SortMode, + onDismiss: () -> Unit, + onSelectSort: (SortMode) -> Unit, +) { + val context = LocalContext.current + val focusRequesters = remember { List(3) { FocusRequester() } } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)) { + Column( + modifier = Modifier + .clip(Shapes.large) + .background(EdenTheme.colors.surface) + .padding(Dimens.paddingXl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BasicText( + text = context.getString(R.string.home_sort), + style = EdenTheme.typography.title.copy(color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + ) + + Spacer(Modifier.height(Dimens.paddingXl)) + + DialogButton( + text = context.getString(R.string.search_recently_played), + onClick = { onSelectSort(SortMode.LAST_PLAYED); onDismiss() }, + focusRequester = focusRequesters[0], + isSelected = currentSortMode == SortMode.LAST_PLAYED, + ) + + Spacer(Modifier.height(Dimens.paddingMd)) + + DialogButton( + text = context.getString(R.string.alphabetical), + onClick = { onSelectSort(SortMode.ALPHABETICAL); onDismiss() }, + focusRequester = focusRequesters[1], + isSelected = currentSortMode == SortMode.ALPHABETICAL, + ) + + Spacer(Modifier.height(Dimens.paddingXl)) + + DialogButton( + text = context.getString(R.string.cancel), + onClick = onDismiss, + focusRequester = focusRequesters[2], + isSecondary = true, + ) + } + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/navigation/HomeNavigationManager.kt b/src/android/app/src/main/java/dev/eden/emu/ui/navigation/HomeNavigationManager.kt new file mode 100644 index 0000000000..ddbcb8a893 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/navigation/HomeNavigationManager.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.navigation + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.* +import dev.eden.emu.ui.components.HeaderFocusRequesters +import dev.eden.emu.ui.utils.NavKeys + +/** Navigation zones for the home screen */ +enum class NavigationZone { HEADER, CONTENT/*, FOOTER */ } + +/** + * Manages focus and navigation between Header and Content zones. + * Uses MutableState for reactive UI updates. + * (Default Focus Manager breaks and is based on "placement", not suitable) + */ +class HomeNavigationManager( + private val headerFocusRequesters: HeaderFocusRequesters, + private val contentFocusRequester: FocusRequester, +) { + private val _currentZone: MutableState = mutableStateOf(NavigationZone.CONTENT) + val currentZone: NavigationZone get() = _currentZone.value + + private val _headerIndex: MutableState = mutableIntStateOf(0) + val headerIndex: Int get() = _headerIndex.value + + // Expose state for Compose observation + val currentZoneState: MutableState get() = _currentZone + val headerIndexState: MutableState get() = _headerIndex + + private val headerElements = listOf( + headerFocusRequesters.userButton, + headerFocusRequesters.searchButton, + headerFocusRequesters.sortButton, + headerFocusRequesters.filterButton, + headerFocusRequesters.settingsButton, + ) + + fun navigateUp() { + if (_currentZone.value == NavigationZone.CONTENT) { + _currentZone.value = NavigationZone.HEADER + headerElements.getOrNull(_headerIndex.value)?.requestFocus() + } + } + + fun navigateDown() { + if (_currentZone.value == NavigationZone.HEADER) { + _currentZone.value = NavigationZone.CONTENT + contentFocusRequester.requestFocus() + } + } + + fun navigateLeft() { + if (_currentZone.value == NavigationZone.HEADER && _headerIndex.value > 0) { + _headerIndex.value-- + headerElements.getOrNull(_headerIndex.value)?.requestFocus() + } + } + + fun navigateRight() { + if (_currentZone.value == NavigationZone.HEADER && _headerIndex.value < headerElements.lastIndex) { + _headerIndex.value++ + headerElements.getOrNull(_headerIndex.value)?.requestFocus() + } + } + + fun requestInitialFocus() { + _currentZone.value = NavigationZone.CONTENT + contentFocusRequester.requestFocus() + } + + fun resetToContent() { + _currentZone.value = NavigationZone.CONTENT + } +} + +/** Handle D-Pad navigation for home screen */ +fun handleHomeNavigation(keyEvent: KeyEvent, nav: HomeNavigationManager): Boolean { + if (keyEvent.type != KeyEventType.KeyDown) return false + + return when (keyEvent.key) { + in NavKeys.up -> { nav.navigateUp(); true } + in NavKeys.down -> { nav.navigateDown(); true } + in NavKeys.left -> if (nav.currentZone == NavigationZone.HEADER) { nav.navigateLeft(); true } else false + in NavKeys.right -> if (nav.currentZone == NavigationZone.HEADER) { nav.navigateRight(); true } else false + else -> false + } +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/theme/EdenTheme.kt b/src/android/app/src/main/java/dev/eden/emu/ui/theme/EdenTheme.kt new file mode 100644 index 0000000000..7c2f262af6 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/theme/EdenTheme.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +private val LocalEdenTypography = staticCompositionLocalOf { + EdenTypography( + title = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + ), + body = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + ), + label = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + ), + ) +} + +object EdenTheme { + val colors: EdenColors + @Composable get() = LocalEdenColors.current + + val typography: EdenTypography + @Composable get() = LocalEdenTypography.current +} + +@Composable +fun EdenTheme( + colors: EdenColors = LocalEdenColors.current, + typography: EdenTypography = LocalEdenTypography.current, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalEdenColors provides colors, + LocalEdenTypography provides typography, + content = content, + ) +} + +data class EdenTypography( + val title: TextStyle, + val body: TextStyle, + val label: TextStyle, +) diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeColors.kt b/src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeColors.kt new file mode 100644 index 0000000000..e93274e391 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeColors.kt @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +val EdenBrandColor = Color(0xFFA161F3) +val EdenSplashColor = EdenBrandColor +val EdenGridLine = Color(0x2AA161F3) +val EdenBackground = Color(0xFF191521) +val EdenSurface = Color(0xFF221C2B) +val EdenSurfaceVariant = Color(0xFF2D2538) +val EdenOnBackground = Color(0xFFEAE5F2) +val EdenOnSurface = Color(0xFFEAE5F2) +val EdenOnSurfaceVariant = Color(0xFFB0A8BA) +val EdenOnPrimary = Color(0xFFFFFFFF) + +internal val LocalEdenColors = staticCompositionLocalOf { + EdenColors( + background = EdenBackground, + surface = EdenSurface, + surfaceVariant = EdenSurfaceVariant, + primary = EdenBrandColor, + onBackground = EdenOnBackground, + onSurface = EdenOnSurface, + onSurfaceVariant = EdenOnSurfaceVariant, + onPrimary = EdenOnPrimary, + ) +} + +data class EdenColors( + val background: Color, + val surface: Color, + val surfaceVariant: Color, + val primary: Color, + val onBackground: Color, + val onSurface: Color, + val onSurfaceVariant: Color, + val onPrimary: Color, +) diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeConst.kt b/src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeConst.kt new file mode 100644 index 0000000000..f57e68c9ec --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeConst.kt @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +// Grid background +val EdenGridSpacing = 58.dp +val EdenGridLineWidth = 2.dp +const val EdenGridSpeedDpPerSecond = 12f + +// Common dimensions +object Dimens { + // Padding + val paddingXs = 4.dp + val paddingSm = 8.dp + val paddingMd = 12.dp + val paddingLg = 16.dp + val paddingXl = 24.dp + + // Icon sizes + val iconSm = 24.dp + val iconMd = 32.dp + val iconLg = 48.dp + + // Border + val borderWidth = 2.dp + + // Corner radius + val radiusSm = 8.dp + val radiusMd = 12.dp + val radiusLg = 16.dp +} + +// Common shapes +object Shapes { + val small = RoundedCornerShape(Dimens.radiusSm) + val medium = RoundedCornerShape(Dimens.radiusMd) + val large = RoundedCornerShape(Dimens.radiusLg) +} diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/utils/DialogButton.kt b/src/android/app/src/main/java/dev/eden/emu/ui/utils/DialogButton.kt new file mode 100644 index 0000000000..4e286d54d1 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/utils/DialogButton.kt @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.utils + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.eden.emu.ui.theme.Dimens +import dev.eden.emu.ui.theme.EdenTheme +import dev.eden.emu.ui.theme.Shapes + +/** + * New dialog button for consistent styling across dialogs + */ +@Composable +fun DialogButton( + text: String, + onClick: () -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + isSecondary: Boolean = false, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val backgroundColor = when { + isFocused -> EdenTheme.colors.primary + isSelected -> EdenTheme.colors.primary.copy(alpha = 0.3f) + isSecondary -> Color.Transparent + else -> EdenTheme.colors.background + } + + val borderColor = when { + isFocused || isSelected -> EdenTheme.colors.primary + isSecondary -> EdenTheme.colors.onBackground.copy(alpha = 0.3f) + else -> EdenTheme.colors.onBackground.copy(alpha = 0.2f) + } + + Box( + modifier = modifier + .fillMaxWidth() + .clip(Shapes.medium) + .background(backgroundColor) + .border(Dimens.borderWidth, borderColor, Shapes.medium) + .focusRequester(focusRequester) + .focusable(interactionSource = interactionSource) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key in confirmKeys) { + onClick() + true + } else false + } + .clickable(interactionSource, null, onClick = onClick) + .padding(vertical = 14.dp, horizontal = 20.dp), + contentAlignment = Alignment.Center, + ) { + BasicText( + text = if (isSelected && !isSecondary) "✓ $text" else text, + style = EdenTheme.typography.body.copy(color = Color.White, fontSize = 16.sp) + ) + } +} + +private val confirmKeys = setOf(Key.Enter, Key.DirectionCenter, Key.ButtonA) diff --git a/src/android/app/src/main/java/dev/eden/emu/ui/utils/KeyUtils.kt b/src/android/app/src/main/java/dev/eden/emu/ui/utils/KeyUtils.kt new file mode 100644 index 0000000000..39f7fc99d1 --- /dev/null +++ b/src/android/app/src/main/java/dev/eden/emu/ui/utils/KeyUtils.kt @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package dev.eden.emu.ui.utils + +import androidx.compose.ui.input.key.Key + +val ConfirmKeys = setOf(Key.Enter, Key.DirectionCenter, Key.ButtonA) +val MenuKeys = setOf(Key.ButtonX, Key.Menu) +val BackKeys = setOf(Key.Escape, Key.Back, Key.ButtonB) + +object NavKeys { + val up = setOf(Key.DirectionUp, Key.W) + val down = setOf(Key.DirectionDown, Key.S) + val left = setOf(Key.DirectionLeft, Key.A) + val right = setOf(Key.DirectionRight, Key.D) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt index 54fb45bd87..3b8cc82c80 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -18,6 +18,8 @@ import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager import android.content.res.Configuration import android.os.LocaleList +import coil.ImageLoader +import coil.ImageLoaderFactory import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.DocumentsTree @@ -28,7 +30,7 @@ import java.util.Locale fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir -class YuzuApplication : Application() { +class YuzuApplication : Application(), ImageLoaderFactory { private fun createNotificationChannels() { val name: CharSequence = getString(R.string.app_notification_channel_name) val description = getString(R.string.app_notification_channel_description) @@ -76,6 +78,12 @@ class YuzuApplication : Application() { createNotificationChannels() } + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .crossfade(true) + .build() + } + companion object { var documentsTree: DocumentsTree? = null lateinit var application: YuzuApplication diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt index 42d4f687f4..d344e98d43 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -649,7 +649,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager fun launch(activity: AppCompatActivity, game: Game) { val launcher = Intent(activity, EmulationActivity::class.java) - launcher.putExtra(EXTRA_SELECTED_GAME, game) + launcher.putExtra("game", game) // Use "game" to match navigation argus activity.startActivity(launcher) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt index 992f8f2a16..511a19a27e 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt @@ -42,7 +42,7 @@ class QuickSettings(val emulationFragment: EmulationFragment) { statusText.setTextColor( MaterialColors.getColor( statusText, - com.google.android.material.R.attr.colorPrimary + androidx.appcompat.R.attr.colorPrimary ) ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/fetcher/ReleaseAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/fetcher/ReleaseAdapter.kt index 23b980d302..efd9679005 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/fetcher/ReleaseAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/fetcher/ReleaseAdapter.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.features.fetcher @@ -171,7 +171,7 @@ class ReleaseAdapter( iconTint = ColorStateList.valueOf( MaterialColors.getColor( this, - com.google.android.material.R.attr.colorPrimary + androidx.appcompat.R.attr.colorPrimary ) ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 0c091fdeb9..99e003f36a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -1310,7 +1310,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { setTextColor( MaterialColors.getColor( this, - com.google.android.material.R.attr.colorPrimary + androidx.appcompat.R.attr.colorPrimary ) ) } @@ -1497,7 +1497,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { setTextColor( MaterialColors.getColor( this, - com.google.android.material.R.attr.colorPrimary + androidx.appcompat.R.attr.colorPrimary ) ) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt index 39ff038034..c2ca9f0737 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -61,16 +61,26 @@ class GamesViewModel : ViewModel() { } fun setGames(games: List) { + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + val sortedList = games.sortedWith( - compareBy( - { it.title.lowercase(Locale.getDefault()) }, - { it.path } - ) + compareByDescending { game -> + preferences.getLong(game.keyLastPlayedTime, 0L) + }.thenBy { it.title.lowercase(Locale.getDefault()) } + .thenBy { it.path } ) _games.value = sortedList } + fun resortGames() { + val currentGames = _games.value + if (currentGames.isNotEmpty()) { + setGames(currentGames) + _shouldScrollToTop.value = true + } + } + fun setShouldSwapData(shouldSwap: Boolean) { _shouldSwapData.value = shouldSwap } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt index 6931a1f9d5..880ebd898a 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt @@ -3,565 +3,468 @@ package org.yuzu.yuzu_emu.ui -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.PopupMenu -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.core.widget.doOnTextChanged +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController +import java.util.Locale import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import org.yuzu.yuzu_emu.HomeNavigationDirections +import kotlinx.coroutines.delay import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.GameAdapter -import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.model.AppletInfo -import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.ui.main.MainActivity -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.collect -import info.debatty.java.stringsimilarity.Jaccard -import info.debatty.java.stringsimilarity.JaroWinkler -import java.util.Locale -import androidx.core.content.edit -import androidx.core.view.doOnNextLayout +import dev.eden.emu.ui.background.RetroGridBackground +import dev.eden.emu.ui.components.FooterAction +import dev.eden.emu.ui.components.FooterButton +import dev.eden.emu.ui.components.GameCarousel +import dev.eden.emu.ui.components.GameGrid +import dev.eden.emu.ui.components.GameTile +import dev.eden.emu.ui.components.HeaderFocusRequesters +import dev.eden.emu.ui.components.HomeFooter +import dev.eden.emu.ui.components.HomeHeader +import dev.eden.emu.ui.components.LayoutMode +import dev.eden.emu.ui.components.LayoutSelectionDialog +import dev.eden.emu.ui.components.SortMode +import dev.eden.emu.ui.components.SortSelectionDialog +import dev.eden.emu.ui.navigation.HomeNavigationManager +import dev.eden.emu.ui.navigation.NavigationZone +import dev.eden.emu.ui.navigation.handleHomeNavigation +import dev.eden.emu.ui.theme.EdenTheme +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.model.Game +private const val PREF_LAYOUT_MODE = "home_layout_mode" +private const val PREF_SORT_MODE = "home_sort_mode" +private const val PREF_LAST_FOCUSED_INDEX = "home_last_focused_index" +private const val PREF_GAME_WAS_LAUNCHED = "home_game_was_launched" + +/** + * Compose-based Games Fragment with Header/Footer navigation + */ class GamesFragment : Fragment() { - private var _binding: FragmentGamesBinding? = null - private val binding get() = _binding!! - - private var originalHeaderTopMargin: Int? = null - private var originalHeaderBottomMargin: Int? = null - private var originalHeaderRightMargin: Int? = null - private var originalHeaderLeftMargin: Int? = null - - private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID - private var fallbackBottomInset: Int = 0 - - companion object { - private const val SEARCH_TEXT = "SearchText" - private const val PREF_SORT_TYPE = "GamesSortType" - } - private val gamesViewModel: GamesViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels() - private lateinit var gameAdapter: GameAdapter - private val preferences = - PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - - private lateinit var mainActivity: MainActivity - private val getGamesDirectory = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> - if (result != null) { - mainActivity.processGamesDir(result, true) - } - } - - private fun getCurrentViewType(): Int { - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val key = if (isLandscape) CarouselRecyclerView.CAROUSEL_VIEW_TYPE_LANDSCAPE else CarouselRecyclerView.CAROUSEL_VIEW_TYPE_PORTRAIT - val fallback = if (isLandscape) GameAdapter.VIEW_TYPE_CAROUSEL else GameAdapter.VIEW_TYPE_GRID - return preferences.getInt(key, fallback) - } - - private fun setCurrentViewType(type: Int) { - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val key = if (isLandscape) CarouselRecyclerView.CAROUSEL_VIEW_TYPE_LANDSCAPE else CarouselRecyclerView.CAROUSEL_VIEW_TYPE_PORTRAIT - preferences.edit { putInt(key, type) } - } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentGamesBinding.inflate(inflater) - return binding.root + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + EdenTheme { + HomeScreen( + gamesViewModel = gamesViewModel, + onGameLaunch = { path: String -> + val game = gamesViewModel.games.value.find { it.path == path } + if (game != null) { + // Save last played time for sorting + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putLong(game.keyLastPlayedTime, System.currentTimeMillis()) + .putBoolean(PREF_GAME_WAS_LAUNCHED, true) + .apply() + + EmulationActivity.launch( + requireActivity() as androidx.appcompat.app.AppCompatActivity, + game + ) + } + }, + onNavigateToSettings = { + findNavController().navigate(R.id.action_gamesFragment_to_homeSettingsFragment) + }, + onNavigateToUserManagement = { + findNavController().navigate(R.id.action_gamesFragment_to_profileManagerFragment) + }, + onShowGameInfo = { game: Game -> + val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game) + findNavController().navigate(action) + } + ) + } + } + } } - @SuppressLint("NotifyDataSetChanged") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) homeViewModel.setStatusBarShadeVisibility(true) - mainActivity = requireActivity() as MainActivity - - if (savedInstanceState != null) { - binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) - } - - gameAdapter = GameAdapter( - requireActivity() as AppCompatActivity - ) - - applyGridGamesBinding() - - binding.swipeRefresh.apply { - (binding.swipeRefresh as? SwipeRefreshLayout)?.setOnRefreshListener { - gamesViewModel.reloadGames(false) - } - (binding.swipeRefresh as? SwipeRefreshLayout)?.setProgressBackgroundColorSchemeColor( - com.google.android.material.color.MaterialColors.getColor( - binding.swipeRefresh, - com.google.android.material.R.attr.colorPrimary - ) - ) - (binding.swipeRefresh as? SwipeRefreshLayout)?.setColorSchemeColors( - com.google.android.material.color.MaterialColors.getColor( - binding.swipeRefresh, - com.google.android.material.R.attr.colorOnPrimary - ) - ) - post { - if (_binding == null) { - return@post - } - (binding.swipeRefresh as? SwipeRefreshLayout)?.isRefreshing = gamesViewModel.isReloading.value - } - } - - gamesViewModel.isReloading.collect(viewLifecycleOwner) { - (binding.swipeRefresh as? SwipeRefreshLayout)?.isRefreshing = it - binding.noticeText.setVisible( - visible = gamesViewModel.games.value.isEmpty() && !it, - gone = false - ) - } - gamesViewModel.games.collect(viewLifecycleOwner) { - if (it.isNotEmpty()) { - setAdapter(it) - } - } - gamesViewModel.shouldSwapData.collect( - viewLifecycleOwner, - resetState = { gamesViewModel.setShouldSwapData(false) } - ) { - if (it) { - setAdapter(gamesViewModel.games.value) - } - } - gamesViewModel.shouldScrollToTop.collect( - viewLifecycleOwner, - resetState = { gamesViewModel.setShouldScrollToTop(false) } - ) { if (it) scrollToTop() } - - gamesViewModel.shouldScrollAfterReload.collect(viewLifecycleOwner) { shouldScroll -> - if (shouldScroll) { - binding.gridGames.post { - (binding.gridGames as? CarouselRecyclerView)?.pendingScrollAfterReload = true - gameAdapter.notifyDataSetChanged() - } - gamesViewModel.setShouldScrollAfterReload(false) - } - } - - setupTopView() - - updateButtonsVisibility() - - binding.addDirectory.setOnClickListener { - getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) - } - - binding.launchQlaunch?.setOnClickListener { - launchQLaunch() - } - - setInsets() - } - - val applyGridGamesBinding = { - (binding.gridGames as? RecyclerView)?.apply { - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val currentViewType = getCurrentViewType() - val savedViewType = if (isLandscape || currentViewType != GameAdapter.VIEW_TYPE_CAROUSEL) currentViewType else GameAdapter.VIEW_TYPE_GRID - - //This prevents Grid/List views from reusing scaled or otherwise modified ViewHolders left over from the carousel. - adapter = null - recycledViewPool.clear() - - gameAdapter.setViewType(savedViewType) - currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID) - - // Set the correct layout manager - layoutManager = when (savedViewType) { - GameAdapter.VIEW_TYPE_GRID -> { - val columns = resources.getInteger(R.integer.game_columns_grid) - GridLayoutManager(context, columns) - } - GameAdapter.VIEW_TYPE_GRID_COMPACT -> { - val columns = resources.getInteger(R.integer.game_columns_grid) - GridLayoutManager(context, columns) - } - GameAdapter.VIEW_TYPE_LIST -> { - val columns = resources.getInteger(R.integer.game_columns_list) - GridLayoutManager(context, columns) - } - GameAdapter.VIEW_TYPE_CAROUSEL -> { - LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) - } - else -> throw IllegalArgumentException("Invalid view type: $savedViewType") - } - if (savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) { - (binding.gridGames as? View)?.let { it -> ViewCompat.requestApplyInsets(it)} - doOnNextLayout { //Carousel: important to avoid overlap issues - (this as? CarouselRecyclerView)?.notifyLaidOut(fallbackBottomInset) - } - } else { - (this as? CarouselRecyclerView)?.setupCarousel(false) - } - adapter = gameAdapter - lastViewType = savedViewType - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - if (_binding != null) { - outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) - } - } - - override fun onPause() { - super.onPause() - if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) { - gamesViewModel.lastScrollPosition = (binding.gridGames as? CarouselRecyclerView)?.getClosestChildPosition() ?: 0 - } } override fun onResume() { super.onResume() - if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) { - (binding.gridGames as? CarouselRecyclerView)?.setupCarousel(true) - (binding.gridGames as? CarouselRecyclerView)?.restoreScrollState(gamesViewModel.lastScrollPosition) + // Only resort games if a game was actually launched (not when coming back from settings) + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + if (prefs.getBoolean(PREF_GAME_WAS_LAUNCHED, false)) { + prefs.edit().putBoolean(PREF_GAME_WAS_LAUNCHED, false).apply() + gamesViewModel.resortGames() + } + } +} + +@Composable +private fun HomeScreen( + gamesViewModel: GamesViewModel, + onGameLaunch: (String) -> Unit, + onNavigateToSettings: () -> Unit, + onNavigateToUserManagement: () -> Unit, + onShowGameInfo: (Game) -> Unit, +) { + val context = LocalContext.current + val games by gamesViewModel.games.collectAsState() + val isLoading by gamesViewModel.isReloading.collectAsState() + val shouldScrollToTop by gamesViewModel.shouldScrollToTop.collectAsState() + + val preferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } + val savedLayoutMode = remember { + val savedValue = preferences.getString(PREF_LAYOUT_MODE, LayoutMode.CAROUSEL.name) + try { + LayoutMode.valueOf(savedValue ?: LayoutMode.CAROUSEL.name) + } catch (_: Exception) { + LayoutMode.CAROUSEL + } + } + + // Layout mode state (Grid or Carousel) + var layoutMode by remember { mutableStateOf(savedLayoutMode) } + var showLayoutDialog by remember { mutableStateOf(false) } + var showSortDialog by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var isSearchExpanded by remember { mutableStateOf(false) } + + // Game properties screen state + var selectedGameTile by remember { mutableStateOf(null) } + + // Sort mode state + val savedSortMode = remember { + val savedValue = preferences.getString(PREF_SORT_MODE, SortMode.LAST_PLAYED.name) + try { + SortMode.valueOf(savedValue ?: SortMode.LAST_PLAYED.name) + } catch (_: Exception) { + SortMode.LAST_PLAYED + } + } + var sortMode by remember { mutableStateOf(savedSortMode) } + + // Save sort mode when it changes + LaunchedEffect(sortMode) { + preferences.edit().putString(PREF_SORT_MODE, sortMode.name).apply() + } + + // Save layout mode when it changes + LaunchedEffect(layoutMode) { + preferences.edit().putString(PREF_LAYOUT_MODE, layoutMode.name).apply() + } + + // Remember last focused index across layout changes - load from preferences + val savedFocusedIndex = remember { + preferences.getInt(PREF_LAST_FOCUSED_INDEX, 0) + } + var lastFocusedIndex by remember { mutableStateOf(savedFocusedIndex) } + + LaunchedEffect(lastFocusedIndex) { + preferences.edit().putInt(PREF_LAST_FOCUSED_INDEX, lastFocusedIndex).apply() + } + + var listVersion by remember { mutableStateOf(0) } + + LaunchedEffect(sortMode) { + lastFocusedIndex = 0 + listVersion++ + } + + // Reset to first item when games are resorted (e.g. after playing a game) + // Else the sorting only takes effect after relaunch + LaunchedEffect(shouldScrollToTop) { + if (shouldScrollToTop) { + lastFocusedIndex = 0 + listVersion++ // Force complete recomposition + gamesViewModel.setShouldScrollToTop(false) + } + } + + // Load current user UUID + var currentUserUuid by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + currentUserUuid = NativeLibrary.getCurrentUser() ?: "" + } + + val headerFocusRequesters = remember { + HeaderFocusRequesters( + userButton = FocusRequester(), + searchButton = FocusRequester(), + sortButton = FocusRequester(), + filterButton = FocusRequester(), + settingsButton = FocusRequester(), + ) + } + // Don't recreate on layoutMode change - this causes zones state loss + val contentFocusRequester = remember { FocusRequester() } + + val navigationManager = remember { + HomeNavigationManager( + headerFocusRequesters = headerFocusRequesters, + contentFocusRequester = contentFocusRequester, + ) + } + + // Request initial focus + LaunchedEffect(Unit) { + delay(200) + navigationManager.requestInitialFocus() + } + + // Filter and sort games based on search query and sort mode + val filteredGames = remember(games, searchQuery, sortMode) { + val filtered = if (searchQuery.isBlank()) { + games + } else { + games.filter { game -> + game.title.lowercase(Locale.getDefault()) + .contains(searchQuery.lowercase(Locale.getDefault())) + } + } + + // Apply sorting + when (sortMode) { + SortMode.LAST_PLAYED -> { + filtered.sortedByDescending { game -> + preferences.getLong(game.keyLastPlayedTime, 0L) + } + } + + SortMode.ALPHABETICAL -> { + filtered.sortedBy { it.title.lowercase(Locale.getDefault()) } + } + } + } + + val gameTiles = remember(filteredGames) { + filteredGames.map { game -> + GameTile.fromGame(game) + } + } + + // NO key handling at top level + Box(modifier = Modifier.fillMaxSize()) { + RetroGridBackground() + + val contentOffset = 24.dp + key(listVersion, layoutMode) { + when (layoutMode) { + LayoutMode.TWO_ROW -> { + GameGrid( + gameTiles = gameTiles, + onGameClick = { tile: GameTile -> + val path = tile.uri ?: return@GameGrid + onGameLaunch(path) + }, + onGameLongClick = { tile: GameTile -> + val game = filteredGames.firstOrNull { it.path == tile.uri } + if (game != null) { + onShowGameInfo(game) + } + }, + onNavigateToSettings = {}, + focusRequester = contentFocusRequester, + onNavigateUp = { + navigationManager.navigateUp() + }, + rowCount = 2, + tileIconSize = 120.dp, + isLoading = isLoading, + emptyMessage = if (games.isEmpty() && !isLoading) context.getString(R.string.empty_gamelist) else null, + initialFocusedIndex = lastFocusedIndex, + onFocusedIndexChanged = { newIndex -> + lastFocusedIndex = newIndex + }, + onShowGameInfo = { tile: GameTile -> + val game = filteredGames.firstOrNull { it.path == tile.uri } + if (game != null) { + onShowGameInfo(game) + } + }, + modifier = Modifier + .fillMaxSize() + .padding(top = contentOffset), + ) + } + + LayoutMode.CAROUSEL -> { + GameCarousel( + gameTiles = gameTiles, + onGameClick = { tile: GameTile -> + val path = tile.uri ?: return@GameCarousel + onGameLaunch(path) + }, + onGameLongClick = { tile: GameTile -> + val game = filteredGames.firstOrNull { it.path == tile.uri } + if (game != null) { + onShowGameInfo(game) + } + }, + focusRequester = contentFocusRequester, + onNavigateUp = { + navigationManager.navigateUp() + }, + initialFocusedIndex = lastFocusedIndex, + onFocusedIndexChanged = { newIndex -> + lastFocusedIndex = newIndex + }, + onShowGameInfo = { tile: GameTile -> + val game = filteredGames.firstOrNull { it.path == tile.uri } + if (game != null) { + onShowGameInfo(game) + } + }, + modifier = Modifier + .fillMaxSize() + .padding(top = contentOffset), + ) + } + + else -> { + // Default to Grid + GameGrid( + gameTiles = gameTiles, + onGameClick = { tile: GameTile -> + val path = tile.uri ?: return@GameGrid + onGameLaunch(path) + }, + onNavigateToSettings = {}, + focusRequester = contentFocusRequester, + onNavigateUp = { + navigationManager.navigateUp() + }, + rowCount = 2, + tileIconSize = 120.dp, + isLoading = isLoading, + emptyMessage = if (games.isEmpty() && !isLoading) context.getString(R.string.empty_gamelist) else null, + modifier = Modifier + .fillMaxSize() + .padding(top = contentOffset), + ) + } + } + + // Refocus content when layout mode changes + LaunchedEffect(layoutMode) { + delay(200) + contentFocusRequester.requestFocus() + navigationManager.resetToContent() + } + + // Refocus content when sort mode changes + LaunchedEffect(sortMode) { + delay(200) + contentFocusRequester.requestFocus() + navigationManager.resetToContent() + } + + // Header + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .onPreviewKeyEvent { keyEvent -> + if (navigationManager.currentZone == NavigationZone.HEADER) { + handleHomeNavigation(keyEvent, navigationManager) + } else { + false + } + } + ) { + HomeHeader( + currentUser = currentUserUuid, + onUserClick = onNavigateToUserManagement, + searchQuery = searchQuery, + onSearchQueryChange = { query -> searchQuery = query }, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = { expanded -> isSearchExpanded = expanded }, + onSortClick = { + showSortDialog = true + }, + onFilterClick = { + showLayoutDialog = true + }, + onSettingsClick = onNavigateToSettings, + focusRequesters = headerFocusRequesters, + ) + } + + val currentZone by navigationManager.currentZoneState + val headerIdx by navigationManager.headerIndexState + val footerActions = when (currentZone) { + NavigationZone.HEADER -> { + val headerText = when (headerIdx) { + 0 -> context.getString(R.string.footer_open) // User profile + 1 -> context.getString(R.string.home_search) // Search + else -> context.getString(R.string.footer_open) // Sort, Filter, Settings + } + listOf(FooterAction(FooterButton.A, headerText)) + } + NavigationZone.CONTENT -> { + listOf( + FooterAction(FooterButton.A, context.getString(R.string.home_start)), + FooterAction(FooterButton.X, context.getString(R.string.footer_game_info)), + ) + } + } + HomeFooter( + actions = footerActions, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + + // Layout selection dialog + if (showLayoutDialog) { + LayoutSelectionDialog( + onDismiss = { showLayoutDialog = false }, + onSelectGrid = { layoutMode = LayoutMode.TWO_ROW }, + onSelectCarousel = { layoutMode = LayoutMode.CAROUSEL }, + ) + } + + // Sort selection dialog + if (showSortDialog) { + SortSelectionDialog( + currentSortMode = sortMode, + onDismiss = { showSortDialog = false }, + onSelectSort = { selectedSortMode -> + sortMode = selectedSortMode + gamesViewModel.resortGames() + }, + ) } } - - private var lastSearchText: String = "" - private var lastFilter: Int = preferences.getInt(PREF_SORT_TYPE, View.NO_ID) - - private fun setAdapter(games: List) { - val currentSearchText = binding.searchText.text.toString() - val currentFilter = binding.filterButton.id - - val searchChanged = currentSearchText != lastSearchText - val filterChanged = currentFilter != lastFilter - - if (searchChanged || filterChanged) { - filterAndSearch(games) - lastSearchText = currentSearchText - lastFilter = currentFilter - } else { - ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games) - gamesViewModel.setFilteredGames(games) - } - } - - private fun setupTopView() { - binding.searchText.doOnTextChanged() { text: CharSequence?, _: Int, _: Int, _: Int -> - if (text.toString().isNotEmpty()) { - binding.clearButton.visibility = View.VISIBLE - } else { - binding.clearButton.visibility = View.INVISIBLE - } - filterAndSearch() - } - - binding.clearButton.setOnClickListener { binding.searchText.setText("") } - binding.searchBackground.setOnClickListener { focusSearch() } - - // Setup view button - binding.viewButton.setOnClickListener { showViewMenu(it) } - - // Setup filter button - binding.filterButton.setOnClickListener { view -> - showFilterMenu(view) - } - - // Setup settings button - binding.settingsButton.setOnClickListener { navigateToSettings() } - } - - private fun navigateToSettings() { - val navController = findNavController() - navController.navigate(R.id.action_gamesFragment_to_homeSettingsFragment) - } - - private fun showViewMenu(anchor: View) { - val popup = PopupMenu(requireContext(), anchor) - popup.menuInflater.inflate(R.menu.menu_game_views, popup.menu) - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - if (!isLandscape) { - popup.menu.findItem(R.id.view_carousel)?.isVisible = false - } - - val currentViewType = getCurrentViewType() - when (currentViewType) { - GameAdapter.VIEW_TYPE_LIST -> popup.menu.findItem(R.id.view_list).isChecked = true - GameAdapter.VIEW_TYPE_GRID_COMPACT -> popup.menu.findItem(R.id.view_grid_compact).isChecked = true - GameAdapter.VIEW_TYPE_GRID -> popup.menu.findItem(R.id.view_grid).isChecked = true - GameAdapter.VIEW_TYPE_CAROUSEL -> popup.menu.findItem(R.id.view_carousel).isChecked = true - } - - popup.setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.view_grid -> { - if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) onPause() - setCurrentViewType(GameAdapter.VIEW_TYPE_GRID) - applyGridGamesBinding() - item.isChecked = true - true - } - - R.id.view_grid_compact -> { - if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) onPause() - setCurrentViewType(GameAdapter.VIEW_TYPE_GRID_COMPACT) - applyGridGamesBinding() - item.isChecked = true - true - } - - R.id.view_list -> { - if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) onPause() - setCurrentViewType(GameAdapter.VIEW_TYPE_LIST) - applyGridGamesBinding() - item.isChecked = true - true - } - - R.id.view_carousel -> { - if (!item.isChecked || getCurrentViewType() != GameAdapter.VIEW_TYPE_CAROUSEL) { - setCurrentViewType(GameAdapter.VIEW_TYPE_CAROUSEL) - applyGridGamesBinding() - item.isChecked = true - onResume() - } - true - } - - else -> false - } - } - - popup.show() - } - - private fun showFilterMenu(anchor: View) { - val popup = PopupMenu(requireContext(), anchor) - popup.menuInflater.inflate(R.menu.menu_game_filters, popup.menu) - - // Set checked state based on current filter - when (currentFilter) { - R.id.alphabetical -> popup.menu.findItem(R.id.alphabetical).isChecked = true - R.id.filter_recently_played -> popup.menu.findItem(R.id.filter_recently_played).isChecked = - true - - R.id.filter_recently_added -> popup.menu.findItem(R.id.filter_recently_added).isChecked = - true - } - - popup.setOnMenuItemClickListener { item -> - currentFilter = item.itemId - preferences.edit { putInt(PREF_SORT_TYPE, currentFilter) } - filterAndSearch() - true - } - - popup.show() - } - - // Track current filter - private var currentFilter = View.NO_ID - - private fun filterAndSearch(baseList: List = gamesViewModel.games.value) { - val filteredList: List = when (currentFilter) { - R.id.alphabetical -> baseList.sortedBy { it.title } - R.id.filter_recently_played -> { - baseList.filter { - val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) - lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } - } - R.id.filter_recently_added -> { - baseList.filter { - val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) - addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } - } - else -> baseList - } - - val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) - if (searchTerm.isEmpty()) { - ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList( - filteredList - ) - gamesViewModel.setFilteredGames(filteredList) - return - } - - val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() - val sortedList = filteredList.mapNotNull { game -> - val title = game.title.lowercase(Locale.getDefault()) - val score = searchAlgorithm.similarity(searchTerm, title) - if (score > 0.03) { - ScoredGame(score, game) - } else { - null - } - }.sortedByDescending { it.score }.map { it.item } - - ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(sortedList) - gamesViewModel.setFilteredGames(sortedList) - } - - private inner class ScoredGame(val score: Double, val item: Game) - - private fun focusSearch() { - binding.searchText.requestFocus() - val imm = requireActivity() - .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? - imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun scrollToTop() { - if (_binding != null) { - (binding.gridGames as? CarouselRecyclerView)?.smoothScrollToPosition(0) - } - } - - private fun launchQLaunch() { - try { - val appletPath = NativeLibrary.getAppletLaunchPath(AppletInfo.QLaunch.entryId) - if (appletPath.isEmpty()) { - Toast.makeText( - requireContext(), - R.string.applets_error_applet, - Toast.LENGTH_SHORT - ).show() - return - } - - NativeLibrary.setCurrentAppletId(AppletInfo.QLaunch.appletId) - - val qlaunchGame = Game( - title = getString(R.string.qlaunch_applet), - path = appletPath - ) - - val action = HomeNavigationDirections.actionGlobalEmulationActivity(qlaunchGame) - findNavController().navigate(action) - } catch (e: Exception) { - Toast.makeText( - requireContext(), - "Failed to launch QLaunch: ${e.message}", - Toast.LENGTH_SHORT - ).show() - } - } - - private fun updateButtonsVisibility() { - val showQLaunch = BooleanSetting.ENABLE_QLAUNCH_BUTTON.getBoolean() - val showFolder = BooleanSetting.ENABLE_FOLDER_BUTTON.getBoolean() - val isFirmwareAvailable = NativeLibrary.isFirmwareAvailable() - - val shouldShowQLaunch = showQLaunch && isFirmwareAvailable - binding.launchQlaunch.visibility = if (shouldShowQLaunch) View.VISIBLE else View.GONE - - binding.addDirectory.visibility = if (showFolder) View.VISIBLE else View.GONE - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) - resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) - - (binding.swipeRefresh as? SwipeRefreshLayout)?.setProgressViewEndTarget( - false, - barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) - ) - - val leftInset = barInsets.left + cutoutInsets.left - val rightInset = barInsets.right + cutoutInsets.right - val topInset = maxOf(barInsets.top, cutoutInsets.top) - - val mlpSwipe = binding.swipeRefresh.layoutParams as ViewGroup.MarginLayoutParams - mlpSwipe.leftMargin = leftInset - mlpSwipe.rightMargin = rightInset - binding.swipeRefresh.layoutParams = mlpSwipe - - val mlpHeader = binding.header.layoutParams as ViewGroup.MarginLayoutParams - - // Store original margins only once - if (originalHeaderTopMargin == null) { - originalHeaderTopMargin = mlpHeader.topMargin - originalHeaderRightMargin = mlpHeader.rightMargin - originalHeaderLeftMargin = mlpHeader.leftMargin - } - - // Always set margin as original + insets - mlpHeader.leftMargin = (originalHeaderLeftMargin ?: 0) + leftInset - mlpHeader.rightMargin = (originalHeaderRightMargin ?: 0) + rightInset - mlpHeader.topMargin = (originalHeaderTopMargin ?: 0) + topInset + resources.getDimensionPixelSize( - R.dimen.spacing_med - ) - binding.header.layoutParams = mlpHeader - - binding.noticeText.updatePadding(bottom = spacingNavigation) - - binding.gridGames.updatePadding( - top = resources.getDimensionPixelSize(R.dimen.spacing_med) - ) - - val mlpFab = binding.addDirectory.layoutParams as ViewGroup.MarginLayoutParams - val fabPadding = resources.getDimensionPixelSize(R.dimen.spacing_large) - mlpFab.leftMargin = leftInset + fabPadding - mlpFab.bottomMargin = barInsets.bottom + fabPadding - mlpFab.rightMargin = rightInset + fabPadding - binding.addDirectory.layoutParams = mlpFab - - binding.launchQlaunch?.let { qlaunchButton -> - val mlpQLaunch = qlaunchButton.layoutParams as ViewGroup.MarginLayoutParams - mlpQLaunch.leftMargin = leftInset + fabPadding - mlpQLaunch.bottomMargin = barInsets.bottom + fabPadding - qlaunchButton.layoutParams = mlpQLaunch - } - - val navInsets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) - val gestureInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures()) - val bottomInset = maxOf(navInsets.bottom, gestureInsets.bottom, cutoutInsets.bottom) - fallbackBottomInset = bottomInset - (binding.gridGames as? CarouselRecyclerView)?.notifyInsetsReady(bottomInset) - windowInsets - } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 8a4262ebe7..04dd6424be 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -124,6 +124,14 @@ class MainActivity : AppCompatActivity(), ThemeProvider { WindowCompat.setDecorFitsSystemWindows(window, false) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + // Hide status bar and navigation bar for fullscreen experience + // Maybe a setting? Reminds me: SafeArea Testing! + WindowCompat.getInsetsController(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.statusBars()) + hide(WindowInsetsCompat.Type.navigationBars()) + systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + window.statusBarColor = ContextCompat.getColor(applicationContext, android.R.color.transparent) window.navigationBarColor = diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt index d05020560a..cc5638caf5 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -25,31 +28,29 @@ import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.model.Game -class GameIconFetcher( +class GameObjectIconFetcher( private val game: Game, private val options: Options ) : Fetcher { override suspend fun fetch(): FetchResult { + val bitmap = decodeGameIcon(game.path) + ?: throw IllegalStateException("Failed to decode game icon for: ${game.title}") + return DrawableResult( - drawable = decodeGameIcon(game.path)!!.toDrawable(options.context.resources), + drawable = bitmap.toDrawable(options.context.resources), isSampled = false, dataSource = DataSource.DISK ) } - private fun decodeGameIcon(uri: String): Bitmap? { - val data = GameMetadata.getIcon(uri) - return BitmapFactory.decodeByteArray( - data, - 0, - data.size, - BitmapFactory.Options() - ) + private fun decodeGameIcon(path: String): Bitmap? { + val iconBytes = GameMetadata.getIcon(path) + return BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size) } class Factory : Fetcher.Factory { override fun create(data: Game, options: Options, imageLoader: ImageLoader): Fetcher = - GameIconFetcher(data, options) + GameObjectIconFetcher(data, options) } } @@ -61,7 +62,7 @@ object GameIconUtils { private val imageLoader = ImageLoader.Builder(YuzuApplication.appContext) .components { add(GameIconKeyer()) - add(GameIconFetcher.Factory()) + add(GameObjectIconFetcher.Factory()) } .memoryCache { MemoryCache.Builder(YuzuApplication.appContext) @@ -99,9 +100,6 @@ object GameIconUtils { R.id.shortcut_foreground, getGameIcon(lifecycleOwner, game).toDrawable(YuzuApplication.appContext.resources) ) - val inset = YuzuApplication.appContext.resources - .getDimensionPixelSize(R.dimen.icon_inset) - layerDrawable.setLayerInset(1, inset, inset, inset, inset) return IconCompat.createWithAdaptiveBitmap( layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888) ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt index 8121cfb6fa..0f8d153187 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -74,7 +74,7 @@ class GradientBorderCardView @JvmOverloads constructor( borderPaint.shader = null val typedValue = android.util.TypedValue() context.theme.resolveAttribute( - com.google.android.material.R.attr.colorPrimary, + androidx.appcompat.R.attr.colorPrimary, typedValue, true ) diff --git a/src/android/app/src/main/res/drawable/ic_sort.xml b/src/android/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000000..86a76ffe61 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml index 873438e7ae..50e5e74fb7 100644 --- a/src/android/app/src/main/res/navigation/home_navigation.xml +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -8,7 +8,7 @@ + android:label="PlatformGamesFragment"> + + + Installieren des Updates fehlgeschlagen: %1$s Suche Einstellungen + Sortieren + Layout + Start + Löschen Es wurden keine Dateien gefunden oder es wurde noch kein Spielverzeichnis ausgewählt. Spiele-Ordner verwalten Erlaubt Eden die Spieleliste zu füllen @@ -705,6 +709,11 @@ Wird der Handheld-Modus verwendet, verringert es die Auflösung und erhöht die Pfad erfolgreich gesetzt Überspringen + + Öffnen + Spielinfo + Zurück + Info Programm-ID, Entwickler, Version diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 97aa054e8a..7a52d4c159 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -240,6 +240,10 @@ Failed to install update: %1$s Search Settings + Sort + Layout + Start + Clear No files were found or no game directory has been selected yet. Manage game folders Allows Eden to populate the games list @@ -766,6 +770,11 @@ Path set successfully Skip + + Open + Game Info + Back + Info Program ID, developer, version diff --git a/src/android/build.gradle.kts b/src/android/build.gradle.kts index 051695d2d1..90234df5fc 100644 --- a/src/android/build.gradle.kts +++ b/src/android/build.gradle.kts @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later @@ -5,7 +8,8 @@ plugins { id("com.android.application") version "8.9.1" apply false id("com.android.library") version "8.1.4" apply false - id("org.jetbrains.kotlin.android") version "1.9.20" apply false + id("org.jetbrains.kotlin.android") version "2.3.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false } tasks.register("clean").configure { diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 2031052409..f068828400 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -345,7 +345,11 @@ void FileSystemController::SetPackedUpdate(ProcessId process_id, FileSys::Virtua } std::shared_ptr FileSystemController::OpenSaveDataController() { - return std::make_shared(system, CreateSaveDataFactory(ProgramId{})); + if (!system_save_data_controller) { + system_save_data_controller = + std::make_shared(system, CreateSaveDataFactory(ProgramId{})); + } + return system_save_data_controller; } std::shared_ptr FileSystemController::CreateSaveDataFactory( diff --git a/src/core/hle/service/filesystem/filesystem.h b/src/core/hle/service/filesystem/filesystem.h index ef45aec627..e93b3b6dd5 100644 --- a/src/core/hle/service/filesystem/filesystem.h +++ b/src/core/hle/service/filesystem/filesystem.h @@ -151,6 +151,8 @@ private: std::unique_ptr gamecard_placeholder; Core::System& system; + + std::shared_ptr system_save_data_controller; }; void LoopProcess(Core::System& system);