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