diff options
20 files changed, 551 insertions, 189 deletions
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 eca84a694..b9f975e2b 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 @@ -13,6 +13,7 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -21,6 +22,7 @@ import coil.load import kotlinx.coroutines.launch import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.databinding.CardGameBinding import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.model.Game @@ -51,6 +53,14 @@ class GameAdapter(private val activity: AppCompatActivity) : */ override fun onClick(view: View) { val holder = view.tag as GameViewHolder + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + preferences.edit() + .putLong( + holder.game.keyLastPlayedTime, + System.currentTimeMillis() + ) + .apply() + EmulationActivity.launch(activity, holder.game) } 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 0e7c181ea..eb29d6c96 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 @@ -21,6 +21,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.yuzu.yuzu_emu.R @@ -30,6 +31,7 @@ import org.yuzu.yuzu_emu.features.DocumentProvider import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile import org.yuzu.yuzu_emu.model.HomeSetting +import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.ui.main.MainActivity import org.yuzu.yuzu_emu.utils.GpuDriverHelper @@ -39,6 +41,8 @@ class HomeSettingsFragment : Fragment() { private lateinit var mainActivity: MainActivity + private val homeViewModel: HomeViewModel by activityViewModels() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -49,6 +53,7 @@ class HomeSettingsFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = true, animated = false) mainActivity = requireActivity() as MainActivity val optionsList: List<HomeSetting> = listOf( 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 new file mode 100644 index 000000000..5babd9bbf --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt @@ -0,0 +1,222 @@ +// 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 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.FileUtil +import org.yuzu.yuzu_emu.utils.Log +import java.util.Locale + +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?) { + homeViewModel.setNavigationVisibility(visible = true, animated = false) + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + gamesViewModel.searchFocused.observe(viewLifecycleOwner) { searchFocused -> + if (searchFocused) { + focusSearch() + gamesViewModel.setSearchFocused(false) + } + } + + 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 -> + if (text.toString().isNotEmpty()) { + binding.clearButton.visibility = View.VISIBLE + } else { + binding.clearButton.visibility = View.INVISIBLE + } + filterAndSearch() + } + + gamesViewModel.games.observe(viewLifecycleOwner) { filterAndSearch() } + gamesViewModel.searchedGames.observe(viewLifecycleOwner) { + (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) + if (it.isEmpty()) { + binding.noResultsView.visibility = View.VISIBLE + } else { + binding.noResultsView.visibility = View.GONE + } + } + + 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<Game> = 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) + } + } + + R.id.chip_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + } + + R.id.chip_homebrew -> { + baseList.filter { + Log.error("Guh - ${it.path}") + FileUtil.hasExtension(it.path, "nro") + || FileUtil.hasExtension(it.path, "nso") + } + } + + R.id.chip_retail -> baseList.filter { + FileUtil.hasExtension(it.path, "xci") + || FileUtil.hasExtension(it.path, "nsp") + } + + 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 = Jaccard(2) + val sortedList: List<Game> = 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, windowInsets: WindowInsetsCompat -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val navigationSpacing = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + binding.frameSearch.updatePadding( + left = insets.left, + top = insets.top, + right = insets.right + ) + + binding.gridGamesSearch.setPadding( + insets.left, + extraListSpacing, + insets.right, + insets.bottom + resources.getDimensionPixelSize(R.dimen.spacing_navigation) + extraListSpacing + ) + + binding.noResultsView.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom + navigationSpacing + ) + + val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams + mlpDivider.leftMargin = insets.left + chipSpacing + mlpDivider.rightMargin = insets.right + chipSpacing + binding.divider.layoutParams = mlpDivider + + binding.chipGroup.updatePadding( + left = insets.left + chipSpacing, + right = insets.right + chipSpacing + ) + + 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 3d2f8719c..13b8315db 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 @@ -71,7 +71,7 @@ class SetupFragment : Fragment() { mainActivity = requireActivity() as MainActivity - homeViewModel.setNavigationVisibility(false) + homeViewModel.setNavigationVisibility(visible = false, animated = false) requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt index db494e40f..c5cde9d05 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt @@ -16,6 +16,9 @@ class Game( val gameId: String, val company: String ) : Parcelable { + val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime" + val keyLastPlayedTime get() = "${gameId}_LastPlayed" + companion object { val extensions: Set<String> = HashSet( listOf(".xci", ".nsp", ".nca", ".nro") 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 95bad38c6..1d0846b08 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 @@ -29,6 +29,9 @@ class GamesViewModel : ViewModel() { private val _shouldScrollToTop = MutableLiveData(false) val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop + private val _searchFocused = MutableLiveData(false) + val searchFocused: LiveData<Boolean> get() = _searchFocused + init { reloadGames(false) } @@ -45,6 +48,10 @@ class GamesViewModel : ViewModel() { _shouldScrollToTop.postValue(shouldScroll) } + fun setSearchFocused(searchFocused: Boolean) { + _searchFocused.postValue(searchFocused) + } + fun reloadGames(directoryChanged: Boolean) { if (isReloading.value == true) return 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 acda8663a..b959ae4ba 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 @@ -5,19 +5,23 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel class HomeViewModel : ViewModel() { - private val _navigationVisible = MutableLiveData(true) - val navigationVisible: LiveData<Boolean> get() = _navigationVisible + private val _navigationVisible = MutableLiveData<Pair<Boolean, Boolean>>() + val navigationVisible: LiveData<Pair<Boolean, Boolean>> get() = _navigationVisible private val _statusBarShadeVisible = MutableLiveData(true) val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible var navigatedToSetup = false - fun setNavigationVisibility(visible: Boolean) { - if (_navigationVisible.value == visible) { + init { + _navigationVisible.value = Pair(false, false) + } + + fun setNavigationVisibility(visible: Boolean, animated: Boolean) { + if (_navigationVisible.value?.first == visible) { return } - _navigationVisible.value = visible + _navigationVisible.value = Pair(visible, animated) } fun setStatusBarShadeVisibility(visible: Boolean) { 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 227ca1afc..6f9e04f7e 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 @@ -52,19 +52,7 @@ class GamesFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - // Use custom back navigation so the user doesn't back out of the app when trying to back - // out of the search view - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.searchView.currentTransitionState == TransitionState.SHOWN) { - binding.searchView.hide() - } else { - requireActivity().finish() - } - } - }) + homeViewModel.setNavigationVisibility(visible = true, animated = false) binding.gridGames.apply { layoutManager = AutofitGridLayoutManager( @@ -73,7 +61,6 @@ class GamesFragment : Fragment() { ) adapter = GameAdapter(requireActivity() as AppCompatActivity) } - setUpSearch() // Add swipe down to refresh gesture binding.swipeRefresh.setOnRefreshListener { @@ -91,21 +78,16 @@ class GamesFragment : Fragment() { // Watch for when we get updates to any of our games lists gamesViewModel.isReloading.observe(viewLifecycleOwner) { isReloading -> binding.swipeRefresh.isRefreshing = isReloading - - if (!isReloading) { - if (gamesViewModel.games.value!!.isEmpty()) { - binding.noticeText.visibility = View.VISIBLE - } else { - binding.noticeText.visibility = View.GONE - } - } } gamesViewModel.games.observe(viewLifecycleOwner) { (binding.gridGames.adapter as GameAdapter).submitList(it) + if (it.isEmpty()) { + binding.noticeText.visibility = View.VISIBLE + } else { + binding.noticeText.visibility = View.GONE + } } - gamesViewModel.searchedGames.observe(viewLifecycleOwner) { - (binding.gridSearch.adapter as GameAdapter).submitList(it) - } + gamesViewModel.shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData -> if (shouldSwapData) { (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value) @@ -113,31 +95,6 @@ class GamesFragment : Fragment() { } } - // Hide bottom navigation and FAB when using the search view - binding.searchView.addTransitionListener { _: SearchView, _: TransitionState, newState: TransitionState -> - when (newState) { - TransitionState.SHOWING, - TransitionState.SHOWN -> { - (binding.gridSearch.adapter as GameAdapter).submitList(emptyList()) - searchShown() - } - TransitionState.HIDDEN, - TransitionState.HIDING -> { - gamesViewModel.setSearchedGames(emptyList()) - searchHidden() - binding.appBarSearch.setExpanded(true) - } - } - } - - // Ensure that bottom navigation or FAB don't appear upon recreation - val searchState = binding.searchView.currentTransitionState - if (searchState == TransitionState.SHOWN) { - searchShown() - } else if (searchState == TransitionState.HIDDEN) { - searchHidden() - } - // Check if the user reselected the games menu item and then scroll to top of the list gamesViewModel.shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll -> if (shouldScroll) { @@ -162,71 +119,24 @@ class GamesFragment : Fragment() { _binding = null } - private fun searchShown() { - homeViewModel.setNavigationVisibility(false) - homeViewModel.setStatusBarShadeVisibility(false) - } - - private fun searchHidden() { - homeViewModel.setNavigationVisibility(true) - homeViewModel.setStatusBarShadeVisibility(true) - } - - private inner class ScoredGame(val score: Double, val item: Game) - - private fun setUpSearch() { - binding.gridSearch.apply { - layoutManager = AutofitGridLayoutManager( - requireContext(), - requireContext().resources.getDimensionPixelSize(R.dimen.card_width) - ) - adapter = GameAdapter(requireActivity() as AppCompatActivity) - } - - binding.searchView.editText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int -> - val searchTerm = text.toString().lowercase(Locale.getDefault()) - val searchAlgorithm = Jaccard(2) - val sortedList: List<Game> = gamesViewModel.games.value!!.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) - } - } - - fun scrollToTop() { + private fun scrollToTop() { if (_binding != null) { binding.gridGames.smoothScrollToPosition(0) } } private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener(binding.gridGames) { view: View, windowInsets: WindowInsetsCompat -> + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large) - view.updatePadding( - top = insets.top + resources.getDimensionPixelSize(R.dimen.spacing_search), + binding.gridGames.updatePadding( + top = insets.top + extraListSpacing, bottom = insets.bottom + resources.getDimensionPixelSize(R.dimen.spacing_navigation) + extraListSpacing ) - binding.gridSearch.updatePadding( - left = insets.left, - top = extraListSpacing, - right = insets.right, - bottom = insets.bottom + extraListSpacing - ) - binding.swipeRefresh.setSlingshotDistance( - resources.getDimensionPixelSize(R.dimen.spacing_refresh_slingshot) - ) - binding.swipeRefresh.setProgressViewOffset( + binding.swipeRefresh.setProgressViewEndTarget( false, - insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_start), insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) ) 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 473d38a29..35b66d1f2 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 @@ -7,6 +7,7 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager import android.view.animation.PathInterpolator import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -60,6 +61,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { setContentView(binding.root) WindowCompat.setDecorFitsSystemWindows(window, false) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) window.statusBarColor = ContextCompat.getColor(applicationContext, android.R.color.transparent) @@ -75,26 +77,30 @@ class MainActivity : AppCompatActivity(), ThemeProvider { supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment setUpNavigation(navHostFragment.navController) (binding.navigationBar as NavigationBarView).setOnItemReselectedListener { - if (it.itemId == R.id.gamesFragment) { - gamesViewModel.setShouldScrollToTop(true) + when (it.itemId) { + R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true) + R.id.searchFragment -> gamesViewModel.setSearchFocused(true) } } binding.statusBarShade.setBackgroundColor( - MaterialColors.getColor( - binding.root, - R.attr.colorSurface + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA ) ) // Prevents navigation from being drawn for a short time on recreation if set to hidden - if (homeViewModel.navigationVisible.value == false) { + if (!homeViewModel.navigationVisible.value?.first!!) { binding.navigationBar.visibility = View.INVISIBLE binding.statusBarShade.visibility = View.INVISIBLE } - homeViewModel.navigationVisible.observe(this) { visible -> - showNavigation(visible) + homeViewModel.navigationVisible.observe(this) { + showNavigation(it.first, it.second) } homeViewModel.statusBarShadeVisible.observe(this) { visible -> showStatusBarShade(visible) @@ -109,7 +115,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { fun finishSetup(navController: NavController) { navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) binding.navigationBar.setupWithNavController(navController) - showNavigation(true) + showNavigation(visible = true, animated = true) ThemeHelper.setNavigationBarColor( this, @@ -132,7 +138,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } } - private fun showNavigation(visible: Boolean) { + private fun showNavigation(visible: Boolean, animated: Boolean) { + if (!animated) { + if (visible) { + binding.navigationBar.visibility = View.VISIBLE + } else { + binding.navigationBar.visibility = View.INVISIBLE + } + return + } + binding.navigationBar.animate().apply { if (visible) { binding.navigationBar.visibility = View.VISIBLE @@ -196,10 +211,6 @@ class MainActivity : AppCompatActivity(), ThemeProvider { themeId = resId } - private fun hasExtension(path: String, extension: String): Boolean { - return path.substring(path.lastIndexOf(".") + 1).contains(extension) - } - val getGamesDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> if (result == null) @@ -232,7 +243,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (result == null) return@registerForActivityResult - if (!hasExtension(result.toString(), "keys")) { + if (!FileUtil.hasExtension(result.toString(), "keys")) { Toast.makeText( applicationContext, R.string.invalid_keys_file, @@ -278,7 +289,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { if (result == null) return@registerForActivityResult - if (!hasExtension(result.toString(), "bin")) { + if (!FileUtil.hasExtension(result.toString(), "bin")) { Toast.makeText( applicationContext, R.string.invalid_keys_file, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt index d16ed96ac..0e3305026 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -292,4 +292,8 @@ object FileUtil { } } } + + fun hasExtension(path: String, extension: String): Boolean { + return path.substring(path.lastIndexOf(".") + 1).contains(extension) + } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index c463a66d8..9dd43343f 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -3,6 +3,7 @@ package org.yuzu.yuzu_emu.utils +import android.content.SharedPreferences import android.net.Uri import androidx.preference.PreferenceManager import org.yuzu.yuzu_emu.NativeLibrary @@ -14,12 +15,15 @@ import kotlin.collections.ArrayList object GameHelper { const val KEY_GAME_PATH = "game_path" + private lateinit var preferences: SharedPreferences + fun getGames(): ArrayList<Game> { val games = ArrayList<Game>() val context = YuzuApplication.appContext val gamesDir = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "") val gamesUri = Uri.parse(gamesDir) + preferences = PreferenceManager.getDefaultSharedPreferences(context) // Ensure keys are loaded so that ROM metadata can be decrypted. NativeLibrary.reloadKeys() @@ -60,7 +64,7 @@ object GameHelper { ) } - return Game( + val newGame = Game( name, NativeLibrary.getDescription(filePath).replace("\n", " "), NativeLibrary.getRegions(filePath), @@ -68,5 +72,14 @@ object GameHelper { gameId, NativeLibrary.getCompany(filePath) ) + + val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L) + if (addedTime == 0L) { + preferences.edit() + .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis()) + .apply() + } + + return newGame } } diff --git a/src/android/app/src/main/res/drawable/ic_clear.xml b/src/android/app/src/main/res/drawable/ic_clear.xml new file mode 100644 index 000000000..b6edb1d32 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_clear.xml @@ -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="?attr/colorControlNormal" + android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" /> +</vector> diff --git a/src/android/app/src/main/res/drawable/ic_search.xml b/src/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..bb0726851 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_search.xml @@ -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="?attr/colorControlNormal" + android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" /> +</vector> diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml index 59812ab8e..6ca426b54 100644 --- a/src/android/app/src/main/res/layout/activity_main.xml +++ b/src/android/app/src/main/res/layout/activity_main.xml @@ -29,6 +29,7 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:menu="@menu/menu_navigation" + app:labelVisibilityMode="selected" tools:visibility="visible" /> <View 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 c4c3eacf4..8b6d0b3b6 100644 --- a/src/android/app/src/main/res/layout/fragment_games.xml +++ b/src/android/app/src/main/res/layout/fragment_games.xml @@ -1,74 +1,34 @@ <?xml version="1.0" encoding="utf-8"?> -<androidx.coordinatorlayout.widget.CoordinatorLayout +<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/coordinator_main" + android:id="@+id/swipe_refresh" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/colorSurface"> + android:background="?attr/colorSurface" + android:clipToPadding="false"> - <androidx.swiperefreshlayout.widget.SwipeRefreshLayout - android:id="@+id/swipe_refresh" + <RelativeLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:clipToPadding="false" - app:layout_behavior="@string/searchbar_scrolling_view_behavior"> + android:layout_height="match_parent"> - <RelativeLayout + <com.google.android.material.textview.MaterialTextView + android:id="@+id/notice_text" + style="@style/TextAppearance.Material3.BodyLarge" android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.textview.MaterialTextView - android:id="@+id/notice_text" - style="@style/TextAppearance.Material3.BodyLarge" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:gravity="center" - android:padding="@dimen/spacing_large" - android:text="@string/empty_gamelist" - tools:visibility="gone" /> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/grid_games" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clipToPadding="false" - tools:listitem="@layout/card_game" /> - - </RelativeLayout> - - </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> - - <com.google.android.material.appbar.AppBarLayout - android:id="@+id/app_bar_search" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:fitsSystemWindows="true" - app:liftOnScrollTargetViewId="@id/grid_games"> - - <com.google.android.material.search.SearchBar - android:id="@+id/search_bar" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/home_search_games" /> - - </com.google.android.material.appbar.AppBarLayout> - - <com.google.android.material.search.SearchView - android:id="@+id/search_view" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:hint="@string/home_search_games" - app:layout_anchor="@id/search_bar"> + android:layout_height="match_parent" + android:gravity="center" + android:padding="@dimen/spacing_large" + android:text="@string/empty_gamelist" + tools:visibility="gone" /> <androidx.recyclerview.widget.RecyclerView - android:id="@+id/grid_search" + android:id="@+id/grid_games" android:layout_width="match_parent" android:layout_height="match_parent" android:clipToPadding="false" tools:listitem="@layout/card_game" /> - </com.google.android.material.search.SearchView> + </RelativeLayout> -</androidx.coordinatorlayout.widget.CoordinatorLayout> +</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> diff --git a/src/android/app/src/main/res/layout/fragment_search.xml b/src/android/app/src/main/res/layout/fragment_search.xml new file mode 100644 index 000000000..3b1aefdfb --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_search.xml @@ -0,0 +1,180 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/colorSurface"> + + <RelativeLayout + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/divider"> + + <LinearLayout + android:id="@+id/no_results_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:gravity="center"> + + <ImageView + android:id="@+id/icon_no_results" + android:layout_width="match_parent" + android:layout_height="80dp" + android:src="@drawable/ic_search" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/notice_text" + style="@style/TextAppearance.Material3.TitleLarge" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:paddingTop="8dp" + android:text="@string/search_and_filter_games" + tools:visibility="visible" /> + + </LinearLayout> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/grid_games_search" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" /> + + </RelativeLayout> + + <FrameLayout + android:id="@+id/frame_search" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="20dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + <com.google.android.material.card.MaterialCardView + android:id="@+id/search_background" + style="?attr/materialCardViewFilledStyle" + android:layout_width="match_parent" + android:layout_height="56dp" + app:cardCornerRadius="28dp"> + + <LinearLayout + android:id="@+id/search_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginStart="24dp" + android:layout_marginEnd="56dp" + android:orientation="horizontal"> + + <ImageView + android:layout_width="28dp" + android:layout_height="28dp" + android:layout_gravity="center_vertical" + android:layout_marginEnd="24dp" + android:src="@drawable/ic_search" + app:tint="?attr/colorOnSurfaceVariant" /> + + <EditText + android:id="@+id/search_text" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/transparent" + android:hint="@string/home_search_games" + android:inputType="text" + android:maxLines="1" + android:imeOptions="flagNoFullscreen" /> + + </LinearLayout> + + <ImageView + android:id="@+id/clear_button" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_gravity="center_vertical|end" + android:layout_marginEnd="24dp" + android:background="?attr/selectableItemBackground" + android:src="@drawable/ic_clear" + android:visibility="invisible" + app:tint="?attr/colorOnSurfaceVariant" + tools:visibility="visible" /> + + </com.google.android.material.card.MaterialCardView> + + </FrameLayout> + + <HorizontalScrollView + android:id="@+id/horizontalScrollView" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:fadingEdge="horizontal" + android:scrollbars="none" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/frame_search"> + + <com.google.android.material.chip.ChipGroup + android:id="@+id/chip_group" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clipToPadding="false" + android:paddingVertical="4dp" + app:chipSpacingHorizontal="12dp" + app:singleLine="true" + app:singleSelection="true"> + + <com.google.android.material.chip.Chip + android:id="@+id/chip_recently_played" + style="@style/Widget.Material3.Chip.Suggestion.Elevated" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:checked="false" + android:text="@string/search_recently_played" + app:chipCornerRadius="28dp" /> + + <com.google.android.material.chip.Chip + android:id="@+id/chip_recently_added" + style="@style/Widget.Material3.Chip.Suggestion.Elevated" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:checked="false" + android:text="@string/search_recently_added" + app:chipCornerRadius="28dp" /> + + <com.google.android.material.chip.Chip + android:id="@+id/chip_retail" + style="@style/Widget.Material3.Chip.Suggestion.Elevated" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:checked="false" + android:text="@string/search_retail" + app:chipCornerRadius="28dp" /> + + <com.google.android.material.chip.Chip + android:id="@+id/chip_homebrew" + style="@style/Widget.Material3.Chip.Suggestion.Elevated" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:checked="false" + android:text="@string/search_homebrew" + app:chipCornerRadius="28dp" /> + + </com.google.android.material.chip.ChipGroup> + + </HorizontalScrollView> + + <com.google.android.material.divider.MaterialDivider + android:id="@+id/divider" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="20dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/horizontalScrollView" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/src/android/app/src/main/res/menu/menu_navigation.xml b/src/android/app/src/main/res/menu/menu_navigation.xml index e46133604..ed10e6e51 100644 --- a/src/android/app/src/main/res/menu/menu_navigation.xml +++ b/src/android/app/src/main/res/menu/menu_navigation.xml @@ -7,6 +7,11 @@ android:title="@string/home_games" /> <item + android:id="@+id/searchFragment" + android:icon="@drawable/ic_search" + android:title="@string/home_search" /> + + <item android:id="@+id/homeSettingsFragment" android:icon="@drawable/ic_settings" android:title="@string/home_settings" /> 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 d500d165b..0f43ba556 100644 --- a/src/android/app/src/main/res/navigation/home_navigation.xml +++ b/src/android/app/src/main/res/navigation/home_navigation.xml @@ -25,4 +25,9 @@ app:popUpToInclusive="true" /> </fragment> + <fragment + android:id="@+id/searchFragment" + android:name="org.yuzu.yuzu_emu.fragments.SearchFragment" + android:label="SearchFragment" /> + </navigation> diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml index ab2583938..28a6d25cf 100644 --- a/src/android/app/src/main/res/values/dimens.xml +++ b/src/android/app/src/main/res/values/dimens.xml @@ -5,11 +5,10 @@ <dimen name="spacing_large">16dp</dimen> <dimen name="spacing_xtralarge">32dp</dimen> <dimen name="spacing_list">64dp</dimen> + <dimen name="spacing_chip">20dp</dimen> <dimen name="spacing_navigation">80dp</dimen> - <dimen name="spacing_search">88dp</dimen> - <dimen name="spacing_refresh_slingshot">80dp</dimen> - <dimen name="spacing_refresh_start">32dp</dimen> - <dimen name="spacing_refresh_end">96dp</dimen> + <dimen name="spacing_search">128dp</dimen> + <dimen name="spacing_refresh_end">72dp</dimen> <dimen name="menu_width">256dp</dimen> <dimen name="card_width">165dp</dimen> diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index c55b9e06b..9c7ab3c26 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -32,7 +32,10 @@ <!-- Home strings --> <string name="home_games">Games</string> + <string name="home_search">Search</string> <string name="home_settings">Settings</string> + <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string> + <string name="search_and_filter_games">Search and filter games</string> <string name="select_games_folder">Select games folder</string> <string name="select_games_folder_description">Allows yuzu to populate the games list</string> <string name="add_games_warning">Skip selecting games folder?</string> @@ -58,6 +61,10 @@ <string name="install_gpu_driver_description">Install alternative drivers for potentially better performance or accuracy</string> <string name="advanced_settings">Advanced settings</string> <string name="settings_description">Configure emulator settings</string> + <string name="search_recently_played">Recently Played</string> + <string name="search_recently_added">Recently Added</string> + <string name="search_retail">Retail</string> + <string name="search_homebrew">Homebrew</string> <string name="open_user_folder">Open yuzu folder</string> <string name="open_user_folder_description">Manage yuzu\'s internal files</string> <string name="no_file_manager">No file manager found</string> @@ -151,8 +158,6 @@ <string name="load_settings">Loading Settings…</string> - <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string> - <!-- Software keyboard --> <string name="software_keyboard">Software Keyboard</string> |