From 2dd8d09c7ba38d4513f2ccf34f99e9f6f41f2124 Mon Sep 17 00:00:00 2001 From: Briar <205427297+icy-briar@users.noreply.github.com> Date: Wed, 9 Apr 2025 00:01:32 +0200 Subject: [PATCH] Refactor Android Game Screen UI * Port SearchFragment functionality to GameFragment @kleidis Co-authored-by: Briar <205427297+icybriarr@users.noreply.github.com> * Remove the bottom navigation bar and SearchFragment remaining code @ishan09811 * Add 2 new game view types `Grid & `List` to GameAdapter @kleidis * Fix padding on header * Change app name to uppercase --------- Co-authored-by: Kleidis <167202775+kleidis@users.noreply.github.com> Co-authored-by: Briar <205427297+icybriarr@users.noreply.github.com> Co-authored-by: Ishan09811 <156402647+Ishan09811@users.noreply.github.com> --- .../org/yuzu/yuzu_emu/adapters/GameAdapter.kt | 78 ++++- .../yuzu/yuzu_emu/fragments/AboutFragment.kt | 6 +- .../fragments/AddGameFolderDialogFragment.kt | 10 +- .../yuzu/yuzu_emu/fragments/AddonsFragment.kt | 5 +- .../fragments/AppletLauncherFragment.kt | 5 +- .../fragments/DriverManagerFragment.kt | 5 +- .../yuzu_emu/fragments/GameFoldersFragment.kt | 5 +- .../yuzu_emu/fragments/GameInfoFragment.kt | 5 +- .../fragments/GamePropertiesFragment.kt | 5 +- .../fragments/HomeSettingsFragment.kt | 31 +- .../yuzu_emu/fragments/InstallableFragment.kt | 5 +- .../yuzu_emu/fragments/LicensesFragment.kt | 6 +- .../yuzu/yuzu_emu/fragments/SearchFragment.kt | 218 -------------- .../yuzu/yuzu_emu/fragments/SetupFragment.kt | 5 +- .../org/yuzu/yuzu_emu/model/GamesViewModel.kt | 31 +- .../org/yuzu/yuzu_emu/model/HomeViewModel.kt | 12 +- .../org/yuzu/yuzu_emu/ui/GamesFragment.kt | 281 ++++++++++++++++-- .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 83 +----- .../app/src/main/res/drawable/ic_eye.xml | 9 + .../app/src/main/res/drawable/ic_filter.xml | 9 + .../main/res/layout-w600dp/activity_main.xml | 12 - .../app/src/main/res/layout/activity_main.xml | 13 - .../{card_game.xml => card_game_grid.xml} | 2 +- .../src/main/res/layout/card_game_list.xml | 67 +++++ .../src/main/res/layout/fragment_games.xml | 233 +++++++++++++-- .../src/main/res/layout/fragment_search.xml | 184 ------------ .../main/res/menu-w600dp/menu_navigation.xml | 19 -- .../src/main/res/menu/menu_game_filters.xml | 14 + .../app/src/main/res/menu/menu_game_views.xml | 12 + .../app/src/main/res/menu/menu_navigation.xml | 19 -- .../main/res/navigation/home_navigation.xml | 12 +- .../src/main/res/values-w600dp/integers.xml | 3 + .../app/src/main/res/values/integers.xml | 2 + .../app/src/main/res/values/strings.xml | 7 +- 34 files changed, 719 insertions(+), 694 deletions(-) delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt create mode 100644 src/android/app/src/main/res/drawable/ic_eye.xml create mode 100644 src/android/app/src/main/res/drawable/ic_filter.xml rename src/android/app/src/main/res/layout/{card_game.xml => card_game_grid.xml} (98%) create mode 100644 src/android/app/src/main/res/layout/card_game_list.xml delete mode 100644 src/android/app/src/main/res/layout/fragment_search.xml delete mode 100644 src/android/app/src/main/res/menu-w600dp/menu_navigation.xml create mode 100644 src/android/app/src/main/res/menu/menu_game_filters.xml create mode 100644 src/android/app/src/main/res/menu/menu_game_views.xml delete mode 100644 src/android/app/src/main/res/menu/menu_navigation.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt index b1f247ac36..671e897448 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.adapters @@ -16,13 +16,15 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.findNavController import androidx.preference.PreferenceManager +import androidx.viewbinding.ViewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.yuzu.yuzu_emu.HomeNavigationDirections import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.databinding.CardGameBinding +import org.yuzu.yuzu_emu.databinding.CardGameListBinding +import org.yuzu.yuzu_emu.databinding.CardGameGridBinding import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.utils.GameIconUtils @@ -31,22 +33,70 @@ import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder class GameAdapter(private val activity: AppCompatActivity) : AbstractDiffAdapter(exact = false) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { - CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return GameViewHolder(it) } + + companion object { + const val VIEW_TYPE_GRID = 0 + const val VIEW_TYPE_LIST = 1 } - inner class GameViewHolder(val binding: CardGameBinding) : - AbstractViewHolder(binding) { + private var viewType = 0 + + fun setViewType(type: Int) { + viewType = type + notifyDataSetChanged() + } + + override fun getItemViewType(position: Int): Int = viewType + + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { + val binding = when (viewType) { + VIEW_TYPE_LIST -> CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false) + VIEW_TYPE_GRID -> CardGameGridBinding.inflate(LayoutInflater.from(parent.context), parent, false) + else -> throw IllegalArgumentException("Invalid view type") + } + return GameViewHolder(binding, viewType) + } + + inner class GameViewHolder( + private val binding: ViewBinding, + private val viewType: Int + ) : AbstractViewHolder(binding) { + + override fun bind(model: Game) { - binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP - GameIconUtils.loadGameIcon(model, binding.imageGameScreen) + when (viewType) { + VIEW_TYPE_LIST -> bindListView(model) + VIEW_TYPE_GRID -> bindGridView(model) + } + } - binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") + private fun bindListView(model: Game) { + val listBinding = binding as CardGameListBinding - binding.textGameTitle.marquee() - binding.cardGame.setOnClickListener { onClick(model) } - binding.cardGame.setOnLongClickListener { onLongClick(model) } + listBinding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + GameIconUtils.loadGameIcon(model, listBinding.imageGameScreen) + + listBinding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") + listBinding.textGameDeveloper.text = model.developer + + listBinding.textGameTitle.marquee() + listBinding.cardGameList.setOnClickListener { onClick(model) } + listBinding.cardGameList.setOnLongClickListener { onLongClick(model) } + } + + private fun bindGridView(model: Game) { + val gridBinding = binding as CardGameGridBinding + + gridBinding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + GameIconUtils.loadGameIcon(model, gridBinding.imageGameScreen) + + gridBinding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") + + gridBinding.textGameTitle.marquee() + gridBinding.cardGameGrid.setOnClickListener { onClick(model) } + gridBinding.cardGameGrid.setOnLongClickListener { onLongClick(model) } } fun onClick(game: Game) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt index ff4f0e5dfb..fc627473a2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt @@ -1,5 +1,6 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + package org.yuzu.yuzu_emu.fragments @@ -50,7 +51,6 @@ class AboutFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarAbout.setNavigationOnClickListener { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt index 9fab88248d..b027547cef 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -33,7 +33,8 @@ class AddGameFolderDialogFragment : DialogFragment() { .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked) homeViewModel.setGamesDirSelected(true) - gamesViewModel.addFolder(newGameDir) + val calledFromGameFragment = requireArguments().getBoolean("calledFromGameFragment", false) + gamesViewModel.addFolder(newGameDir, calledFromGameFragment) } .setNegativeButton(android.R.string.cancel, null) .setView(binding.root) @@ -45,9 +46,10 @@ class AddGameFolderDialogFragment : DialogFragment() { private const val FOLDER_URI_STRING = "FolderUriString" - fun newInstance(folderUriString: String): AddGameFolderDialogFragment { + fun newInstance(folderUriString: String, calledFromGameFragment: Boolean): AddGameFolderDialogFragment { val args = Bundle() args.putString(FOLDER_URI_STRING, folderUriString) + args.putBoolean("calledFromGameFragment", calledFromGameFragment) val fragment = AddGameFolderDialogFragment() fragment.arguments = args return fragment diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt index 110aa29600..5229e25475 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -59,7 +59,6 @@ class AddonsFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = false) homeViewModel.setStatusBarShadeVisibility(false) binding.toolbarAddons.setNavigationOnClickListener { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt index 1514e38289..f39bb1affd 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -47,7 +47,6 @@ class AppletLauncherFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarApplets.setNavigationOnClickListener { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt index 8b23a10217..c58c02506c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -62,7 +62,6 @@ class DriverManagerFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = false) driverViewModel.onOpenDriverManager(args.game) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt index 3a6f7a38c4..83ae5622a2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -53,7 +53,6 @@ class GameFoldersFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarFolders.setNavigationOnClickListener { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt index 97a8954bb2..3cdee36d9c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -59,7 +59,6 @@ class GameInfoFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = false) homeViewModel.setStatusBarShadeVisibility(false) binding.apply { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt index c06842c596..8dbd69121b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -76,7 +76,6 @@ class GamePropertiesFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(true) binding.buttonBack.setOnClickListener { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index 006a7a4e6d..463caea421 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -70,7 +70,6 @@ class HomeSettingsFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = true, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = true) mainActivity = requireActivity() as MainActivity @@ -389,32 +388,20 @@ class HomeSettingsFragment : Fragment() { } private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { view: View, windowInsets: WindowInsetsCompat -> + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) - val spacingNavigationRail = - resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right binding.scrollViewSettings.updatePadding( top = barInsets.top, - bottom = barInsets.bottom ) - binding.scrollViewSettings.updateMargins(left = leftInsets, right = rightInsets) - - binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation) - - if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { - binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail) - } else { - binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail) - } + binding.homeSettingsList.updatePadding( + left = barInsets.left + cutoutInsets.left, + top = cutoutInsets.top, + right = barInsets.right + cutoutInsets.right, + bottom = barInsets.bottom + ) windowInsets } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt index d218da1c88..22976900f2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -66,7 +66,6 @@ class InstallableFragment : Fragment() { val mainActivity = requireActivity() as MainActivity - homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarInstallables.setNavigationOnClickListener { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt index f17f621f85..5fdcea29fa 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt @@ -1,5 +1,6 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + package org.yuzu.yuzu_emu.fragments @@ -45,7 +46,6 @@ class LicensesFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - homeViewModel.setNavigationVisibility(visible = false, animated = true) homeViewModel.setStatusBarShadeVisibility(visible = false) binding.toolbarLicenses.setNavigationOnClickListener { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt deleted file mode 100644 index 662ae9760a..0000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.Context -import android.content.SharedPreferences -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.preference.PreferenceManager -import info.debatty.java.stringsimilarity.Jaccard -import info.debatty.java.stringsimilarity.JaroWinkler -import java.util.Locale -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.GameAdapter -import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding -import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.collect - -class SearchFragment : Fragment() { - private var _binding: FragmentSearchBinding? = null - private val binding get() = _binding!! - - private val gamesViewModel: GamesViewModel by activityViewModels() - private val homeViewModel: HomeViewModel by activityViewModels() - - private lateinit var preferences: SharedPreferences - - companion object { - private const val SEARCH_TEXT = "SearchText" - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSearchBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = true, animated = true) - homeViewModel.setStatusBarShadeVisibility(true) - preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - - if (savedInstanceState != null) { - binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) - } - - binding.gridGamesSearch.apply { - layoutManager = AutofitGridLayoutManager( - requireContext(), - requireContext().resources.getDimensionPixelSize(R.dimen.card_width) - ) - adapter = GameAdapter(requireActivity() as AppCompatActivity) - } - - binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } - - binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int -> - binding.clearButton.setVisible(text.toString().isNotEmpty()) - filterAndSearch() - } - - gamesViewModel.searchFocused.collect( - viewLifecycleOwner, - resetState = { gamesViewModel.setSearchFocused(false) } - ) { if (it) focusSearch() } - gamesViewModel.games.collect(viewLifecycleOwner) { filterAndSearch() } - gamesViewModel.searchedGames.collect(viewLifecycleOwner) { - (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) - binding.noResultsView.setVisible(it.isNotEmpty()) - } - - binding.clearButton.setOnClickListener { binding.searchText.setText("") } - - binding.searchBackground.setOnClickListener { focusSearch() } - - setInsets() - filterAndSearch() - } - - private inner class ScoredGame(val score: Double, val item: Game) - - private fun filterAndSearch() { - val baseList = gamesViewModel.games.value - val filteredList: List = when (binding.chipGroup.checkedChipId) { - R.id.chip_recently_played -> { - baseList.filter { - val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) - lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } - } - - R.id.chip_recently_added -> { - baseList.filter { - val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) - addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } - } - - R.id.chip_homebrew -> baseList.filter { it.isHomebrew } - - R.id.chip_retail -> baseList.filter { !it.isHomebrew } - - else -> baseList - } - - if (binding.searchText.text.toString().isEmpty() && - binding.chipGroup.checkedChipId != View.NO_ID - ) { - gamesViewModel.setSearchedGames(filteredList) - return - } - - val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) - val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() - val sortedList: List = filteredList.mapNotNull { game -> - val title = game.title.lowercase(Locale.getDefault()) - val score = searchAlgorithm.similarity(searchTerm, title) - if (score > 0.03) { - ScoredGame(score, game) - } else { - null - } - }.sortedByDescending { it.score }.map { it.item } - gamesViewModel.setSearchedGames(sortedList) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - if (_binding != null) { - outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) - } - } - - private fun focusSearch() { - if (_binding != null) { - binding.searchText.requestFocus() - val imm = requireActivity() - .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? - imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) - } - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { view: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) - val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) - val spacingNavigationRail = - resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) - val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) - - binding.constraintSearch.updatePadding( - left = barInsets.left + cutoutInsets.left, - top = barInsets.top, - right = barInsets.right + cutoutInsets.right - ) - - binding.gridGamesSearch.updatePadding( - top = extraListSpacing, - bottom = barInsets.bottom + spacingNavigation + extraListSpacing - ) - binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom) - - val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams - if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { - binding.frameSearch.updatePadding(left = spacingNavigationRail) - binding.gridGamesSearch.updatePadding(left = spacingNavigationRail) - binding.noResultsView.updatePadding(left = spacingNavigationRail) - binding.chipGroup.updatePadding( - left = chipSpacing + spacingNavigationRail, - right = chipSpacing - ) - mlpDivider.leftMargin = chipSpacing + spacingNavigationRail - mlpDivider.rightMargin = chipSpacing - } else { - binding.frameSearch.updatePadding(right = spacingNavigationRail) - binding.gridGamesSearch.updatePadding(right = spacingNavigationRail) - binding.noResultsView.updatePadding(right = spacingNavigationRail) - binding.chipGroup.updatePadding( - left = chipSpacing, - right = chipSpacing + spacingNavigationRail - ) - mlpDivider.leftMargin = chipSpacing - mlpDivider.rightMargin = chipSpacing + spacingNavigationRail - } - binding.divider.layoutParams = mlpDivider - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt index 4f7548e98e..5110145001 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.fragments @@ -78,7 +78,6 @@ class SetupFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { mainActivity = requireActivity() as MainActivity - homeViewModel.setNavigationVisibility(visible = false, animated = false) requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt index 5ae05b5cc2..72a15ccef3 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -1,9 +1,10 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.model import android.net.Uri +import android.widget.Toast import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -15,9 +16,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.utils.GameHelper import org.yuzu.yuzu_emu.utils.NativeConfig @@ -27,9 +28,6 @@ class GamesViewModel : ViewModel() { val games: StateFlow> get() = _games private val _games = MutableStateFlow(emptyList()) - val searchedGames: StateFlow> get() = _searchedGames - private val _searchedGames = MutableStateFlow(emptyList()) - val isReloading: StateFlow get() = _isReloading private val _isReloading = MutableStateFlow(false) @@ -47,6 +45,8 @@ class GamesViewModel : ViewModel() { private val _folders = MutableStateFlow(mutableListOf()) val folders = _folders.asStateFlow() + private val _filteredGames = MutableStateFlow>(emptyList()) + init { // Ensure keys are loaded so that ROM metadata can be decrypted. NativeLibrary.reloadKeys() @@ -66,10 +66,6 @@ class GamesViewModel : ViewModel() { _games.value = sortedList } - fun setSearchedGames(games: List) { - _searchedGames.value = games - } - fun setShouldSwapData(shouldSwap: Boolean) { _shouldSwapData.value = shouldSwap } @@ -82,6 +78,10 @@ class GamesViewModel : ViewModel() { _searchFocused.value = searchFocused } + fun setFilteredGames(games: List) { + _filteredGames.value = games + } + fun reloadGames(directoriesChanged: Boolean, firstStartup: Boolean = false) { if (reloading.get()) { return @@ -131,12 +131,21 @@ class GamesViewModel : ViewModel() { } } - fun addFolder(gameDir: GameDir) = + fun addFolder(gameDir: GameDir, savedFromGameFragment: Boolean) = viewModelScope.launch { withContext(Dispatchers.IO) { NativeConfig.addGameDir(gameDir) getGameDirs(true) } + + if (savedFromGameFragment) { + NativeConfig.saveGlobalConfig() + Toast.makeText( + YuzuApplication.appContext, + R.string.add_directory_success, + Toast.LENGTH_SHORT + ).show() + } } fun removeFolder(gameDir: GameDir) = diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt index cfc777b81c..eb1a329e8d 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.model @@ -10,9 +10,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class HomeViewModel : ViewModel() { - val navigationVisible: StateFlow> get() = _navigationVisible - private val _navigationVisible = MutableStateFlow(Pair(false, false)) - val statusBarShadeVisible: StateFlow get() = _statusBarShadeVisible private val _statusBarShadeVisible = MutableStateFlow(true) @@ -36,13 +33,6 @@ class HomeViewModel : ViewModel() { var navigatedToSetup = false - fun setNavigationVisibility(visible: Boolean, animated: Boolean) { - if (navigationVisible.value.first == visible) { - return - } - _navigationVisible.value = Pair(visible, animated) - } - fun setStatusBarShadeVisibility(visible: Boolean) { if (statusBarShadeVisible.value == visible) { return diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt index fadb20e394..78abd974f6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt @@ -1,35 +1,69 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.ui +import android.content.Context +import android.content.Intent +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.PopupMenu +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding +import androidx.core.widget.doOnTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.color.MaterialColors +import info.debatty.java.stringsimilarity.Jaccard +import info.debatty.java.stringsimilarity.JaroWinkler import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.adapters.GameAdapter import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding -import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager +import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins import org.yuzu.yuzu_emu.utils.collect +import java.util.Locale +import androidx.core.content.edit class GamesFragment : Fragment() { private var _binding: FragmentGamesBinding? = null private val binding get() = _binding!! + companion object { + private const val SEARCH_TEXT = "SearchText" + private const val PREF_VIEW_TYPE = "GamesViewType" + private const val PREF_SORT_TYPE = "GamesSortType" + } + private val gamesViewModel: GamesViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels() + private lateinit var gameAdapter: GameAdapter + + private val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + private lateinit var mainActivity: MainActivity + private val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + mainActivity.processGamesDir(result, true) + } + } + override fun onCreateView( inflater: LayoutInflater, @@ -42,17 +76,19 @@ class GamesFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = true, animated = true) homeViewModel.setStatusBarShadeVisibility(true) + mainActivity = requireActivity() as MainActivity - binding.gridGames.apply { - layoutManager = AutofitGridLayoutManager( - requireContext(), - requireContext().resources.getDimensionPixelSize(R.dimen.card_width) - ) - adapter = GameAdapter(requireActivity() as AppCompatActivity) + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) } + gameAdapter = GameAdapter( + requireActivity() as AppCompatActivity, + ) + + applyGridGamesBinding() + binding.swipeRefresh.apply { // Add swipe down to refresh gesture setOnRefreshListener { @@ -90,14 +126,14 @@ class GamesFragment : Fragment() { ) } gamesViewModel.games.collect(viewLifecycleOwner) { - (binding.gridGames.adapter as GameAdapter).submitList(it) + setAdapter(it) } gamesViewModel.shouldSwapData.collect( viewLifecycleOwner, resetState = { gamesViewModel.setShouldSwapData(false) } ) { if (it) { - (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value) + setAdapter(gamesViewModel.games.value) } } gamesViewModel.shouldScrollToTop.collect( @@ -105,9 +141,193 @@ class GamesFragment : Fragment() { resetState = { gamesViewModel.setShouldScrollToTop(false) } ) { if (it) scrollToTop() } - setInsets() + setupTopView() + + binding.addDirectory.setOnClickListener { + getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + } + + setInsets() } + val applyGridGamesBinding = { + binding.gridGames.apply { + val savedViewType = preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) + gameAdapter.setViewType(savedViewType) + currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID) + adapter = gameAdapter + + val gameGrid = when (savedViewType) { + GameAdapter.VIEW_TYPE_LIST -> R.integer.game_columns_list + GameAdapter.VIEW_TYPE_GRID -> R.integer.game_columns_grid + else -> 0 + } + + layoutManager = GridLayoutManager(requireContext(), resources.getInteger(gameGrid)) + + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (_binding != null) { + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + } + + private fun setAdapter(games: List) { + val currentSearchText = binding.searchText.text.toString() + val currentFilter = binding.filterButton.id + + + if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) { + filterAndSearch(games) + } else { + (binding.gridGames.adapter as GameAdapter).submitList(games) + gamesViewModel.setFilteredGames(games) + } + } + + private fun setupTopView() { + binding.searchText.doOnTextChanged() { text: CharSequence?, _: Int, _: Int, _: Int -> + if (text.toString().isNotEmpty()) { + binding.clearButton.visibility = View.VISIBLE + } else { + binding.clearButton.visibility = View.INVISIBLE + } + filterAndSearch() + } + + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + binding.searchBackground.setOnClickListener { focusSearch() } + + // Setup view button + binding.viewButton.setOnClickListener { showViewMenu(it) } + + // Setup filter button + binding.filterButton.setOnClickListener { view -> + showFilterMenu(view) + } + + // Setup settings button + binding.settingsButton.setOnClickListener { navigateToSettings() } + } + + private fun navigateToSettings() { + val navController = findNavController() + navController.navigate(R.id.action_gamesFragment_to_homeSettingsFragment) + } + + private fun showViewMenu(anchor: View) { + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.menu_game_views, popup.menu) + + val currentViewType = (preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID)) + when (currentViewType) { + GameAdapter.VIEW_TYPE_LIST -> popup.menu.findItem(R.id.view_list).isChecked = true + GameAdapter.VIEW_TYPE_GRID -> popup.menu.findItem(R.id.view_grid).isChecked = true + } + + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.view_grid -> { + preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) } + applyGridGamesBinding() + item.isChecked = true + true + } + R.id.view_list -> { + preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_LIST) } + applyGridGamesBinding() + item.isChecked = true + true + } + else -> false + } + } + + popup.show() + } + + private fun showFilterMenu(anchor: View) { + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.menu_game_filters, popup.menu) + + // Set checked state based on current filter + when (currentFilter) { + R.id.alphabetical -> popup.menu.findItem(R.id.alphabetical).isChecked = true + R.id.filter_recently_played -> popup.menu.findItem(R.id.filter_recently_played).isChecked = + true + + R.id.filter_recently_added -> popup.menu.findItem(R.id.filter_recently_added).isChecked = + true + } + + popup.setOnMenuItemClickListener { item -> + currentFilter = item.itemId + preferences.edit().putInt(PREF_SORT_TYPE, currentFilter).apply() + filterAndSearch() + true + } + + popup.show() + } + + // Track current filter + private var currentFilter = View.NO_ID + + private fun filterAndSearch(baseList: List = gamesViewModel.games.value) { + val filteredList: List = when (currentFilter) { + R.id.alphabetical -> baseList.sortedBy { it.title } + R.id.filter_recently_played -> { + baseList.filter { + val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) + lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } + } + + R.id.filter_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } + } + + else -> baseList + } + + val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) + if (searchTerm.isEmpty()) { + (binding.gridGames.adapter as GameAdapter).submitList(filteredList) + gamesViewModel.setFilteredGames(filteredList) + return + } + + val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() + val sortedList = filteredList.mapNotNull { game -> + val title = game.title.lowercase(Locale.getDefault()) + val score = searchAlgorithm.similarity(searchTerm, title) + if (score > 0.03) { + ScoredGame(score, game) + } else { + null + } + }.sortedByDescending { it.score }.map { it.item } + + (binding.gridGames.adapter as GameAdapter).submitList(sortedList) + gamesViewModel.setFilteredGames(sortedList) + } + + private inner class ScoredGame(val score: Double, val item: Game) + + private fun focusSearch() { + binding.searchText.requestFocus() + val imm = requireActivity() + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) + } + + override fun onDestroyView() { super.onDestroyView() _binding = null @@ -125,15 +345,10 @@ class GamesFragment : Fragment() { ) { view: View, windowInsets: WindowInsetsCompat -> val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large) val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) - val spacingNavigationRail = resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) - - binding.gridGames.updatePadding( - top = barInsets.top + extraListSpacing, - bottom = barInsets.bottom + spacingNavigation + extraListSpacing - ) + val isLandscape = + resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE binding.swipeRefresh.setProgressViewEndTarget( false, @@ -142,18 +357,28 @@ class GamesFragment : Fragment() { val leftInsets = barInsets.left + cutoutInsets.left val rightInsets = barInsets.right + cutoutInsets.right - val left: Int - val right: Int + val mlpSwipe = binding.swipeRefresh.layoutParams as ViewGroup.MarginLayoutParams if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { - left = leftInsets + spacingNavigationRail - right = rightInsets + mlpSwipe.leftMargin = leftInsets + mlpSwipe.rightMargin = rightInsets } else { - left = leftInsets - right = rightInsets + spacingNavigationRail + mlpSwipe.leftMargin = leftInsets + mlpSwipe.rightMargin = rightInsets } - binding.swipeRefresh.updateMargins(left = left, right = right) + binding.swipeRefresh.layoutParams = mlpSwipe binding.noticeText.updatePadding(bottom = spacingNavigation) + binding.header.updatePadding(top = cutoutInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_large) + if (isLandscape) barInsets.top else 0) + binding.gridGames.updatePadding( + top = resources.getDimensionPixelSize(R.dimen.spacing_med) + ) + + val mlpFab = binding.addDirectory.layoutParams as ViewGroup.MarginLayoutParams + val fabPadding = resources.getDimensionPixelSize(R.dimen.spacing_large) + mlpFab.leftMargin = leftInsets + fabPadding + mlpFab.bottomMargin = barInsets.bottom + fabPadding + mlpFab.rightMargin = rightInsets + fabPadding + binding.addDirectory.layoutParams = mlpFab windowInsets } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index a0de1bbd8b..58ff109deb 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -121,27 +121,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment setUpNavigation(navHostFragment.navController) - (binding.navigationView as NavigationBarView).setOnItemReselectedListener { - when (it.itemId) { - R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true) - R.id.searchFragment -> gamesViewModel.setSearchFocused(true) - R.id.homeSettingsFragment -> { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - null, - Settings.MenuTag.SECTION_ROOT - ) - navHostFragment.navController.navigate(action) - } - } - } - // Prevents navigation from being drawn for a short time on recreation if set to hidden - if (!homeViewModel.navigationVisible.value.first) { - binding.navigationView.setVisible(visible = false, gone = false) - binding.statusBarShade.setVisible(visible = false, gone = false) - } - - homeViewModel.navigationVisible.collect(this) { showNavigation(it.first, it.second) } homeViewModel.statusBarShadeVisible.collect(this) { showStatusBarShade(it) } homeViewModel.contentToInstall.collect( this, @@ -175,8 +155,6 @@ class MainActivity : AppCompatActivity(), ThemeProvider { fun finishSetup(navController: NavController) { navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) - (binding.navigationView as NavigationBarView).setupWithNavController(navController) - showNavigation(visible = true, animated = true) } private fun setUpNavigation(navController: NavController) { @@ -186,64 +164,9 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (firstTimeSetup && !homeViewModel.navigatedToSetup) { navController.navigate(R.id.firstTimeSetupFragment) homeViewModel.navigatedToSetup = true - } else { - (binding.navigationView as NavigationBarView).setupWithNavController(navController) } } - private fun showNavigation(visible: Boolean, animated: Boolean) { - if (!animated) { - binding.navigationView.setVisible(visible) - return - } - - val smallLayout = resources.getBoolean(R.bool.small_layout) - binding.navigationView.animate().apply { - if (visible) { - binding.navigationView.setVisible(true) - duration = 300 - interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) - - if (smallLayout) { - binding.navigationView.translationY = - binding.navigationView.height.toFloat() * 2 - translationY(0f) - } else { - if (ViewCompat.getLayoutDirection(binding.navigationView) == - ViewCompat.LAYOUT_DIRECTION_LTR - ) { - binding.navigationView.translationX = - binding.navigationView.width.toFloat() * -2 - translationX(0f) - } else { - binding.navigationView.translationX = - binding.navigationView.width.toFloat() * 2 - translationX(0f) - } - } - } else { - duration = 300 - interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) - - if (smallLayout) { - translationY(binding.navigationView.height.toFloat() * 2) - } else { - if (ViewCompat.getLayoutDirection(binding.navigationView) == - ViewCompat.LAYOUT_DIRECTION_LTR - ) { - translationX(binding.navigationView.width.toFloat() * -2) - } else { - translationX(binding.navigationView.width.toFloat() * 2) - } - } - } - }.withEndAction { - if (!visible) { - binding.navigationView.setVisible(visible = false, gone = false) - } - }.start() - } - private fun showStatusBarShade(visible: Boolean) { binding.statusBarShade.animate().apply { if (visible) { @@ -254,7 +177,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) } else { duration = 300 - translationY(binding.navigationView.height.toFloat() * -2) + translationY(binding.statusBarShade.height.toFloat() * -2) interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) } }.withEndAction { @@ -299,7 +222,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } } - fun processGamesDir(result: Uri) { + fun processGamesDir(result: Uri, calledFromGameFragment: Boolean = false) { contentResolver.takePersistableUriPermission( result, Intent.FLAG_GRANT_READ_URI_PERMISSION @@ -316,7 +239,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { return } - AddGameFolderDialogFragment.newInstance(uriString) + AddGameFolderDialogFragment.newInstance(uriString, calledFromGameFragment) .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) } diff --git a/src/android/app/src/main/res/drawable/ic_eye.xml b/src/android/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 0000000000..63afd1ba4f --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_filter.xml b/src/android/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 0000000000..ad0a048f51 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout-w600dp/activity_main.xml b/src/android/app/src/main/res/layout-w600dp/activity_main.xml index 74bee872e9..d4188e7c9d 100644 --- a/src/android/app/src/main/res/layout-w600dp/activity_main.xml +++ b/src/android/app/src/main/res/layout-w600dp/activity_main.xml @@ -21,18 +21,6 @@ app:navGraph="@navigation/home_navigation" tools:layout="@layout/fragment_games" /> - - - - + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_games.xml b/src/android/app/src/main/res/layout/fragment_games.xml index cc280b1ffd..a3747b21da 100644 --- a/src/android/app/src/main/res/layout/fragment_games.xml +++ b/src/android/app/src/main/res/layout/fragment_games.xml @@ -1,35 +1,224 @@ - - + > - + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_marginHorizontal="20dp" + app:layout_constraintTop_toTopOf="parent" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + > - + - + - + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/fragment_search.xml b/src/android/app/src/main/res/layout/fragment_search.xml deleted file mode 100644 index efdfd7047d..0000000000 --- a/src/android/app/src/main/res/layout/fragment_search.xml +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml b/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml deleted file mode 100644 index dd7698e785..0000000000 --- a/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - diff --git a/src/android/app/src/main/res/menu/menu_game_filters.xml b/src/android/app/src/main/res/menu/menu_game_filters.xml new file mode 100644 index 0000000000..e0032da827 --- /dev/null +++ b/src/android/app/src/main/res/menu/menu_game_filters.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/menu/menu_game_views.xml b/src/android/app/src/main/res/menu/menu_game_views.xml new file mode 100644 index 0000000000..18e95bc5ad --- /dev/null +++ b/src/android/app/src/main/res/menu/menu_game_views.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/menu/menu_navigation.xml b/src/android/app/src/main/res/menu/menu_navigation.xml deleted file mode 100644 index da128c5a12..0000000000 --- a/src/android/app/src/main/res/menu/menu_navigation.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml index 8e57e866af..c9409875fd 100644 --- a/src/android/app/src/main/res/navigation/home_navigation.xml +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -8,6 +8,13 @@ android:id="@+id/gamesFragment" android:name="org.yuzu.yuzu_emu.ui.GamesFragment" android:label="PlatformGamesFragment" /> + - - 2 + 2 + 4 + diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml index 1c6f5db93a..5ddc913c41 100644 --- a/src/android/app/src/main/res/values/integers.xml +++ b/src/android/app/src/main/res/values/integers.xml @@ -1,6 +1,8 @@ 1 + 1 + 2 760 diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 6f0b06d596..c4ae05860e 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ - eden + Eden This software will run games for the Nintendo Switch game console. No game titles or keys are included.<br /><br />Before you begin, please locate your prod.keys ]]> file on your device storage.<br /><br />Learn more]]> Notices and errors noticesAndErrors @@ -28,6 +28,11 @@ Complete! + Alphabetical + List + Grid + Folder + "New game directory added successfully " Games Search Settings