Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
469c690f5f | ||
|
|
0aacd0fadb |
35 changed files with 2770 additions and 537 deletions
|
|
@ -5,8 +5,6 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// import android.annotation.SuppressLint
|
||||
import com.android.build.gradle.api.ApplicationVariant
|
||||
import kotlin.collections.setOf
|
||||
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
||||
import com.github.triplet.gradle.androidpublisher.ReleaseStatus
|
||||
import org.gradle.api.tasks.Copy
|
||||
|
|
@ -15,7 +13,8 @@ plugins {
|
|||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization") version "1.9.20"
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
kotlin("plugin.serialization") version "2.3.0"
|
||||
id("androidx.navigation.safeargs.kotlin")
|
||||
id("org.jlleitschuh.gradle.ktlint") version "11.4.0"
|
||||
id("com.github.triplet.play") version "3.8.6"
|
||||
|
|
@ -43,6 +42,7 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
|
|
@ -50,8 +50,10 @@ android {
|
|||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
|
|
@ -65,7 +67,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId = "dev.eden.eden_emulator"
|
||||
minSdk = 24
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionName = getGitVersion()
|
||||
versionCode = autoVersion
|
||||
|
|
@ -267,6 +269,10 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
}
|
||||
|
||||
idea {
|
||||
module {
|
||||
// Inclusion to exclude build/ dir from non-Android
|
||||
|
|
@ -292,7 +298,7 @@ tasks.getByPath("ktlintMainSourceSetCheck").doFirst { showFormatHelp.invoke() }
|
|||
tasks.getByPath("loadKtlintReporters").dependsOn("ktlintReset")
|
||||
|
||||
ktlint {
|
||||
version.set("0.47.1")
|
||||
version.set("0.50.0")
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
disabledRules.set(
|
||||
|
|
@ -317,29 +323,42 @@ play {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.compose:compose-bom:2026.01.01")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3:1.4.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.3")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.7")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
implementation("io.coil-kt:coil-svg:2.7.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.4.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.6")
|
||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.9")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("io.coil-kt:coil:2.2.2")
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2")
|
||||
implementation("androidx.window:window:1.3.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("org.commonmark:commonmark:0.22.0")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.8.9")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.8.9")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("io.coil-kt:coil:2.7.0")
|
||||
implementation("androidx.core:core-splashscreen:1.2.0")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0")
|
||||
implementation("androidx.window:window:1.5.1")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0")
|
||||
implementation("org.commonmark:commonmark:0.27.1")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.9.7")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.9.7")
|
||||
implementation("info.debatty:java-string-similarity:2.0.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
implementation("androidx.compose.ui:ui-graphics-android:1.7.8")
|
||||
implementation("androidx.compose.ui:ui-text-android:1.7.8")
|
||||
implementation("net.swiftzer.semver:semver:2.0.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||
implementation("androidx.compose.ui:ui-graphics-android:1.10.2")
|
||||
implementation("androidx.compose.ui:ui-text-android:1.10.2")
|
||||
implementation("net.swiftzer.semver:semver:2.1.0")
|
||||
}
|
||||
|
||||
fun runGitCommand(command: List<String>): String {
|
||||
|
|
|
|||
230
src/android/app/src/main/assets/base.svg
Normal file
230
src/android/app/src/main/assets/base.svg
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
|
|
@ -18,6 +18,8 @@ import javax.net.ssl.TrustManagerFactory
|
|||
import javax.net.ssl.X509TrustManager
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
||||
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
||||
import org.yuzu.yuzu_emu.utils.DocumentsTree
|
||||
|
|
@ -28,7 +30,7 @@ import java.util.Locale
|
|||
|
||||
fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir
|
||||
|
||||
class YuzuApplication : Application() {
|
||||
class YuzuApplication : Application(), ImageLoaderFactory {
|
||||
private fun createNotificationChannels() {
|
||||
val name: CharSequence = getString(R.string.app_notification_channel_name)
|
||||
val description = getString(R.string.app_notification_channel_description)
|
||||
|
|
@ -76,6 +78,12 @@ class YuzuApplication : Application() {
|
|||
createNotificationChannels()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var documentsTree: DocumentsTree? = null
|
||||
lateinit var application: YuzuApplication
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||
|
|
@ -649,7 +649,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager
|
|||
|
||||
fun launch(activity: AppCompatActivity, game: Game) {
|
||||
val launcher = Intent(activity, EmulationActivity::class.java)
|
||||
launcher.putExtra(EXTRA_SELECTED_GAME, game)
|
||||
launcher.putExtra("game", game) // Use "game" to match navigation argus
|
||||
activity.startActivity(launcher)
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue