initial rework of the launcher, using kotlin compose
This commit is contained in:
parent
19494bc7ac
commit
0aacd0fadb
|
|
@ -5,8 +5,6 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
// import android.annotation.SuppressLint
|
// import android.annotation.SuppressLint
|
||||||
import com.android.build.gradle.api.ApplicationVariant
|
|
||||||
import kotlin.collections.setOf
|
|
||||||
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
||||||
import com.github.triplet.gradle.androidpublisher.ReleaseStatus
|
import com.github.triplet.gradle.androidpublisher.ReleaseStatus
|
||||||
import org.gradle.api.tasks.Copy
|
import org.gradle.api.tasks.Copy
|
||||||
|
|
@ -15,7 +13,8 @@ plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("kotlin-parcelize")
|
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("androidx.navigation.safeargs.kotlin")
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "11.4.0"
|
id("org.jlleitschuh.gradle.ktlint") version "11.4.0"
|
||||||
id("com.github.triplet.play") version "3.8.6"
|
id("com.github.triplet.play") version "3.8.6"
|
||||||
|
|
@ -43,6 +42,7 @@ android {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
@ -50,8 +50,10 @@ android {
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlin {
|
||||||
jvmTarget = "17"
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
|
|
@ -65,7 +67,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.eden.eden_emulator"
|
applicationId = "dev.eden.eden_emulator"
|
||||||
minSdk = 24
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionName = getGitVersion()
|
versionName = getGitVersion()
|
||||||
versionCode = autoVersion
|
versionCode = autoVersion
|
||||||
|
|
@ -267,6 +269,10 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composeCompiler {
|
||||||
|
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||||
|
}
|
||||||
|
|
||||||
idea {
|
idea {
|
||||||
module {
|
module {
|
||||||
// Inclusion to exclude build/ dir from non-Android
|
// Inclusion to exclude build/ dir from non-Android
|
||||||
|
|
@ -292,7 +298,7 @@ tasks.getByPath("ktlintMainSourceSetCheck").doFirst { showFormatHelp.invoke() }
|
||||||
tasks.getByPath("loadKtlintReporters").dependsOn("ktlintReset")
|
tasks.getByPath("loadKtlintReporters").dependsOn("ktlintReset")
|
||||||
|
|
||||||
ktlint {
|
ktlint {
|
||||||
version.set("0.47.1")
|
version.set("0.50.0")
|
||||||
android.set(true)
|
android.set(true)
|
||||||
ignoreFailures.set(false)
|
ignoreFailures.set(false)
|
||||||
disabledRules.set(
|
disabledRules.set(
|
||||||
|
|
@ -317,29 +323,42 @@ play {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.core:core-ktx:1.15.0")
|
implementation("androidx.compose:compose-bom:2026.01.01")
|
||||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
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.recyclerview:recyclerview:1.4.0")
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
implementation("androidx.fragment:fragment-ktx:1.8.6")
|
implementation("androidx.fragment:fragment-ktx:1.8.9")
|
||||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.13.0")
|
||||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0")
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||||
implementation("io.coil-kt:coil:2.2.2")
|
implementation("io.coil-kt:coil:2.7.0")
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.core:core-splashscreen:1.2.0")
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0")
|
||||||
implementation("androidx.window:window:1.3.0")
|
implementation("androidx.window:window:1.5.1")
|
||||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0")
|
||||||
implementation("org.commonmark:commonmark:0.22.0")
|
implementation("org.commonmark:commonmark:0.27.1")
|
||||||
implementation("androidx.navigation:navigation-fragment-ktx:2.8.9")
|
implementation("androidx.navigation:navigation-fragment-ktx:2.9.7")
|
||||||
implementation("androidx.navigation:navigation-ui-ktx:2.8.9")
|
implementation("androidx.navigation:navigation-ui-ktx:2.9.7")
|
||||||
implementation("info.debatty:java-string-similarity:2.0.0")
|
implementation("info.debatty:java-string-similarity:2.0.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||||
implementation("androidx.compose.ui:ui-graphics-android:1.7.8")
|
implementation("androidx.compose.ui:ui-graphics-android:1.10.2")
|
||||||
implementation("androidx.compose.ui:ui-text-android:1.7.8")
|
implementation("androidx.compose.ui:ui-text-android:1.10.2")
|
||||||
implementation("net.swiftzer.semver:semver:2.0.0")
|
implementation("net.swiftzer.semver:semver:2.1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runGitCommand(command: List<String>): String {
|
fun runGitCommand(command: List<String>): String {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg7"
|
||||||
|
sodipodi:docname="base.svg.2026_01_12_14_43_47.0.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
||||||
|
inkscape:export-filename="base.svg.2026_01_12_14_43_47.0.svg"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs7">
|
||||||
|
<linearGradient
|
||||||
|
id="linearGradient1"
|
||||||
|
inkscape:collect="always">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ff2e88;stop-opacity:0.5;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#bf42f6;stop-opacity:0.5;"
|
||||||
|
offset="0.44631511"
|
||||||
|
id="stop4" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#5da5ed;stop-opacity:0.5;"
|
||||||
|
offset="0.90088946"
|
||||||
|
id="stop2" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="linearGradient138"
|
||||||
|
inkscape:collect="always">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ff2e88;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop152" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#bf42f6;stop-opacity:1;"
|
||||||
|
offset="0.44971901"
|
||||||
|
id="stop137" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#5da5ed;stop-opacity:1;"
|
||||||
|
offset="0.89793283"
|
||||||
|
id="stop138" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="swatch37"
|
||||||
|
inkscape:swatch="solid">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop37" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="swatch28"
|
||||||
|
inkscape:swatch="solid">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#252525;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop28" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="swatch27"
|
||||||
|
inkscape:swatch="solid">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop27" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="swatch15"
|
||||||
|
inkscape:swatch="solid">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop16" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="linearGradient14"
|
||||||
|
inkscape:swatch="gradient">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop14" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop15" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="swatch9"
|
||||||
|
inkscape:swatch="solid">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop10" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="swatch8"
|
||||||
|
inkscape:swatch="solid">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop9" />
|
||||||
|
</linearGradient>
|
||||||
|
<rect
|
||||||
|
x="22.627417"
|
||||||
|
y="402.76802"
|
||||||
|
width="521.34025"
|
||||||
|
height="248.94868"
|
||||||
|
id="rect24" />
|
||||||
|
<linearGradient
|
||||||
|
id="linearGradient11"
|
||||||
|
inkscape:collect="always">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ff2e88;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop11" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#bf42f6;stop-opacity:1;"
|
||||||
|
offset="0.44971901"
|
||||||
|
id="stop154" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#5da5ed;stop-opacity:1;"
|
||||||
|
offset="0.89793283"
|
||||||
|
id="stop12" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient138"
|
||||||
|
id="linearGradient6"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.118028,0,0,1.116699,-46.314723,-42.388667)"
|
||||||
|
x1="270.39996"
|
||||||
|
y1="40.000019"
|
||||||
|
x2="270.39996"
|
||||||
|
y2="494.39996"
|
||||||
|
spreadMethod="pad" />
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath18">
|
||||||
|
<circle
|
||||||
|
style="opacity:1;mix-blend-mode:normal;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10.8382;stroke-opacity:0.566238;paint-order:stroke fill markers"
|
||||||
|
id="circle18"
|
||||||
|
cx="-246.8315"
|
||||||
|
cy="246.8338"
|
||||||
|
inkscape:label="Circle"
|
||||||
|
r="191.89999" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath22">
|
||||||
|
<circle
|
||||||
|
style="opacity:1;mix-blend-mode:normal;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10.8382;stroke-opacity:0.566238;paint-order:stroke fill markers"
|
||||||
|
id="circle22"
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
inkscape:label="Circle"
|
||||||
|
r="191.89999" />
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient11"
|
||||||
|
id="linearGradient27"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="translate(-6.9401139e-5,-2.8678628)"
|
||||||
|
x1="256.00012"
|
||||||
|
y1="102.94693"
|
||||||
|
x2="256.00012"
|
||||||
|
y2="409.05307" />
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath128">
|
||||||
|
<circle
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#03ffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="circle128"
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="192" />
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient1"
|
||||||
|
id="linearGradient2"
|
||||||
|
x1="256"
|
||||||
|
y1="64"
|
||||||
|
x2="256"
|
||||||
|
y2="448"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.3229974,0,0,1.3214002,-82.687336,-82.290326)" />
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.4142136"
|
||||||
|
inkscape:cx="261.62951"
|
||||||
|
inkscape:cy="230.87036"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1008"
|
||||||
|
inkscape:window-x="1080"
|
||||||
|
inkscape:window-y="351"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg7" />
|
||||||
|
<path
|
||||||
|
id="path8-7"
|
||||||
|
style="display:inline;mix-blend-mode:multiply;fill:url(#linearGradient6);fill-opacity:1;fill-rule:nonzero;stroke:url(#linearGradient2);stroke-width:3.9666;stroke-dasharray:none;stroke-opacity:0.566238;paint-order:stroke fill markers"
|
||||||
|
inkscape:label="Circle"
|
||||||
|
d="M 256,2.2792898 A 254.0155,253.71401 0 0 0 150.68475,25.115202 c 19.54414,1.070775 38.74692,5.250294 51.56848,11.647658 14.14361,7.056691 28.63804,19.185961 39.4212,29.347551 h 40.60981 c 1.03847,-0.68139 2.10297,-1.36938 3.1938,-2.05957 5.45602,-15.78533 14.79164,-43.183497 19.49612,-57.0097682 A 254.0155,253.71401 0 0 0 256,2.2792898 Z m 61.57106,7.567234 -18.26098,46.1544672 c 7.79702,-4.13918 16.35655,-7.87447 25.20671,-10.87081 23.1229,-7.828433 43.96931,-10.170904 54.94058,-10.868226 A 254.0155,253.71401 0 0 0 317.57106,9.8465238 Z m 65.39277,26.4001532 c -9.68256,4.806644 -33.05532,16.642034 -55.68217,29.863734 H 424.4677 A 254.0155,253.71401 0 0 0 382.96383,36.246677 Z M 113.90698,45.690231 A 254.0155,253.71401 0 0 0 87.532302,66.110411 H 194.2739 c -1.47402,-0.80231 -2.35141,-1.25949 -2.35141,-1.25949 l 10.4496,-11.83348 -38.40568,7.01234 c 0,1e-5 -12.21537,-4.60266 -40.17313,-12.27223 -3.45336,-0.94731 -6.75329,-1.61824 -9.8863,-2.06732 z m -36.803618,30.18635 a 254.0155,253.71401 0 0 0 -34.88372,43.090929 h 59.976738 c 18.11461,-12.04145 40.14252,-22.882149 62.31266,-24.534159 52.93006,-3.9444 70.16538,1.86342 70.16538,1.86342 0,0 -4.612,-4.8206 -14.51938,-13.36656 -2.72366,-2.34942 -6.0844,-4.77373 -9.52455,-7.05363 z m 174.472868,0 c 4.57322,4.7186 7.29716,7.83565 7.29716,7.83565 0,0 3.53501,-3.18484 9.62532,-7.83565 z m 60.27649,0 c -21.56573,15.45339 -25.4703,27.979669 -25.4703,27.979669 0,0 54.83326,-19.215729 100.70543,-0.31228 11.63986,4.79661 21.58481,10.13159 29.94832,15.42354 h 52.74419 A 254.0155,253.71401 0 0 0 434.89664,75.876581 Z M 36.250648,128.73367 A 254.0155,253.71401 0 0 0 16.372095,171.82459 H 147.45478 c 1.45695,-2.5815 3.06539,-5.08648 4.83979,-7.48982 14.23694,-19.28301 27.92088,-30.0088 36.86047,-35.6011 h -30.25323 c -5.87346,0.93472 -12.04945,1.99094 -18.28166,3.16937 -30.12936,5.69727 -81.157618,22.78945 -81.157618,22.78945 0,0 11.47125,-12.39249 29.11369,-25.95882 z m 265.630492,0 c 33.48676,11.2434 52.42799,26.78443 62.7752,43.09092 h 130.97157 a 254.0155,253.71401 0 0 0 -19.87856,-43.09092 h -44.81136 c 14.85233,11.5863 21.59948,20.9854 21.59948,20.9854 0,0 -33.5226,-12.37087 -66.0646,-20.9854 z m -45.96641,16.27007 c -1.00419,0.0106 -10.12705,0.72026 -44.98966,20.64729 -3.12132,1.78406 -6.25434,3.86182 -9.37468,6.17356 h 41.81911 c 7.17181,-17.34774 12.64083,-26.82085 12.64083,-26.82085 0,0 -0.0287,-7.1e-4 -0.0957,0 z m 14.18088,0.0465 c 0,0 -3.31228,9.32762 -7.30492,26.77438 h 51.78554 C 287.6577,146.14158 270.09561,145.0502 270.09561,145.0502 Z M 13.152456,181.59075 A 254.0155,253.71401 0 0 0 3.927651,224.68167 H 134.1447 c 0.56161,-12.72411 2.67825,-28.50188 8.61499,-43.09092 z m 176.661504,0 c -14.27121,13.10564 -27.60733,29.58761 -37.56073,43.09092 h 73.3721 c 4.47018,-16.79061 9.35068,-31.26371 13.86562,-43.09092 z m 70.85787,0 c -2.41384,11.76417 -4.9032,26.20707 -6.94831,43.09092 H 360.4832 c -8.32133,-10.88917 -20.66988,-26.17008 -36.35141,-43.09092 z m 109.17313,0 c 6.63611,15.24089 6.92441,30.5373 5.57882,43.09092 h 132.64857 a 254.0155,253.71401 0 0 0 -9.22481,-43.09092 z M 2.90181,234.44783 A 254.0155,253.71401 0 0 0 1.984498,255.9933 254.0155,253.71401 0 0 0 2.90181,277.53876 h 211.89923 c 2.25762,-15.52555 5.14325,-29.93448 8.3385,-43.09093 h -77.8863 c -6.46396,9.27617 -10.33076,15.56549 -10.33076,15.56549 0,0 -0.82623,-6.14945 -0.9354,-15.56549 z m 249.72093,0 c -1.3692,13.09684 -2.4456,27.49209 -3.02068,43.09093 h 259.49613 a 254.0155,253.71401 0 0 0 0.91731,-21.54546 254.0155,253.71401 0 0 0 -0.91731,-21.54547 H 374.02584 c -0.445,2.5469 -0.90878,4.89768 -1.32817,7.01751 0,0 -1.69726,-2.53821 -4.94056,-7.01751 z M 3.927651,287.30493 a 254.0155,253.71401 0 0 0 9.224805,43.09091 H 214.04393 c -1.29238,-15.40742 -1.57503,-30.04388 -0.41861,-43.09091 z m 245.385009,0 c -0.30355,13.54349 -0.22032,27.92598 0.36951,43.09091 h 249.16537 a 254.0155,253.71401 0 0 0 9.22481,-43.09091 z M 16.369511,340.16201 a 254.0155,253.71401 0 0 0 19.878554,43.09091 H 221.4677 c -2.69781,-14.4523 -4.96108,-29.01285 -6.4832,-43.09091 z m 233.842379,0 c 1.15864,15.47765 3.81286,29.83979 7.51679,43.09091 h 218.02325 a 254.0155,253.71401 0 0 0 19.87856,-43.09091 z M 42.217052,393.01909 a 254.0155,253.71401 0 0 0 34.88372,43.09093 H 233.09561 c -3.40902,-13.67281 -6.76794,-28.2531 -9.73902,-43.09093 z m 218.490958,0 c 5.34985,16.15926 12.22007,30.51982 19.68733,43.09093 h 154.50389 a 254.0155,253.71401 0 0 0 34.88371,-43.09093 z M 87.529722,445.87618 a 254.0155,253.71401 0 0 0 166.229968,63.8208 c -3.67805,-12.0825 -10.85464,-35.49828 -18.18088,-63.8208 z m 199.010328,0 c 17.5887,26.43772 36.99259,43.60598 47.33592,51.61309 a 254.0155,253.71401 0 0 0 90.59431,-51.61309 z" />
|
||||||
|
<path
|
||||||
|
id="path27"
|
||||||
|
style="display:inline;mix-blend-mode:multiply;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:url(#linearGradient27);stroke-width:3;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke fill markers"
|
||||||
|
d="m 318.98012,441.7375 c -9.87518,-6.73978 -64.39137,-49.0272 -67.68975,-127.81978 -3.69298,-88.21893 15.36468,-141.91029 15.36468,-141.91029 0,0 16.00378,0.99513 39.80316,26.53195 23.79939,25.53753 37.74965,46.43102 37.74965,46.43102 3.91262,-19.79992 12.84563,-66.32402 -60.72865,-87.55523 0,0 12.82326,-5.38883 39.3925,-3.81382 26.56907,1.57572 81.6822,21.93799 81.6822,21.93799 0,0 -14.79766,-20.63773 -49.47063,-34.94295 -34.67291,-14.30533 -76.1182,0.23644 -76.1182,0.23644 0,0 3.86959,-12.43127 27.22669,-26.38478 23.35718,-13.9537 49.27409,-26.501533 49.27409,-26.501533 0,0 -21.97854,-0.26548 -47.67725,8.44535 -6.68948,2.267506 -13.15863,5.094213 -19.05208,8.226563 l 16.05803,-40.634103 -4.4617,-1.89059 -5.1305,-0.95965 c 0,0 -11.24072,33.12428 -16.92051,49.576513 -12.13137,7.68489 -20.11005,14.87735 -20.11005,14.87735 0,0 -21.90573,-25.09227 -42.79668,-35.527803 -26.03412,-13.00525 -86.88249,-13.90359 -94.0044,10.401173 0,0 13.56804,-7.884703 34.70032,-2.080917 21.13214,5.803997 30.3644,9.287307 30.3644,9.287307 l 29.02989,-5.30681 -7.89811,8.95527 c 0,0 13.8496,7.21324 21.33822,13.68063 7.48859,6.46722 10.9757,10.11472 10.9757,10.11472 0,0 -13.02739,-4.39388 -53.03507,-1.40893 -40.00771,2.98473 -79.40016,45.60209 -79.40016,45.60209 0,0 38.57037,-12.93531 61.34393,-17.24677 22.77354,-4.31126 44.52166,-6.46757 44.52166,-6.46757 0,0 -17.23298,5.97003 -35.69792,31.00932 -18.46522,25.03987 -13.13146,64.83866 -13.13146,64.83866 0,0 29.33874,-47.7577 57.44675,-63.84249 28.10798,-16.08527 34.0799,-15.6238 34.0799,-15.6238 0,0 -22.56785,39.13486 -31.39017,101.98268 -8.03005,57.2039 26.77689,163.75449 31.1572,178.89699"
|
||||||
|
sodipodi:nodetypes="cscsccscscscsccccccscscccscscscscscsc"
|
||||||
|
inkscape:label="MainOutline"
|
||||||
|
clip-path="url(#clipPath128)"
|
||||||
|
transform="matrix(1.3229974,0,0,1.3214002,-82.687282,-82.278451)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<GameTile>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<GameTile>,
|
||||||
|
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<GameTile>,
|
||||||
|
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
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<FooterAction>,
|
||||||
|
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)
|
||||||
|
|
@ -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<String?>(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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NavigationZone> = mutableStateOf(NavigationZone.CONTENT)
|
||||||
|
val currentZone: NavigationZone get() = _currentZone.value
|
||||||
|
|
||||||
|
private val _headerIndex: MutableState<Int> = mutableIntStateOf(0)
|
||||||
|
val headerIndex: Int get() = _headerIndex.value
|
||||||
|
|
||||||
|
// Expose state for Compose observation
|
||||||
|
val currentZoneState: MutableState<NavigationZone> get() = _currentZone
|
||||||
|
val headerIndexState: MutableState<Int> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ import javax.net.ssl.TrustManagerFactory
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.LocaleList
|
import android.os.LocaleList
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
||||||
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
||||||
import org.yuzu.yuzu_emu.utils.DocumentsTree
|
import org.yuzu.yuzu_emu.utils.DocumentsTree
|
||||||
|
|
@ -28,7 +30,7 @@ import java.util.Locale
|
||||||
|
|
||||||
fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir
|
fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir
|
||||||
|
|
||||||
class YuzuApplication : Application() {
|
class YuzuApplication : Application(), ImageLoaderFactory {
|
||||||
private fun createNotificationChannels() {
|
private fun createNotificationChannels() {
|
||||||
val name: CharSequence = getString(R.string.app_notification_channel_name)
|
val name: CharSequence = getString(R.string.app_notification_channel_name)
|
||||||
val description = getString(R.string.app_notification_channel_description)
|
val description = getString(R.string.app_notification_channel_description)
|
||||||
|
|
@ -76,6 +78,12 @@ class YuzuApplication : Application() {
|
||||||
createNotificationChannels()
|
createNotificationChannels()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.crossfade(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var documentsTree: DocumentsTree? = null
|
var documentsTree: DocumentsTree? = null
|
||||||
lateinit var application: YuzuApplication
|
lateinit var application: YuzuApplication
|
||||||
|
|
|
||||||
|
|
@ -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-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
|
@ -649,7 +649,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager
|
||||||
|
|
||||||
fun launch(activity: AppCompatActivity, game: Game) {
|
fun launch(activity: AppCompatActivity, game: Game) {
|
||||||
val launcher = Intent(activity, EmulationActivity::class.java)
|
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)
|
activity.startActivity(launcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class QuickSettings(val emulationFragment: EmulationFragment) {
|
||||||
statusText.setTextColor(
|
statusText.setTextColor(
|
||||||
MaterialColors.getColor(
|
MaterialColors.getColor(
|
||||||
statusText,
|
statusText,
|
||||||
com.google.android.material.R.attr.colorPrimary
|
androidx.appcompat.R.attr.colorPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.features.fetcher
|
package org.yuzu.yuzu_emu.features.fetcher
|
||||||
|
|
@ -171,7 +171,7 @@ class ReleaseAdapter(
|
||||||
iconTint = ColorStateList.valueOf(
|
iconTint = ColorStateList.valueOf(
|
||||||
MaterialColors.getColor(
|
MaterialColors.getColor(
|
||||||
this,
|
this,
|
||||||
com.google.android.material.R.attr.colorPrimary
|
androidx.appcompat.R.attr.colorPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1310,7 +1310,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
setTextColor(
|
setTextColor(
|
||||||
MaterialColors.getColor(
|
MaterialColors.getColor(
|
||||||
this,
|
this,
|
||||||
com.google.android.material.R.attr.colorPrimary
|
androidx.appcompat.R.attr.colorPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1497,7 +1497,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
setTextColor(
|
setTextColor(
|
||||||
MaterialColors.getColor(
|
MaterialColors.getColor(
|
||||||
this,
|
this,
|
||||||
com.google.android.material.R.attr.colorPrimary
|
androidx.appcompat.R.attr.colorPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,16 +61,26 @@ class GamesViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setGames(games: List<Game>) {
|
fun setGames(games: List<Game>) {
|
||||||
|
val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
|
||||||
val sortedList = games.sortedWith(
|
val sortedList = games.sortedWith(
|
||||||
compareBy(
|
compareByDescending<Game> { game ->
|
||||||
{ it.title.lowercase(Locale.getDefault()) },
|
preferences.getLong(game.keyLastPlayedTime, 0L)
|
||||||
{ it.path }
|
}.thenBy { it.title.lowercase(Locale.getDefault()) }
|
||||||
)
|
.thenBy { it.path }
|
||||||
)
|
)
|
||||||
|
|
||||||
_games.value = sortedList
|
_games.value = sortedList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun resortGames() {
|
||||||
|
val currentGames = _games.value
|
||||||
|
if (currentGames.isNotEmpty()) {
|
||||||
|
setGames(currentGames)
|
||||||
|
_shouldScrollToTop.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setShouldSwapData(shouldSwap: Boolean) {
|
fun setShouldSwapData(shouldSwap: Boolean) {
|
||||||
_shouldSwapData.value = shouldSwap
|
_shouldSwapData.value = shouldSwap
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,565 +3,468 @@
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.ui
|
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.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.inputmethod.InputMethodManager
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import android.widget.PopupMenu
|
import androidx.compose.foundation.layout.Box
|
||||||
import android.widget.Toast
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.core.view.updatePadding
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.core.widget.doOnTextChanged
|
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.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import java.util.Locale
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import kotlinx.coroutines.delay
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
import org.yuzu.yuzu_emu.HomeNavigationDirections
|
|
||||||
import org.yuzu.yuzu_emu.NativeLibrary
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
import org.yuzu.yuzu_emu.R
|
import org.yuzu.yuzu_emu.R
|
||||||
import org.yuzu.yuzu_emu.YuzuApplication
|
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
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.model.GamesViewModel
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
import org.yuzu.yuzu_emu.model.HomeViewModel
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
import org.yuzu.yuzu_emu.ui.main.MainActivity
|
import dev.eden.emu.ui.background.RetroGridBackground
|
||||||
import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible
|
import dev.eden.emu.ui.components.FooterAction
|
||||||
import org.yuzu.yuzu_emu.utils.collect
|
import dev.eden.emu.ui.components.FooterButton
|
||||||
import info.debatty.java.stringsimilarity.Jaccard
|
import dev.eden.emu.ui.components.GameCarousel
|
||||||
import info.debatty.java.stringsimilarity.JaroWinkler
|
import dev.eden.emu.ui.components.GameGrid
|
||||||
import java.util.Locale
|
import dev.eden.emu.ui.components.GameTile
|
||||||
import androidx.core.content.edit
|
import dev.eden.emu.ui.components.HeaderFocusRequesters
|
||||||
import androidx.core.view.doOnNextLayout
|
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() {
|
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 gamesViewModel: GamesViewModel by activityViewModels()
|
||||||
private val homeViewModel: HomeViewModel 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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
_binding = FragmentGamesBinding.inflate(inflater)
|
return ComposeView(requireContext()).apply {
|
||||||
return binding.root
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
homeViewModel.setStatusBarShadeVisibility(true)
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) {
|
// Only resort games if a game was actually launched (not when coming back from settings)
|
||||||
(binding.gridGames as? CarouselRecyclerView)?.setupCarousel(true)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
(binding.gridGames as? CarouselRecyclerView)?.restoreScrollState(gamesViewModel.lastScrollPosition)
|
if (prefs.getBoolean(PREF_GAME_WAS_LAUNCHED, false)) {
|
||||||
|
prefs.edit().putBoolean(PREF_GAME_WAS_LAUNCHED, false).apply()
|
||||||
|
gamesViewModel.resortGames()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lastSearchText: String = ""
|
@Composable
|
||||||
private var lastFilter: Int = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)
|
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()
|
||||||
|
|
||||||
private fun setAdapter(games: List<Game>) {
|
val preferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
|
||||||
val currentSearchText = binding.searchText.text.toString()
|
val savedLayoutMode = remember {
|
||||||
val currentFilter = binding.filterButton.id
|
val savedValue = preferences.getString(PREF_LAYOUT_MODE, LayoutMode.CAROUSEL.name)
|
||||||
|
|
||||||
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<Game> = gamesViewModel.games.value) {
|
|
||||||
val filteredList: List<Game> = 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 {
|
try {
|
||||||
val appletPath = NativeLibrary.getAppletLaunchPath(AppletInfo.QLaunch.entryId)
|
LayoutMode.valueOf(savedValue ?: LayoutMode.CAROUSEL.name)
|
||||||
if (appletPath.isEmpty()) {
|
} catch (_: Exception) {
|
||||||
Toast.makeText(
|
LayoutMode.CAROUSEL
|
||||||
requireContext(),
|
}
|
||||||
R.string.applets_error_applet,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NativeLibrary.setCurrentAppletId(AppletInfo.QLaunch.appletId)
|
// 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) }
|
||||||
|
|
||||||
val qlaunchGame = Game(
|
// Game properties screen state
|
||||||
title = getString(R.string.qlaunch_applet),
|
var selectedGameTile by remember { mutableStateOf<GameTile?>(null) }
|
||||||
path = appletPath
|
|
||||||
|
// 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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val action = HomeNavigationDirections.actionGlobalEmulationActivity(qlaunchGame)
|
|
||||||
findNavController().navigate(action)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(
|
|
||||||
requireContext(),
|
|
||||||
"Failed to launch QLaunch: ${e.message}",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Don't recreate on layoutMode change - this causes zones state loss
|
||||||
|
val contentFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
private fun updateButtonsVisibility() {
|
val navigationManager = remember {
|
||||||
val showQLaunch = BooleanSetting.ENABLE_QLAUNCH_BUTTON.getBoolean()
|
HomeNavigationManager(
|
||||||
val showFolder = BooleanSetting.ENABLE_FOLDER_BUTTON.getBoolean()
|
headerFocusRequesters = headerFocusRequesters,
|
||||||
val isFirmwareAvailable = NativeLibrary.isFirmwareAvailable()
|
contentFocusRequester = contentFocusRequester,
|
||||||
|
|
||||||
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
|
// Request initial focus
|
||||||
mlpHeader.leftMargin = (originalHeaderLeftMargin ?: 0) + leftInset
|
LaunchedEffect(Unit) {
|
||||||
mlpHeader.rightMargin = (originalHeaderRightMargin ?: 0) + rightInset
|
delay(200)
|
||||||
mlpHeader.topMargin = (originalHeaderTopMargin ?: 0) + topInset + resources.getDimensionPixelSize(
|
navigationManager.requestInitialFocus()
|
||||||
R.dimen.spacing_med
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
)
|
)
|
||||||
binding.header.layoutParams = mlpHeader
|
}
|
||||||
|
|
||||||
binding.noticeText.updatePadding(bottom = spacingNavigation)
|
LayoutMode.CAROUSEL -> {
|
||||||
|
GameCarousel(
|
||||||
binding.gridGames.updatePadding(
|
gameTiles = gameTiles,
|
||||||
top = resources.getDimensionPixelSize(R.dimen.spacing_med)
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
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())
|
else -> {
|
||||||
val gestureInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures())
|
// Default to Grid
|
||||||
val bottomInset = maxOf(navInsets.bottom, gestureInsets.bottom, cutoutInsets.bottom)
|
GameGrid(
|
||||||
fallbackBottomInset = bottomInset
|
gameTiles = gameTiles,
|
||||||
(binding.gridGames as? CarouselRecyclerView)?.notifyInsetsReady(bottomInset)
|
onGameClick = { tile: GameTile ->
|
||||||
windowInsets
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,14 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
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 =
|
window.statusBarColor =
|
||||||
ContextCompat.getColor(applicationContext, android.R.color.transparent)
|
ContextCompat.getColor(applicationContext, android.R.color.transparent)
|
||||||
window.navigationBarColor =
|
window.navigationBarColor =
|
||||||
|
|
|
||||||
|
|
@ -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-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// 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.YuzuApplication
|
||||||
import org.yuzu.yuzu_emu.model.Game
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
|
||||||
class GameIconFetcher(
|
class GameObjectIconFetcher(
|
||||||
private val game: Game,
|
private val game: Game,
|
||||||
private val options: Options
|
private val options: Options
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
override suspend fun fetch(): FetchResult {
|
override suspend fun fetch(): FetchResult {
|
||||||
|
val bitmap = decodeGameIcon(game.path)
|
||||||
|
?: throw IllegalStateException("Failed to decode game icon for: ${game.title}")
|
||||||
|
|
||||||
return DrawableResult(
|
return DrawableResult(
|
||||||
drawable = decodeGameIcon(game.path)!!.toDrawable(options.context.resources),
|
drawable = bitmap.toDrawable(options.context.resources),
|
||||||
isSampled = false,
|
isSampled = false,
|
||||||
dataSource = DataSource.DISK
|
dataSource = DataSource.DISK
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decodeGameIcon(uri: String): Bitmap? {
|
private fun decodeGameIcon(path: String): Bitmap? {
|
||||||
val data = GameMetadata.getIcon(uri)
|
val iconBytes = GameMetadata.getIcon(path)
|
||||||
return BitmapFactory.decodeByteArray(
|
return BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
|
||||||
data,
|
|
||||||
0,
|
|
||||||
data.size,
|
|
||||||
BitmapFactory.Options()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory : Fetcher.Factory<Game> {
|
class Factory : Fetcher.Factory<Game> {
|
||||||
override fun create(data: Game, options: Options, imageLoader: ImageLoader): Fetcher =
|
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)
|
private val imageLoader = ImageLoader.Builder(YuzuApplication.appContext)
|
||||||
.components {
|
.components {
|
||||||
add(GameIconKeyer())
|
add(GameIconKeyer())
|
||||||
add(GameIconFetcher.Factory())
|
add(GameObjectIconFetcher.Factory())
|
||||||
}
|
}
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
MemoryCache.Builder(YuzuApplication.appContext)
|
MemoryCache.Builder(YuzuApplication.appContext)
|
||||||
|
|
@ -99,9 +100,6 @@ object GameIconUtils {
|
||||||
R.id.shortcut_foreground,
|
R.id.shortcut_foreground,
|
||||||
getGameIcon(lifecycleOwner, game).toDrawable(YuzuApplication.appContext.resources)
|
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(
|
return IconCompat.createWithAdaptiveBitmap(
|
||||||
layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888)
|
layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
|
@ -74,7 +74,7 @@ class GradientBorderCardView @JvmOverloads constructor(
|
||||||
borderPaint.shader = null
|
borderPaint.shader = null
|
||||||
val typedValue = android.util.TypedValue()
|
val typedValue = android.util.TypedValue()
|
||||||
context.theme.resolveAttribute(
|
context.theme.resolveAttribute(
|
||||||
com.google.android.material.R.attr.colorPrimary,
|
androidx.appcompat.R.attr.colorPrimary,
|
||||||
typedValue,
|
typedValue,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M3,18h6v-2H3v2zM3,6v2h18V6H3zm0,7h12v-2H3v2z"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/gamesFragment"
|
android:id="@+id/gamesFragment"
|
||||||
android:name="org.yuzu.yuzu_emu.ui.GamesFragment"
|
android:name="org.yuzu.yuzu_emu.ui.GamesFragment"
|
||||||
android:label="PlatformGamesFragment" />
|
android:label="PlatformGamesFragment">
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_gamesFragment_to_homeSettingsFragment"
|
android:id="@+id/action_gamesFragment_to_homeSettingsFragment"
|
||||||
app:destination="@id/homeSettingsFragment"
|
app:destination="@id/homeSettingsFragment"
|
||||||
|
|
@ -16,6 +16,14 @@
|
||||||
app:exitAnim="@anim/nav_default_exit_anim"
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_gamesFragment_to_profileManagerFragment"
|
||||||
|
app:destination="@id/profileManagerFragment"
|
||||||
|
app:enterAnim="@anim/nav_default_enter_anim"
|
||||||
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
|
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||||
|
</fragment>
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/homeSettingsFragment"
|
android:id="@+id/homeSettingsFragment"
|
||||||
|
|
@ -158,6 +166,9 @@
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_global_perGamePropertiesFragment"
|
android:id="@+id/action_global_perGamePropertiesFragment"
|
||||||
app:destination="@id/perGamePropertiesFragment" />
|
app:destination="@id/perGamePropertiesFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_global_gameInfoFragment"
|
||||||
|
app:destination="@id/gameInfoFragment" />
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/gameInfoFragment"
|
android:id="@+id/gameInfoFragment"
|
||||||
android:name="org.yuzu.yuzu_emu.fragments.GameInfoFragment"
|
android:name="org.yuzu.yuzu_emu.fragments.GameInfoFragment"
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,10 @@
|
||||||
<string name="update_install_failed">Installieren des Updates fehlgeschlagen: %1$s</string>
|
<string name="update_install_failed">Installieren des Updates fehlgeschlagen: %1$s</string>
|
||||||
<string name="home_search">Suche</string>
|
<string name="home_search">Suche</string>
|
||||||
<string name="home_settings">Einstellungen</string>
|
<string name="home_settings">Einstellungen</string>
|
||||||
|
<string name="home_sort">Sortieren</string>
|
||||||
|
<string name="home_layout">Layout</string>
|
||||||
|
<string name="home_start">Start</string>
|
||||||
|
<string name="home_clear">Löschen</string>
|
||||||
<string name="empty_gamelist">Es wurden keine Dateien gefunden oder es wurde noch kein Spielverzeichnis ausgewählt.</string>
|
<string name="empty_gamelist">Es wurden keine Dateien gefunden oder es wurde noch kein Spielverzeichnis ausgewählt.</string>
|
||||||
<string name="manage_game_folders">Spiele-Ordner verwalten</string>
|
<string name="manage_game_folders">Spiele-Ordner verwalten</string>
|
||||||
<string name="select_games_folder_description">Erlaubt Eden die Spieleliste zu füllen</string>
|
<string name="select_games_folder_description">Erlaubt Eden die Spieleliste zu füllen</string>
|
||||||
|
|
@ -705,6 +709,11 @@ Wird der Handheld-Modus verwendet, verringert es die Auflösung und erhöht die
|
||||||
<string name="path_set">Pfad erfolgreich gesetzt</string>
|
<string name="path_set">Pfad erfolgreich gesetzt</string>
|
||||||
<string name="skip_migration">Überspringen</string>
|
<string name="skip_migration">Überspringen</string>
|
||||||
|
|
||||||
|
<!-- Footer actions -->
|
||||||
|
<string name="footer_open">Öffnen</string>
|
||||||
|
<string name="footer_game_info">Spielinfo</string>
|
||||||
|
<string name="footer_back">Zurück</string>
|
||||||
|
|
||||||
<!-- Game properties -->
|
<!-- Game properties -->
|
||||||
<string name="info">Info</string>
|
<string name="info">Info</string>
|
||||||
<string name="info_description">Programm-ID, Entwickler, Version</string>
|
<string name="info_description">Programm-ID, Entwickler, Version</string>
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,10 @@
|
||||||
<string name="update_install_failed">Failed to install update: %1$s</string>
|
<string name="update_install_failed">Failed to install update: %1$s</string>
|
||||||
<string name="home_search">Search</string>
|
<string name="home_search">Search</string>
|
||||||
<string name="home_settings">Settings</string>
|
<string name="home_settings">Settings</string>
|
||||||
|
<string name="home_sort">Sort</string>
|
||||||
|
<string name="home_layout">Layout</string>
|
||||||
|
<string name="home_start">Start</string>
|
||||||
|
<string name="home_clear">Clear</string>
|
||||||
<string name="empty_gamelist">No files were found or no game directory has been selected yet.</string>
|
<string name="empty_gamelist">No files were found or no game directory has been selected yet.</string>
|
||||||
<string name="manage_game_folders">Manage game folders</string>
|
<string name="manage_game_folders">Manage game folders</string>
|
||||||
<string name="select_games_folder_description">Allows Eden to populate the games list</string>
|
<string name="select_games_folder_description">Allows Eden to populate the games list</string>
|
||||||
|
|
@ -766,6 +770,11 @@
|
||||||
<string name="path_set">Path set successfully</string>
|
<string name="path_set">Path set successfully</string>
|
||||||
<string name="skip_migration">Skip</string>
|
<string name="skip_migration">Skip</string>
|
||||||
|
|
||||||
|
<!-- Footer actions -->
|
||||||
|
<string name="footer_open">Open</string>
|
||||||
|
<string name="footer_game_info">Game Info</string>
|
||||||
|
<string name="footer_back">Back</string>
|
||||||
|
|
||||||
<!-- Game properties -->
|
<!-- Game properties -->
|
||||||
<string name="info">Info</string>
|
<string name="info">Info</string>
|
||||||
<string name="info_description">Program ID, developer, version</string>
|
<string name="info_description">Program ID, developer, version</string>
|
||||||
|
|
|
||||||
|
|
@ -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-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
|
@ -5,7 +8,8 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.9.1" apply false
|
id("com.android.application") version "8.9.1" apply false
|
||||||
id("com.android.library") version "8.1.4" 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 {
|
tasks.register("clean").configure {
|
||||||
|
|
|
||||||
|
|
@ -345,7 +345,11 @@ void FileSystemController::SetPackedUpdate(ProcessId process_id, FileSys::Virtua
|
||||||
}
|
}
|
||||||
|
|
||||||
std::shared_ptr<SaveDataController> FileSystemController::OpenSaveDataController() {
|
std::shared_ptr<SaveDataController> FileSystemController::OpenSaveDataController() {
|
||||||
return std::make_shared<SaveDataController>(system, CreateSaveDataFactory(ProgramId{}));
|
if (!system_save_data_controller) {
|
||||||
|
system_save_data_controller =
|
||||||
|
std::make_shared<SaveDataController>(system, CreateSaveDataFactory(ProgramId{}));
|
||||||
|
}
|
||||||
|
return system_save_data_controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::shared_ptr<FileSys::SaveDataFactory> FileSystemController::CreateSaveDataFactory(
|
std::shared_ptr<FileSys::SaveDataFactory> FileSystemController::CreateSaveDataFactory(
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ private:
|
||||||
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;
|
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;
|
||||||
|
|
||||||
Core::System& system;
|
Core::System& system;
|
||||||
|
|
||||||
|
std::shared_ptr<SaveDataController> system_save_data_controller;
|
||||||
};
|
};
|
||||||
|
|
||||||
void LoopProcess(Core::System& system);
|
void LoopProcess(Core::System& system);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue