android: Search Fragment

master
Charles Lombardo 2023-04-29 18:35:28 +07:00 committed by bunnei
parent 3281dc597e
commit 6df030998a
20 changed files with 551 additions and 189 deletions

@ -13,6 +13,7 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
@ -21,6 +22,7 @@ import coil.load
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R 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.CardGameBinding
import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.Game
@ -51,6 +53,14 @@ class GameAdapter(private val activity: AppCompatActivity) :
*/ */
override fun onClick(view: View) { override fun onClick(view: View) {
val holder = view.tag as GameViewHolder 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) EmulationActivity.launch(activity, holder.game)
} }

@ -21,6 +21,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R 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.ui.SettingsActivity
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.model.HomeSetting 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.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.GpuDriverHelper import org.yuzu.yuzu_emu.utils.GpuDriverHelper
@ -39,6 +41,8 @@ class HomeSettingsFragment : Fragment() {
private lateinit var mainActivity: MainActivity private lateinit var mainActivity: MainActivity
private val homeViewModel: HomeViewModel by activityViewModels()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -49,6 +53,7 @@ class HomeSettingsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
homeViewModel.setNavigationVisibility(visible = true, animated = false)
mainActivity = requireActivity() as MainActivity mainActivity = requireActivity() as MainActivity
val optionsList: List<HomeSetting> = listOf( val optionsList: List<HomeSetting> = listOf(

@ -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
}
}

@ -71,7 +71,7 @@ class SetupFragment : Fragment() {
mainActivity = requireActivity() as MainActivity mainActivity = requireActivity() as MainActivity
homeViewModel.setNavigationVisibility(false) homeViewModel.setNavigationVisibility(visible = false, animated = false)
requireActivity().onBackPressedDispatcher.addCallback( requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner, viewLifecycleOwner,

@ -16,6 +16,9 @@ class Game(
val gameId: String, val gameId: String,
val company: String val company: String
) : Parcelable { ) : Parcelable {
val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime"
val keyLastPlayedTime get() = "${gameId}_LastPlayed"
companion object { companion object {
val extensions: Set<String> = HashSet( val extensions: Set<String> = HashSet(
listOf(".xci", ".nsp", ".nca", ".nro") listOf(".xci", ".nsp", ".nca", ".nro")

@ -29,6 +29,9 @@ class GamesViewModel : ViewModel() {
private val _shouldScrollToTop = MutableLiveData(false) private val _shouldScrollToTop = MutableLiveData(false)
val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop
private val _searchFocused = MutableLiveData(false)
val searchFocused: LiveData<Boolean> get() = _searchFocused
init { init {
reloadGames(false) reloadGames(false)
} }
@ -45,6 +48,10 @@ class GamesViewModel : ViewModel() {
_shouldScrollToTop.postValue(shouldScroll) _shouldScrollToTop.postValue(shouldScroll)
} }
fun setSearchFocused(searchFocused: Boolean) {
_searchFocused.postValue(searchFocused)
}
fun reloadGames(directoryChanged: Boolean) { fun reloadGames(directoryChanged: Boolean) {
if (isReloading.value == true) if (isReloading.value == true)
return return

@ -5,19 +5,23 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
private val _navigationVisible = MutableLiveData(true) private val _navigationVisible = MutableLiveData<Pair<Boolean, Boolean>>()
val navigationVisible: LiveData<Boolean> get() = _navigationVisible val navigationVisible: LiveData<Pair<Boolean, Boolean>> get() = _navigationVisible
private val _statusBarShadeVisible = MutableLiveData(true) private val _statusBarShadeVisible = MutableLiveData(true)
val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible
var navigatedToSetup = false var navigatedToSetup = false
fun setNavigationVisibility(visible: Boolean) { init {
if (_navigationVisible.value == visible) { _navigationVisible.value = Pair(false, false)
}
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
if (_navigationVisible.value?.first == visible) {
return return
} }
_navigationVisible.value = visible _navigationVisible.value = Pair(visible, animated)
} }
fun setStatusBarShadeVisibility(visible: Boolean) { fun setStatusBarShadeVisibility(visible: Boolean) {

@ -52,19 +52,7 @@ class GamesFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 homeViewModel.setNavigationVisibility(visible = true, animated = false)
// 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()
}
}
})
binding.gridGames.apply { binding.gridGames.apply {
layoutManager = AutofitGridLayoutManager( layoutManager = AutofitGridLayoutManager(
@ -73,7 +61,6 @@ class GamesFragment : Fragment() {
) )
adapter = GameAdapter(requireActivity() as AppCompatActivity) adapter = GameAdapter(requireActivity() as AppCompatActivity)
} }
setUpSearch()
// Add swipe down to refresh gesture // Add swipe down to refresh gesture
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
@ -91,21 +78,16 @@ class GamesFragment : Fragment() {
// Watch for when we get updates to any of our games lists // Watch for when we get updates to any of our games lists
gamesViewModel.isReloading.observe(viewLifecycleOwner) { isReloading -> gamesViewModel.isReloading.observe(viewLifecycleOwner) { isReloading ->
binding.swipeRefresh.isRefreshing = isReloading binding.swipeRefresh.isRefreshing = isReloading
}
if (!isReloading) { gamesViewModel.games.observe(viewLifecycleOwner) {
if (gamesViewModel.games.value!!.isEmpty()) { (binding.gridGames.adapter as GameAdapter).submitList(it)
if (it.isEmpty()) {
binding.noticeText.visibility = View.VISIBLE binding.noticeText.visibility = View.VISIBLE
} else { } else {
binding.noticeText.visibility = View.GONE binding.noticeText.visibility = View.GONE
} }
} }
}
gamesViewModel.games.observe(viewLifecycleOwner) {
(binding.gridGames.adapter as GameAdapter).submitList(it)
}
gamesViewModel.searchedGames.observe(viewLifecycleOwner) {
(binding.gridSearch.adapter as GameAdapter).submitList(it)
}
gamesViewModel.shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData -> gamesViewModel.shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData ->
if (shouldSwapData) { if (shouldSwapData) {
(binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value) (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 // Check if the user reselected the games menu item and then scroll to top of the list
gamesViewModel.shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll -> gamesViewModel.shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll ->
if (shouldScroll) { if (shouldScroll) {
@ -162,71 +119,24 @@ class GamesFragment : Fragment() {
_binding = null _binding = null
} }
private fun searchShown() { private fun scrollToTop() {
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() {
if (_binding != null) { if (_binding != null) {
binding.gridGames.smoothScrollToPosition(0) binding.gridGames.smoothScrollToPosition(0)
} }
} }
private fun setInsets() = 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 insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large)
view.updatePadding( binding.gridGames.updatePadding(
top = insets.top + resources.getDimensionPixelSize(R.dimen.spacing_search), top = insets.top + extraListSpacing,
bottom = insets.bottom + resources.getDimensionPixelSize(R.dimen.spacing_navigation) + 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( binding.swipeRefresh.setProgressViewEndTarget(
resources.getDimensionPixelSize(R.dimen.spacing_refresh_slingshot)
)
binding.swipeRefresh.setProgressViewOffset(
false, false,
insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_start),
insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
) )

@ -7,6 +7,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager
import android.view.animation.PathInterpolator import android.view.animation.PathInterpolator
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -60,6 +61,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
setContentView(binding.root) setContentView(binding.root)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
window.statusBarColor = window.statusBarColor =
ContextCompat.getColor(applicationContext, android.R.color.transparent) ContextCompat.getColor(applicationContext, android.R.color.transparent)
@ -75,26 +77,30 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
setUpNavigation(navHostFragment.navController) setUpNavigation(navHostFragment.navController)
(binding.navigationBar as NavigationBarView).setOnItemReselectedListener { (binding.navigationBar as NavigationBarView).setOnItemReselectedListener {
if (it.itemId == R.id.gamesFragment) { when (it.itemId) {
gamesViewModel.setShouldScrollToTop(true) R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true)
R.id.searchFragment -> gamesViewModel.setSearchFocused(true)
} }
} }
binding.statusBarShade.setBackgroundColor( binding.statusBarShade.setBackgroundColor(
ThemeHelper.getColorWithOpacity(
MaterialColors.getColor( MaterialColors.getColor(
binding.root, binding.root,
R.attr.colorSurface R.attr.colorSurface
),
ThemeHelper.SYSTEM_BAR_ALPHA
) )
) )
// Prevents navigation from being drawn for a short time on recreation if set to hidden // 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.navigationBar.visibility = View.INVISIBLE
binding.statusBarShade.visibility = View.INVISIBLE binding.statusBarShade.visibility = View.INVISIBLE
} }
homeViewModel.navigationVisible.observe(this) { visible -> homeViewModel.navigationVisible.observe(this) {
showNavigation(visible) showNavigation(it.first, it.second)
} }
homeViewModel.statusBarShadeVisible.observe(this) { visible -> homeViewModel.statusBarShadeVisible.observe(this) { visible ->
showStatusBarShade(visible) showStatusBarShade(visible)
@ -109,7 +115,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
fun finishSetup(navController: NavController) { fun finishSetup(navController: NavController) {
navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment)
binding.navigationBar.setupWithNavController(navController) binding.navigationBar.setupWithNavController(navController)
showNavigation(true) showNavigation(visible = true, animated = true)
ThemeHelper.setNavigationBarColor( ThemeHelper.setNavigationBarColor(
this, 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 { binding.navigationBar.animate().apply {
if (visible) { if (visible) {
binding.navigationBar.visibility = View.VISIBLE binding.navigationBar.visibility = View.VISIBLE
@ -196,10 +211,6 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
themeId = resId themeId = resId
} }
private fun hasExtension(path: String, extension: String): Boolean {
return path.substring(path.lastIndexOf(".") + 1).contains(extension)
}
val getGamesDirectory = val getGamesDirectory =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
if (result == null) if (result == null)
@ -232,7 +243,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
if (result == null) if (result == null)
return@registerForActivityResult return@registerForActivityResult
if (!hasExtension(result.toString(), "keys")) { if (!FileUtil.hasExtension(result.toString(), "keys")) {
Toast.makeText( Toast.makeText(
applicationContext, applicationContext,
R.string.invalid_keys_file, R.string.invalid_keys_file,
@ -278,7 +289,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
if (result == null) if (result == null)
return@registerForActivityResult return@registerForActivityResult
if (!hasExtension(result.toString(), "bin")) { if (!FileUtil.hasExtension(result.toString(), "bin")) {
Toast.makeText( Toast.makeText(
applicationContext, applicationContext,
R.string.invalid_keys_file, R.string.invalid_keys_file,

@ -292,4 +292,8 @@ object FileUtil {
} }
} }
} }
fun hasExtension(path: String, extension: String): Boolean {
return path.substring(path.lastIndexOf(".") + 1).contains(extension)
}
} }

@ -3,6 +3,7 @@
package org.yuzu.yuzu_emu.utils package org.yuzu.yuzu_emu.utils
import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.NativeLibrary
@ -14,12 +15,15 @@ import kotlin.collections.ArrayList
object GameHelper { object GameHelper {
const val KEY_GAME_PATH = "game_path" const val KEY_GAME_PATH = "game_path"
private lateinit var preferences: SharedPreferences
fun getGames(): ArrayList<Game> { fun getGames(): ArrayList<Game> {
val games = ArrayList<Game>() val games = ArrayList<Game>()
val context = YuzuApplication.appContext val context = YuzuApplication.appContext
val gamesDir = val gamesDir =
PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "") PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
val gamesUri = Uri.parse(gamesDir) val gamesUri = Uri.parse(gamesDir)
preferences = PreferenceManager.getDefaultSharedPreferences(context)
// Ensure keys are loaded so that ROM metadata can be decrypted. // Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys() NativeLibrary.reloadKeys()
@ -60,7 +64,7 @@ object GameHelper {
) )
} }
return Game( val newGame = Game(
name, name,
NativeLibrary.getDescription(filePath).replace("\n", " "), NativeLibrary.getDescription(filePath).replace("\n", " "),
NativeLibrary.getRegions(filePath), NativeLibrary.getRegions(filePath),
@ -68,5 +72,14 @@ object GameHelper {
gameId, gameId,
NativeLibrary.getCompany(filePath) NativeLibrary.getCompany(filePath)
) )
val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L)
if (addedTime == 0L) {
preferences.edit()
.putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis())
.apply()
}
return newGame
} }
} }

@ -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>

@ -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>

@ -29,6 +29,7 @@
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/menu_navigation" app:menu="@menu/menu_navigation"
app:labelVisibilityMode="selected"
tools:visibility="visible" /> tools:visibility="visible" />
<View <View

@ -1,19 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?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:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinator_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh" android:id="@+id/swipe_refresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" android:background="?attr/colorSurface"
app:layout_behavior="@string/searchbar_scrolling_view_behavior"> android:clipToPadding="false">
<RelativeLayout <RelativeLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -38,37 +31,4 @@
</RelativeLayout> </RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/grid_search"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
tools:listitem="@layout/card_game" />
</com.google.android.material.search.SearchView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -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>

@ -6,6 +6,11 @@
android:icon="@drawable/ic_controller" android:icon="@drawable/ic_controller"
android:title="@string/home_games" /> android:title="@string/home_games" />
<item
android:id="@+id/searchFragment"
android:icon="@drawable/ic_search"
android:title="@string/home_search" />
<item <item
android:id="@+id/homeSettingsFragment" android:id="@+id/homeSettingsFragment"
android:icon="@drawable/ic_settings" android:icon="@drawable/ic_settings"

@ -25,4 +25,9 @@
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
</fragment> </fragment>
<fragment
android:id="@+id/searchFragment"
android:name="org.yuzu.yuzu_emu.fragments.SearchFragment"
android:label="SearchFragment" />
</navigation> </navigation>

@ -5,11 +5,10 @@
<dimen name="spacing_large">16dp</dimen> <dimen name="spacing_large">16dp</dimen>
<dimen name="spacing_xtralarge">32dp</dimen> <dimen name="spacing_xtralarge">32dp</dimen>
<dimen name="spacing_list">64dp</dimen> <dimen name="spacing_list">64dp</dimen>
<dimen name="spacing_chip">20dp</dimen>
<dimen name="spacing_navigation">80dp</dimen> <dimen name="spacing_navigation">80dp</dimen>
<dimen name="spacing_search">88dp</dimen> <dimen name="spacing_search">128dp</dimen>
<dimen name="spacing_refresh_slingshot">80dp</dimen> <dimen name="spacing_refresh_end">72dp</dimen>
<dimen name="spacing_refresh_start">32dp</dimen>
<dimen name="spacing_refresh_end">96dp</dimen>
<dimen name="menu_width">256dp</dimen> <dimen name="menu_width">256dp</dimen>
<dimen name="card_width">165dp</dimen> <dimen name="card_width">165dp</dimen>

@ -32,7 +32,10 @@
<!-- Home strings --> <!-- Home strings -->
<string name="home_games">Games</string> <string name="home_games">Games</string>
<string name="home_search">Search</string>
<string name="home_settings">Settings</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">Select games folder</string>
<string name="select_games_folder_description">Allows yuzu to populate the games list</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> <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="install_gpu_driver_description">Install alternative drivers for potentially better performance or accuracy</string>
<string name="advanced_settings">Advanced settings</string> <string name="advanced_settings">Advanced settings</string>
<string name="settings_description">Configure emulator 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">Open yuzu folder</string>
<string name="open_user_folder_description">Manage yuzu\'s internal files</string> <string name="open_user_folder_description">Manage yuzu\'s internal files</string>
<string name="no_file_manager">No file manager found</string> <string name="no_file_manager">No file manager found</string>
@ -151,8 +158,6 @@
<string name="load_settings">Loading Settings…</string> <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 --> <!-- Software keyboard -->
<string name="software_keyboard">Software Keyboard</string> <string name="software_keyboard">Software Keyboard</string>