35 changed files with 2769 additions and 536 deletions
-
71src/android/app/build.gradle.kts
-
230src/android/app/src/main/assets/base.svg
-
184src/android/app/src/main/java/dev/eden/emu/ui/background/RetroGridBackground.kt
-
157src/android/app/src/main/java/dev/eden/emu/ui/components/EdenTile.kt
-
259src/android/app/src/main/java/dev/eden/emu/ui/components/ExpandableSearchBar.kt
-
319src/android/app/src/main/java/dev/eden/emu/ui/components/GameCarousel.kt
-
304src/android/app/src/main/java/dev/eden/emu/ui/components/GameGrid.kt
-
30src/android/app/src/main/java/dev/eden/emu/ui/components/GameTile.kt
-
66src/android/app/src/main/java/dev/eden/emu/ui/components/HomeFooter.kt
-
186src/android/app/src/main/java/dev/eden/emu/ui/components/HomeHeader.kt
-
80src/android/app/src/main/java/dev/eden/emu/ui/components/LayoutSelectionDialog.kt
-
82src/android/app/src/main/java/dev/eden/emu/ui/components/SortSelectionDialog.kt
-
93src/android/app/src/main/java/dev/eden/emu/ui/navigation/HomeNavigationManager.kt
-
59src/android/app/src/main/java/dev/eden/emu/ui/theme/EdenTheme.kt
-
42src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeColors.kt
-
42src/android/app/src/main/java/dev/eden/emu/ui/theme/ThemeConst.kt
-
89src/android/app/src/main/java/dev/eden/emu/ui/utils/DialogButton.kt
-
17src/android/app/src/main/java/dev/eden/emu/ui/utils/KeyUtils.kt
-
10src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
-
4src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
-
2src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt
-
4src/android/app/src/main/java/org/yuzu/yuzu_emu/features/fetcher/ReleaseAdapter.kt
-
4src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
-
18src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
-
859src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
-
8src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
-
28src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt
-
4src/android/app/src/main/java/org/yuzu/yuzu_emu/views/GradientBorderCardView.kt
-
9src/android/app/src/main/res/drawable/ic_sort.xml
-
13src/android/app/src/main/res/navigation/home_navigation.xml
-
9src/android/app/src/main/res/values-de/strings.xml
-
9src/android/app/src/main/res/values/strings.xml
-
6src/android/build.gradle.kts
-
6src/core/hle/service/filesystem/filesystem.cpp
-
2src/core/hle/service/filesystem/filesystem.h
@ -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> |
|||
@ -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) |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:width="24dp" |
|||
android:height="24dp" |
|||
android:viewportWidth="24" |
|||
android:viewportHeight="24"> |
|||
<path |
|||
android:fillColor="#FFFFFF" |
|||
android:pathData="M3,18h6v-2H3v2zM3,6v2h18V6H3zm0,7h12v-2H3v2z"/> |
|||
</vector> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue