Răsfoiți Sursa

android: MainActivity overhaul

This moves several parts of the main activity into fragments that manage themselves to react to changes. UI changes like the appearance of a new search view or when the games list changes now gets updated via multiple view models. This also starts a conversion to the androidx navigation component which furthers the goals mentioned previously with more fragment responsibility. This will eventually allow us to use one activity with interchanging fragments and multiple view models that are stored within that central activity.

fdas
Charles Lombardo 3 ani în urmă
părinte
comite
233ae9ab69
32 a modificat fișierele cu 1031 adăugiri și 626 ștergeri
  1. 3 0
      src/android/app/build.gradle.kts
  2. 1 2
      src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
  3. 21 21
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
  4. 55 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeOptionAdapter.kt
  5. 6 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
  6. 281 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/OptionsFragment.kt
  7. 45 5
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
  8. 11 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeOption.kt
  9. 17 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
  10. 220 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
  11. 63 244
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
  12. 0 52
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt
  13. 0 23
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt
  14. 0 109
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt
  15. 0 48
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.kt
  16. 3 7
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt
  17. 9 0
      src/android/app/src/main/res/drawable/ic_add.xml
  18. 10 0
      src/android/app/src/main/res/drawable/ic_input.xml
  19. 9 0
      src/android/app/src/main/res/drawable/ic_nfc.xml
  20. 9 0
      src/android/app/src/main/res/drawable/ic_options.xml
  21. 9 0
      src/android/app/src/main/res/drawable/ic_unlock.xml
  22. 18 0
      src/android/app/src/main/res/drawable/ic_yuzu_themed.xml
  23. 23 19
      src/android/app/src/main/res/layout/activity_main.xml
  24. 53 0
      src/android/app/src/main/res/layout/card_home_option.xml
  25. 80 0
      src/android/app/src/main/res/layout/fragment_games.xml
  26. 0 37
      src/android/app/src/main/res/layout/fragment_grid.xml
  27. 30 0
      src/android/app/src/main/res/layout/fragment_options.xml
  28. 0 47
      src/android/app/src/main/res/menu/menu_game_grid.xml
  29. 14 0
      src/android/app/src/main/res/menu/menu_navigation.xml
  30. 17 0
      src/android/app/src/main/res/navigation/home_navigation.xml
  31. 6 1
      src/android/app/src/main/res/values/dimens.xml
  32. 18 11
      src/android/app/src/main/res/values/strings.xml

+ 3 - 0
src/android/app/build.gradle.kts

@@ -155,6 +155,9 @@ dependencies {
     implementation("org.ini4j:ini4j:0.5.4")
     implementation("androidx.constraintlayout:constraintlayout:2.1.4")
     implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+    implementation("androidx.navigation:navigation-fragment-ktx:2.5.3")
+    implementation("androidx.navigation:navigation-ui-ktx:2.5.3")
+    implementation("info.debatty:java-string-similarity:2.0.0")
 }
 
 fun getVersion(): String {

+ 1 - 2
src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt

@@ -13,7 +13,6 @@ import android.view.View
 import android.view.WindowManager
 import android.view.inputmethod.InputMethodManager
 import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.FragmentActivity
 import androidx.preference.PreferenceManager
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.google.android.material.slider.Slider.OnChangeListener
@@ -202,7 +201,7 @@ open class EmulationActivity : AppCompatActivity() {
         private const val EMULATION_RUNNING_NOTIFICATION = 0x1000
 
         @JvmStatic
-        fun launch(activity: FragmentActivity, game: Game) {
+        fun launch(activity: AppCompatActivity, game: Game) {
             val launcher = Intent(activity, EmulationActivity::class.java)
             launcher.putExtra(EXTRA_SELECTED_GAME, game)
             activity.startActivity(launcher)

+ 21 - 21
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt

@@ -3,6 +3,7 @@
 
 package org.yuzu.yuzu_emu.adapters
 
+import android.annotation.SuppressLint
 import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.view.LayoutInflater
@@ -11,29 +12,25 @@ import android.view.ViewGroup
 import android.widget.ImageView
 import androidx.appcompat.app.AppCompatActivity
 import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
 import androidx.recyclerview.widget.RecyclerView
 import coil.load
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.CardGameBinding
 import org.yuzu.yuzu_emu.activities.EmulationActivity
 import org.yuzu.yuzu_emu.model.Game
-import kotlin.collections.ArrayList
-
-/**
- * This adapter gets its information from a database Cursor. This fact, paired with the usage of
- * ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly)
- * large dataset.
- */
-class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<Game>) :
-    RecyclerView.Adapter<GameAdapter.GameViewHolder>(),
+import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder
+
+class GameAdapter(private val activity: AppCompatActivity) :
+    ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
     View.OnClickListener {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
         // Create a new view.
-        val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context))
+        val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
         binding.root.setOnClickListener(this)
 
         // Use that view to create a ViewHolder.
@@ -41,12 +38,10 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
     }
 
     override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
-        holder.bind(games[position])
+        holder.bind(currentList[position])
     }
 
-    override fun getItemCount(): Int {
-        return games.size
-    }
+    override fun getItemCount(): Int = currentList.size
 
     /**
      * Launches the game that was clicked on.
@@ -55,7 +50,7 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
      */
     override fun onClick(view: View) {
         val holder = view.tag as GameViewHolder
-        EmulationActivity.launch((view.context as AppCompatActivity), holder.game)
+        EmulationActivity.launch(activity, holder.game)
     }
 
     inner class GameViewHolder(val binding: CardGameBinding) :
@@ -74,7 +69,6 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
                 val bitmap = decodeGameIcon(game.path)
                 binding.imageGameScreen.load(bitmap) {
                     error(R.drawable.no_icon)
-                    crossfade(true)
                 }
             }
 
@@ -87,9 +81,15 @@ class GameAdapter(private val activity: AppCompatActivity, var games: ArrayList<
         }
     }
 
-    fun swapData(games: ArrayList<Game>) {
-        this.games = games
-        notifyDataSetChanged()
+    private class DiffCallback : DiffUtil.ItemCallback<Game>() {
+        override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
+            return oldItem.gameId == newItem.gameId
+        }
+
+        @SuppressLint("DiffUtilEquals")
+        override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
+            return oldItem == newItem
+        }
     }
 
     private fun decodeGameIcon(uri: String): Bitmap? {

+ 55 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeOptionAdapter.kt

@@ -0,0 +1,55 @@
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.res.ResourcesCompat
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding
+import org.yuzu.yuzu_emu.model.HomeOption
+
+class HomeOptionAdapter(private val activity: AppCompatActivity, var options: List<HomeOption>) :
+    RecyclerView.Adapter<HomeOptionAdapter.HomeOptionViewHolder>(),
+    View.OnClickListener {
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
+        val binding = CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        binding.root.setOnClickListener(this)
+        return HomeOptionViewHolder(binding)
+    }
+
+    override fun getItemCount(): Int {
+        return options.size
+    }
+
+    override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
+        holder.bind(options[position])
+    }
+
+    override fun onClick(view: View) {
+        val holder = view.tag as HomeOptionViewHolder
+        holder.option.onClick.invoke()
+    }
+
+    inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
+        RecyclerView.ViewHolder(binding.root) {
+        lateinit var option: HomeOption
+
+        init {
+            itemView.tag = this
+        }
+
+        fun bind(option: HomeOption) {
+            this.option = option
+            binding.optionTitle.text = activity.resources.getString(option.titleId)
+            binding.optionDescription.text = activity.resources.getString(option.descriptionId)
+            binding.optionIcon.setImageDrawable(
+                ResourcesCompat.getDrawable(
+                    activity.resources,
+                    option.iconId,
+                    activity.theme
+                )
+            )
+        }
+    }
+}

+ 6 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt

@@ -15,6 +15,7 @@ import androidx.core.view.ViewCompat
 import androidx.core.view.WindowCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updatePadding
+import com.google.android.material.color.MaterialColors
 import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
@@ -50,6 +51,11 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView {
         setSupportActionBar(binding.toolbarSettings)
         supportActionBar!!.setDisplayHomeAsUpEnabled(true)
 
+        ThemeHelper.setNavigationBarColor(
+            this,
+            MaterialColors.getColor(window.decorView, R.attr.colorSurface)
+        )
+
         setInsets()
     }
 

+ 281 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/OptionsFragment.kt

@@ -0,0 +1,281 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.DialogInterface
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.HomeOptionAdapter
+import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.databinding.FragmentOptionsBinding
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeOption
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.GameHelper
+import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+import java.io.IOException
+
+class OptionsFragment : Fragment() {
+    private var _binding: FragmentOptionsBinding? = null
+    private val binding get() = _binding!!
+
+    private val gamesViewModel: GamesViewModel by activityViewModels()
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        _binding = FragmentOptionsBinding.inflate(layoutInflater)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        val optionsList: List<HomeOption> = listOf(
+            HomeOption(
+                R.string.add_games,
+                R.string.add_games_description,
+                R.drawable.ic_add
+            ) { getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
+            HomeOption(
+                R.string.install_prod_keys,
+                R.string.install_prod_keys_description,
+                R.drawable.ic_unlock
+            ) { getProdKey.launch(arrayOf("*/*")) },
+            HomeOption(
+                R.string.install_amiibo_keys,
+                R.string.install_amiibo_keys_description,
+                R.drawable.ic_nfc
+            ) { getAmiiboKey.launch(arrayOf("*/*")) },
+            HomeOption(
+                R.string.install_gpu_driver,
+                R.string.install_gpu_driver_description,
+                R.drawable.ic_input
+            ) { driverInstaller() },
+            HomeOption(
+                R.string.settings,
+                R.string.settings_description,
+                R.drawable.ic_settings
+            ) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") }
+        )
+
+        binding.optionsList.apply {
+            layoutManager = LinearLayoutManager(requireContext())
+            adapter = HomeOptionAdapter(requireActivity() as AppCompatActivity, optionsList)
+        }
+
+        requireActivity().window.statusBarColor = ThemeHelper.getColorWithOpacity(
+            MaterialColors.getColor(
+                binding.root,
+                R.attr.colorSurface
+            ), ThemeHelper.SYSTEM_BAR_ALPHA
+        )
+
+        setInsets()
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        _binding = null
+    }
+
+    private fun driverInstaller() {
+        // Get the driver name for the dialog message.
+        var driverName = GpuDriverHelper.customDriverName
+        if (driverName == null) {
+            driverName = getString(R.string.system_gpu_driver)
+        }
+
+        MaterialAlertDialogBuilder(requireContext())
+            .setTitle(getString(R.string.select_gpu_driver_title))
+            .setMessage(driverName)
+            .setNegativeButton(android.R.string.cancel, null)
+            .setPositiveButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
+                GpuDriverHelper.installDefaultDriver(requireContext())
+                Toast.makeText(
+                    requireContext(),
+                    R.string.select_gpu_driver_use_default,
+                    Toast.LENGTH_SHORT
+                ).show()
+            }
+            .setNeutralButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
+                getDriver.launch(arrayOf("application/zip"))
+            }
+            .show()
+    }
+
+    private fun setInsets() =
+        ViewCompat.setOnApplyWindowInsetsListener(binding.scrollViewOptions) { view: View, windowInsets: WindowInsetsCompat ->
+            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+            view.setPadding(
+                insets.left,
+                insets.top,
+                insets.right,
+                insets.bottom + resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+            )
+            windowInsets
+        }
+
+    private val getGamesDirectory =
+        registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
+            if (result == null)
+                return@registerForActivityResult
+
+            val takeFlags =
+                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
+            requireActivity().contentResolver.takePersistableUriPermission(
+                result,
+                takeFlags
+            )
+
+            // When a new directory is picked, we currently will reset the existing games
+            // database. This effectively means that only one game directory is supported.
+            PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
+                .putString(GameHelper.KEY_GAME_PATH, result.toString())
+                .apply()
+
+            gamesViewModel.reloadGames(true)
+        }
+
+    private val getProdKey =
+        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+            if (result == null)
+                return@registerForActivityResult
+
+            val takeFlags =
+                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
+            requireActivity().contentResolver.takePersistableUriPermission(
+                result,
+                takeFlags
+            )
+
+            val dstPath = DirectoryInitialization.userDirectory + "/keys/"
+            if (FileUtil.copyUriToInternalStorage(requireContext(), result, dstPath, "prod.keys")) {
+                if (NativeLibrary.reloadKeys()) {
+                    Toast.makeText(
+                        requireContext(),
+                        R.string.install_keys_success,
+                        Toast.LENGTH_SHORT
+                    ).show()
+                    gamesViewModel.reloadGames(true)
+                } else {
+                    Toast.makeText(
+                        requireContext(),
+                        R.string.install_keys_failure,
+                        Toast.LENGTH_LONG
+                    ).show()
+                }
+            }
+        }
+
+    private val getAmiiboKey =
+        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+            if (result == null)
+                return@registerForActivityResult
+
+            val takeFlags =
+                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
+            requireActivity().contentResolver.takePersistableUriPermission(
+                result,
+                takeFlags
+            )
+
+            val dstPath = DirectoryInitialization.userDirectory + "/keys/"
+            if (FileUtil.copyUriToInternalStorage(
+                    requireContext(),
+                    result,
+                    dstPath,
+                    "key_retail.bin"
+                )
+            ) {
+                if (NativeLibrary.reloadKeys()) {
+                    Toast.makeText(
+                        requireContext(),
+                        R.string.install_keys_success,
+                        Toast.LENGTH_SHORT
+                    ).show()
+                } else {
+                    Toast.makeText(
+                        requireContext(),
+                        R.string.install_amiibo_keys_failure,
+                        Toast.LENGTH_LONG
+                    ).show()
+                }
+            }
+        }
+
+    private val getDriver =
+        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+            if (result == null)
+                return@registerForActivityResult
+
+            val takeFlags =
+                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
+            requireActivity().contentResolver.takePersistableUriPermission(
+                result,
+                takeFlags
+            )
+
+            val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
+            progressBinding.progressBar.isIndeterminate = true
+            val installationDialog = MaterialAlertDialogBuilder(requireContext())
+                .setTitle(R.string.installing_driver)
+                .setView(progressBinding.root)
+                .show()
+
+            lifecycleScope.launch {
+                withContext(Dispatchers.IO) {
+                    // Ignore file exceptions when a user selects an invalid zip
+                    try {
+                        GpuDriverHelper.installCustomDriver(requireContext(), result)
+                    } catch (_: IOException) {
+                    }
+
+                    withContext(Dispatchers.Main) {
+                        installationDialog.dismiss()
+
+                        val driverName = GpuDriverHelper.customDriverName
+                        if (driverName != null) {
+                            Toast.makeText(
+                                requireContext(),
+                                getString(
+                                    R.string.select_gpu_driver_install_success,
+                                    driverName
+                                ),
+                                Toast.LENGTH_SHORT
+                            ).show()
+                        } else {
+                            Toast.makeText(
+                                requireContext(),
+                                R.string.select_gpu_driver_error,
+                                Toast.LENGTH_LONG
+                            ).show()
+                        }
+                    }
+                }
+            }
+        }
+}

+ 45 - 5
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt

@@ -1,18 +1,58 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
 package org.yuzu.yuzu_emu.model
 
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.utils.GameHelper
 
 class GamesViewModel : ViewModel() {
-    private val _games = MutableLiveData<ArrayList<Game>>()
-    val games: LiveData<ArrayList<Game>> get() = _games
+    private val _games = MutableLiveData<List<Game>>(emptyList())
+    val games: LiveData<List<Game>> get() = _games
+
+    private val _searchedGames = MutableLiveData<List<Game>>(emptyList())
+    val searchedGames: LiveData<List<Game>> get() = _searchedGames
+
+    private val _isReloading = MutableLiveData(false)
+    val isReloading: LiveData<Boolean> get() = _isReloading
+
+    private val _shouldSwapData = MutableLiveData(false)
+    val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData
 
     init {
-        _games.value = ArrayList()
+        reloadGames(false)
+    }
+
+    fun setSearchedGames(games: List<Game>) {
+        _searchedGames.postValue(games)
+    }
+
+    fun setShouldSwapData(shouldSwap: Boolean) {
+        _shouldSwapData.postValue(shouldSwap)
     }
 
-    fun setGames(games: ArrayList<Game>) {
-        _games.value = games
+    fun reloadGames(directoryChanged: Boolean) {
+        if (isReloading.value == true)
+            return
+        _isReloading.postValue(true)
+
+        viewModelScope.launch {
+            withContext(Dispatchers.IO) {
+                NativeLibrary.resetRomMetadata()
+                _games.postValue(GameHelper.getGames())
+                _isReloading.postValue(false)
+
+                if (directoryChanged) {
+                    setShouldSwapData(true)
+                }
+            }
+        }
     }
 }

+ 11 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeOption.kt

@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+data class HomeOption(
+    val titleId: Int,
+    val descriptionId: Int,
+    val iconId: Int,
+    val onClick: () -> Unit
+)

+ 17 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt

@@ -0,0 +1,17 @@
+package org.yuzu.yuzu_emu.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+
+class HomeViewModel : ViewModel() {
+    private val _navigationVisible = MutableLiveData(true)
+    val navigationVisible: LiveData<Boolean> get() = _navigationVisible
+
+    fun setNavigationVisible(visible: Boolean) {
+        if (_navigationVisible.value == visible) {
+            return
+        }
+        _navigationVisible.value = visible
+    }
+}

+ 220 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt

@@ -0,0 +1,220 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+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 com.google.android.material.color.MaterialColors
+import com.google.android.material.search.SearchView
+import com.google.android.material.search.SearchView.TransitionState
+import info.debatty.java.stringsimilarity.Jaccard
+import org.yuzu.yuzu_emu.R
+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.utils.ThemeHelper
+import java.util.Locale
+
+class GamesFragment : Fragment() {
+    private var _binding: FragmentGamesBinding? = null
+    private val binding get() = _binding!!
+
+    private val gamesViewModel: GamesViewModel by activityViewModels()
+    private val homeViewModel: HomeViewModel by activityViewModels()
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        _binding = FragmentGamesBinding.inflate(inflater)
+        return binding.root
+    }
+
+    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()
+                    }
+                }
+            })
+
+        binding.gridGames.apply {
+            layoutManager = AutofitGridLayoutManager(
+                requireContext(),
+                requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
+            )
+            adapter = GameAdapter(requireActivity() as AppCompatActivity)
+        }
+        setUpSearch()
+
+        // Add swipe down to refresh gesture
+        binding.swipeRefresh.setOnRefreshListener {
+            gamesViewModel.reloadGames(false)
+        }
+
+        // Set theme color to the refresh animation's background
+        binding.swipeRefresh.setProgressBackgroundColorSchemeColor(
+            MaterialColors.getColor(binding.swipeRefresh, R.attr.colorPrimary)
+        )
+        binding.swipeRefresh.setColorSchemeColors(
+            MaterialColors.getColor(binding.swipeRefresh, R.attr.colorOnPrimary)
+        )
+
+        // 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)
+        }
+        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)
+                gamesViewModel.setShouldSwapData(false)
+            }
+        }
+
+        // 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()
+                }
+            }
+        }
+
+        // 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()
+        }
+
+        setInsets()
+
+        // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
+        binding.swipeRefresh.post {
+            binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!!
+        }
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        _binding = null
+    }
+
+    private fun searchShown() {
+        homeViewModel.setNavigationVisible(false)
+        requireActivity().window.statusBarColor =
+            ContextCompat.getColor(requireContext(), android.R.color.transparent)
+    }
+
+    private fun searchHidden() {
+        homeViewModel.setNavigationVisible(true)
+        requireActivity().window.statusBarColor = ThemeHelper.getColorWithOpacity(
+            MaterialColors.getColor(
+                binding.root,
+                R.attr.colorSurface
+            ), ThemeHelper.SYSTEM_BAR_ALPHA
+        )
+    }
+
+    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)
+        }
+    }
+
+    private fun setInsets() =
+        ViewCompat.setOnApplyWindowInsetsListener(binding.gridGames) { view: View, windowInsets: WindowInsetsCompat ->
+            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+            val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
+
+            view.setPadding(
+                insets.left,
+                insets.top + resources.getDimensionPixelSize(R.dimen.spacing_search),
+                insets.right,
+                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(
+                false,
+                insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_start),
+                insets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
+            )
+
+            windowInsets
+        }
+}

+ 63 - 244
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt

@@ -3,42 +3,31 @@
 
 package org.yuzu.yuzu_emu.ui.main
 
-import android.content.DialogInterface
-import android.content.Intent
 import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
 import android.view.View
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
+import android.view.ViewGroup.MarginLayoutParams
+import android.view.animation.PathInterpolator
+import androidx.activity.viewModels
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowCompat
 import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.updatePadding
-import androidx.lifecycle.lifecycleScope
-import androidx.preference.PreferenceManager
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.yuzu.yuzu_emu.NativeLibrary
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.setupWithNavController
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.elevation.ElevationOverlayProvider
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.activities.EmulationActivity
 import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
-import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
-import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
-import org.yuzu.yuzu_emu.ui.platform.PlatformGamesFragment
+import org.yuzu.yuzu_emu.model.HomeViewModel
 import org.yuzu.yuzu_emu.utils.*
-import java.io.IOException
-
-class MainActivity : AppCompatActivity(), MainView {
-    private var platformGamesFragment: PlatformGamesFragment? = null
-    private val presenter = MainPresenter(this)
 
+class MainActivity : AppCompatActivity() {
     private lateinit var binding: ActivityMainBinding
 
+    private val homeViewModel: HomeViewModel by viewModels()
+
     override fun onCreate(savedInstanceState: Bundle?) {
         val splashScreen = installSplashScreen()
         splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
@@ -52,19 +41,36 @@ class MainActivity : AppCompatActivity(), MainView {
 
         WindowCompat.setDecorFitsSystemWindows(window, false)
 
-        setSupportActionBar(binding.toolbarMain)
-        presenter.onCreate()
-        if (savedInstanceState == null) {
-            StartupHandler.handleInit(this)
-            platformGamesFragment = PlatformGamesFragment()
-            supportFragmentManager.beginTransaction()
-                .add(R.id.games_platform_frame, platformGamesFragment!!)
-                .commit()
-        } else {
-            platformGamesFragment = supportFragmentManager.getFragment(
-                savedInstanceState,
-                PlatformGamesFragment.TAG
-            ) as PlatformGamesFragment?
+        ThemeHelper.setNavigationBarColor(
+            this,
+            ElevationOverlayProvider(binding.navigationBar.context).compositeOverlay(
+                MaterialColors.getColor(binding.navigationBar, R.attr.colorSurface),
+                binding.navigationBar.elevation
+            )
+        )
+
+        // Set up a central host fragment that is controlled via bottom navigation with xml navigation
+        val navHostFragment =
+            supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
+        binding.navigationBar.setupWithNavController(navHostFragment.navController)
+
+        binding.statusBarShade.setBackgroundColor(
+            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) {
+            binding.navigationBar.visibility = View.INVISIBLE
+            binding.statusBarShade.visibility = View.INVISIBLE
+        }
+
+        homeViewModel.navigationVisible.observe(this) { visible ->
+            showNavigation(visible)
         }
 
         // Dismiss previous notifications (should not happen unless a crash occurred)
@@ -73,78 +79,24 @@ class MainActivity : AppCompatActivity(), MainView {
         setInsets()
     }
 
-    override fun onSaveInstanceState(outState: Bundle) {
-        super.onSaveInstanceState(outState)
-        supportFragmentManager.putFragment(
-            outState,
-            PlatformGamesFragment.TAG,
-            platformGamesFragment!!
-        )
-    }
-
-    override fun onCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.menu_game_grid, menu)
-        return true
-    }
-
-    /**
-     * MainView
-     */
-    override fun setVersionString(version: String) {
-        binding.toolbarMain.subtitle = version
-    }
-
-    override fun launchSettingsActivity(menuTag: String) {
-        SettingsActivity.launch(this, menuTag, "")
-    }
-
-    override fun launchFileListActivity(request: Int) {
-        when (request) {
-            MainPresenter.REQUEST_ADD_DIRECTORY -> getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data)
-            MainPresenter.REQUEST_INSTALL_KEYS -> getProdKey.launch(arrayOf("*/*"))
-            MainPresenter.REQUEST_INSTALL_AMIIBO_KEYS -> getAmiiboKey.launch(arrayOf("*/*"))
-            MainPresenter.REQUEST_SELECT_GPU_DRIVER -> {
-                // Get the driver name for the dialog message.
-                var driverName = GpuDriverHelper.customDriverName
-                if (driverName == null) {
-                    driverName = getString(R.string.system_gpu_driver)
-                }
-
-                MaterialAlertDialogBuilder(this)
-                    .setTitle(getString(R.string.select_gpu_driver_title))
-                    .setMessage(driverName)
-                    .setNegativeButton(android.R.string.cancel, null)
-                    .setPositiveButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
-                        GpuDriverHelper.installDefaultDriver(this)
-                        Toast.makeText(
-                            this,
-                            R.string.select_gpu_driver_use_default,
-                            Toast.LENGTH_SHORT
-                        ).show()
-                    }
-                    .setNeutralButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
-                        getDriver.launch(arrayOf("application/zip"))
-                    }
-                    .show()
+    private fun showNavigation(visible: Boolean) {
+        binding.navigationBar.animate().apply {
+            if (visible) {
+                binding.navigationBar.visibility = View.VISIBLE
+                binding.navigationBar.translationY = binding.navigationBar.height.toFloat() * 2
+                duration = 300
+                translationY(0f)
+                interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
+            } else {
+                duration = 300
+                translationY(binding.navigationBar.height.toFloat() * 2)
+                interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
             }
-        }
-    }
-
-    /**
-     * Called by the framework whenever any actionbar/toolbar icon is clicked.
-     *
-     * @param item The icon that was clicked on.
-     * @return True if the event was handled, false to bubble it up to the OS.
-     */
-    override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        return presenter.handleOptionSelection(item.itemId)
-    }
-
-    private fun refreshFragment() {
-        if (platformGamesFragment != null) {
-            NativeLibrary.resetRomMetadata()
-            platformGamesFragment!!.refresh()
-        }
+        }.withEndAction {
+            if (!visible) {
+                binding.navigationBar.visibility = View.INVISIBLE
+            }
+        }.start()
     }
 
     override fun onDestroy() {
@@ -152,145 +104,12 @@ class MainActivity : AppCompatActivity(), MainView {
         super.onDestroy()
     }
 
-    private fun setInsets() {
-        ViewCompat.setOnApplyWindowInsetsListener(binding.gamesPlatformFrame) { view: View, windowInsets: WindowInsetsCompat ->
+    private fun setInsets() =
+        ViewCompat.setOnApplyWindowInsetsListener(binding.statusBarShade) { view: View, windowInsets: WindowInsetsCompat ->
             val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
-            view.updatePadding(left = insets.left, right = insets.right)
-            InsetsHelper.insetAppBar(insets, binding.appbarMain)
+            val mlpShade = view.layoutParams as MarginLayoutParams
+            mlpShade.height = insets.top
+            binding.statusBarShade.layoutParams = mlpShade
             windowInsets
         }
-    }
-
-    private val getGamesDirectory =
-        registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
-            if (result == null)
-                return@registerForActivityResult
-
-            val takeFlags =
-                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
-            contentResolver.takePersistableUriPermission(
-                result,
-                takeFlags
-            )
-
-            // When a new directory is picked, we currently will reset the existing games
-            // database. This effectively means that only one game directory is supported.
-            PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
-                .putString(GameHelper.KEY_GAME_PATH, result.toString())
-                .apply()
-        }
-
-    private val getProdKey =
-        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
-            if (result == null)
-                return@registerForActivityResult
-
-            val takeFlags =
-                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
-            contentResolver.takePersistableUriPermission(
-                result,
-                takeFlags
-            )
-
-            val dstPath = DirectoryInitialization.userDirectory + "/keys/"
-            if (FileUtil.copyUriToInternalStorage(this, result, dstPath, "prod.keys")) {
-                if (NativeLibrary.reloadKeys()) {
-                    Toast.makeText(
-                        this,
-                        R.string.install_keys_success,
-                        Toast.LENGTH_SHORT
-                    ).show()
-                    refreshFragment()
-                } else {
-                    Toast.makeText(
-                        this,
-                        R.string.install_keys_failure,
-                        Toast.LENGTH_LONG
-                    ).show()
-                }
-            }
-        }
-
-    private val getAmiiboKey =
-        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
-            if (result == null)
-                return@registerForActivityResult
-
-            val takeFlags =
-                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
-            contentResolver.takePersistableUriPermission(
-                result,
-                takeFlags
-            )
-
-            val dstPath = DirectoryInitialization.userDirectory + "/keys/"
-            if (FileUtil.copyUriToInternalStorage(this, result, dstPath, "key_retail.bin")) {
-                if (NativeLibrary.reloadKeys()) {
-                    Toast.makeText(
-                        this,
-                        R.string.install_keys_success,
-                        Toast.LENGTH_SHORT
-                    ).show()
-                    refreshFragment()
-                } else {
-                    Toast.makeText(
-                        this,
-                        R.string.install_amiibo_keys_failure,
-                        Toast.LENGTH_LONG
-                    ).show()
-                }
-            }
-        }
-
-    private val getDriver =
-        registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
-            if (result == null)
-                return@registerForActivityResult
-
-            val takeFlags =
-                Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
-            contentResolver.takePersistableUriPermission(
-                result,
-                takeFlags
-            )
-
-            val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
-            progressBinding.progressBar.isIndeterminate = true
-            val installationDialog = MaterialAlertDialogBuilder(this)
-                .setTitle(R.string.installing_driver)
-                .setView(progressBinding.root)
-                .show()
-
-            lifecycleScope.launch {
-                withContext(Dispatchers.IO) {
-                    // Ignore file exceptions when a user selects an invalid zip
-                    try {
-                        GpuDriverHelper.installCustomDriver(applicationContext, result)
-                    } catch (_: IOException) {
-                    }
-
-                    withContext(Dispatchers.Main) {
-                        installationDialog.dismiss()
-
-                        val driverName = GpuDriverHelper.customDriverName
-                        if (driverName != null) {
-                            Toast.makeText(
-                                applicationContext,
-                                getString(
-                                    R.string.select_gpu_driver_install_success,
-                                    driverName
-                                ),
-                                Toast.LENGTH_SHORT
-                            ).show()
-                        } else {
-                            Toast.makeText(
-                                applicationContext,
-                                R.string.select_gpu_driver_error,
-                                Toast.LENGTH_LONG
-                            ).show()
-                        }
-                    }
-                }
-            }
-        }
 }

+ 0 - 52
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainPresenter.kt

@@ -1,52 +0,0 @@
-// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.yuzu.yuzu_emu.ui.main
-
-import org.yuzu.yuzu_emu.BuildConfig
-import org.yuzu.yuzu_emu.R
-import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
-
-class MainPresenter(private val view: MainView) {
-    fun onCreate() {
-        val versionName = BuildConfig.VERSION_NAME
-        view.setVersionString(versionName)
-    }
-
-    private fun launchFileListActivity(request: Int) {
-        view.launchFileListActivity(request)
-    }
-
-    fun handleOptionSelection(itemId: Int): Boolean {
-        when (itemId) {
-            R.id.menu_settings_core -> {
-                view.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG)
-                return true
-            }
-            R.id.button_add_directory -> {
-                launchFileListActivity(REQUEST_ADD_DIRECTORY)
-                return true
-            }
-            R.id.button_install_keys -> {
-                launchFileListActivity(REQUEST_INSTALL_KEYS)
-                return true
-            }
-            R.id.button_install_amiibo_keys -> {
-                launchFileListActivity(REQUEST_INSTALL_AMIIBO_KEYS)
-                return true
-            }
-            R.id.button_select_gpu_driver -> {
-                launchFileListActivity(REQUEST_SELECT_GPU_DRIVER)
-                return true
-            }
-        }
-        return false
-    }
-
-    companion object {
-        const val REQUEST_ADD_DIRECTORY = 1
-        const val REQUEST_INSTALL_KEYS = 2
-        const val REQUEST_INSTALL_AMIIBO_KEYS = 3
-        const val REQUEST_SELECT_GPU_DRIVER = 4
-    }
-}

+ 0 - 23
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainView.kt

@@ -1,23 +0,0 @@
-// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.yuzu.yuzu_emu.ui.main
-
-/**
- * Abstraction for the screen that shows on application launch.
- * Implementations will differ primarily to target touch-screen
- * or non-touch screen devices.
- */
-interface MainView {
-    /**
-     * Pass the view the native library's version string. Displaying
-     * it is optional.
-     *
-     * @param version A string pulled from native code.
-     */
-    fun setVersionString(version: String)
-
-    fun launchSettingsActivity(menuTag: String)
-
-    fun launchFileListActivity(request: Int)
-}

+ 0 - 109
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/platform/PlatformGamesFragment.kt

@@ -1,109 +0,0 @@
-// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.yuzu.yuzu_emu.ui.platform
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.updatePadding
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.ViewModelProvider
-import com.google.android.material.color.MaterialColors
-import org.yuzu.yuzu_emu.R
-import org.yuzu.yuzu_emu.adapters.GameAdapter
-import org.yuzu.yuzu_emu.databinding.FragmentGridBinding
-import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
-import org.yuzu.yuzu_emu.model.GamesViewModel
-import org.yuzu.yuzu_emu.utils.GameHelper
-
-class PlatformGamesFragment : Fragment() {
-    private var _binding: FragmentGridBinding? = null
-    private val binding get() = _binding!!
-
-    private lateinit var gamesViewModel: GamesViewModel
-
-    override fun onCreateView(
-        inflater: LayoutInflater,
-        container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View {
-        _binding = FragmentGridBinding.inflate(inflater)
-        return binding.root
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        gamesViewModel = ViewModelProvider(requireActivity())[GamesViewModel::class.java]
-
-        binding.gridGames.apply {
-            layoutManager = AutofitGridLayoutManager(
-                requireContext(),
-                requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
-            )
-            adapter =
-                GameAdapter(requireActivity() as AppCompatActivity, gamesViewModel.games.value!!)
-        }
-
-        // Add swipe down to refresh gesture
-        binding.swipeRefresh.setOnRefreshListener {
-            refresh()
-            binding.swipeRefresh.isRefreshing = false
-        }
-
-        // Set theme color to the refresh animation's background
-        binding.swipeRefresh.setProgressBackgroundColorSchemeColor(
-            MaterialColors.getColor(binding.swipeRefresh, R.attr.colorPrimary)
-        )
-        binding.swipeRefresh.setColorSchemeColors(
-            MaterialColors.getColor(binding.swipeRefresh, R.attr.colorOnPrimary)
-        )
-
-        gamesViewModel.games.observe(viewLifecycleOwner) {
-            (binding.gridGames.adapter as GameAdapter).swapData(it)
-            updateTextView()
-        }
-
-        setInsets()
-
-        refresh()
-    }
-
-    override fun onResume() {
-        super.onResume()
-        refresh()
-    }
-
-    override fun onDestroyView() {
-        super.onDestroyView()
-        _binding = null
-    }
-
-    fun refresh() {
-        gamesViewModel.setGames(GameHelper.getGames())
-        updateTextView()
-    }
-
-    private fun updateTextView() {
-        if (_binding == null)
-            return
-
-        binding.gamelistEmptyText.visibility =
-            if ((binding.gridGames.adapter as GameAdapter).itemCount == 0) View.VISIBLE else View.GONE
-    }
-
-    private fun setInsets() {
-        ViewCompat.setOnApplyWindowInsetsListener(binding.gridGames) { view: View, windowInsets: WindowInsetsCompat ->
-            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
-            view.updatePadding(bottom = insets.bottom)
-            windowInsets
-        }
-    }
-
-    companion object {
-        const val TAG = "PlatformGamesFragment"
-    }
-}

+ 0 - 48
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/StartupHandler.kt

@@ -1,48 +0,0 @@
-// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.yuzu.yuzu_emu.utils
-
-import androidx.preference.PreferenceManager
-import android.text.Html
-import android.text.method.LinkMovementMethod
-import android.view.View
-import android.widget.TextView
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import org.yuzu.yuzu_emu.R
-import org.yuzu.yuzu_emu.YuzuApplication
-import org.yuzu.yuzu_emu.features.settings.model.Settings
-import org.yuzu.yuzu_emu.ui.main.MainActivity
-import org.yuzu.yuzu_emu.ui.main.MainPresenter
-
-object StartupHandler {
-    private val preferences =
-        PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
-
-    private fun handleStartupPromptDismiss(parent: MainActivity) {
-        parent.launchFileListActivity(MainPresenter.REQUEST_INSTALL_KEYS)
-    }
-
-    private fun markFirstBoot() {
-        preferences.edit()
-            .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false)
-            .apply()
-    }
-
-    fun handleInit(parent: MainActivity) {
-        if (preferences.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)) {
-            markFirstBoot()
-            val alert = MaterialAlertDialogBuilder(parent)
-                .setMessage(Html.fromHtml(parent.resources.getString(R.string.app_disclaimer)))
-                .setTitle(R.string.app_name)
-                .setIcon(R.drawable.ic_launcher)
-                .setPositiveButton(android.R.string.ok, null)
-                .setOnDismissListener {
-                    handleStartupPromptDismiss(parent)
-                }
-                .show()
-            (alert.findViewById<View>(android.R.id.message) as TextView?)!!.movementMethod =
-                LinkMovementMethod.getInstance()
-        }
-    }
-}

+ 3 - 7
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt

@@ -15,7 +15,7 @@ import org.yuzu.yuzu_emu.R
 import kotlin.math.roundToInt
 
 object ThemeHelper {
-    private const val NAV_BAR_ALPHA = 0.9f
+    const val SYSTEM_BAR_ALPHA = 0.9f
 
     @JvmStatic
     fun setTheme(activity: AppCompatActivity) {
@@ -29,10 +29,6 @@ object ThemeHelper {
         windowController.isAppearanceLightNavigationBars = isLightMode
 
         activity.window.statusBarColor = ContextCompat.getColor(activity, android.R.color.transparent)
-
-        val navigationBarColor =
-            MaterialColors.getColor(activity.window.decorView, R.attr.colorSurface)
-        setNavigationBarColor(activity, navigationBarColor)
     }
 
     @JvmStatic
@@ -48,7 +44,7 @@ object ThemeHelper {
         } else if (gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION ||
             gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION
         ) {
-            activity.window.navigationBarColor = getColorWithOpacity(color, NAV_BAR_ALPHA)
+            activity.window.navigationBarColor = getColorWithOpacity(color, SYSTEM_BAR_ALPHA)
         } else {
             activity.window.navigationBarColor = ContextCompat.getColor(
                 activity.applicationContext,
@@ -58,7 +54,7 @@ object ThemeHelper {
     }
 
     @ColorInt
-    private fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int {
+    fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int {
         return Color.argb(
             (alphaFactor * Color.alpha(color)).roundToInt(), Color.red(color),
             Color.green(color), Color.blue(color)

+ 9 - 0
src/android/app/src/main/res/drawable/ic_add.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,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
+</vector>

+ 10 - 0
src/android/app/src/main/res/drawable/ic_input.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="?attr/colorControlNormal"
+        android:pathData="M21,3.01H3c-1.1,0 -2,0.9 -2,2V9h2V4.99h18v14.03H3V15H1v4.01c0,1.1 0.9,1.98 2,1.98h18c1.1,0 2,-0.88 2,-1.98v-14c0,-1.11 -0.9,-2 -2,-2zM11,16l4,-4 -4,-4v3H1v2h10v3z" />
+</vector>

+ 9 - 0
src/android/app/src/main/res/drawable/ic_nfc.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="M20,2L4,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,20L4,20L4,4h16v16zM18,6h-5c-1.1,0 -2,0.9 -2,2v2.28c-0.6,0.35 -1,0.98 -1,1.72 0,1.1 0.9,2 2,2s2,-0.9 2,-2c0,-0.74 -0.4,-1.38 -1,-1.72L13,8h3v8L8,16L8,8h2L10,6L6,6v12h12L18,6z" />
+</vector>

+ 9 - 0
src/android/app/src/main/res/drawable/ic_options.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="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" />
+</vector>

+ 9 - 0
src/android/app/src/main/res/drawable/ic_unlock.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="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z" />
+</vector>

+ 18 - 0
src/android/app/src/main/res/drawable/ic_yuzu_themed.xml

@@ -0,0 +1,18 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="614.697dp"
+    android:height="683dp"
+    android:viewportWidth="614.4"
+    android:viewportHeight="682.67">
+    <group>
+        <clip-path android:pathData="M-43,-46.67h699.6v777.33h-699.6z" />
+        <path
+            android:fillColor="?attr/colorPrimary"
+            android:pathData="M340.81,138V682.08c150.26,0 272.06,-121.81 272.06,-272.06S491.07,138 340.81,138M394,197.55a219.06,219.06 0,0 1,0 424.94V197.55" />
+    </group>
+    <group>
+        <clip-path android:pathData="M-43,-46.67h699.6v777.33h-699.6z" />
+        <path
+            android:fillColor="?attr/colorPrimary"
+            android:pathData="M272.79,1.92C122.53,1.92 0.73,123.73 0.73,274s121.8,272.07 272.06,272.07ZM219.65,61.51v425A219,219 0,0 1,118 119.18,217.51 217.51,0 0,1 219.65,61.51" />
+    </group>
+</vector>

+ 23 - 19
src/android/app/src/main/res/layout/activity_main.xml

@@ -1,28 +1,32 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<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:id="@+id/coordinator_main"
     android:layout_width="match_parent"
     android:layout_height="match_parent">
 
-    <com.google.android.material.appbar.AppBarLayout
-        android:id="@+id/appbar_main"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:fitsSystemWindows="true"
-        app:liftOnScrollTargetViewId="@id/grid_games">
-
-        <androidx.appcompat.widget.Toolbar
-            android:id="@+id/toolbar_main"
-            android:layout_width="match_parent"
-            android:layout_height="?attr/actionBarSize" />
+    <androidx.fragment.app.FragmentContainerView
+        android:id="@+id/fragment_container"
+        android:name="androidx.navigation.fragment.NavHostFragment"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:defaultNavHost="true"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:navGraph="@navigation/home_navigation"
+        tools:layout="@layout/fragment_games" />
 
-    </com.google.android.material.appbar.AppBarLayout>
-
-    <FrameLayout
-        android:id="@+id/games_platform_frame"
+    <com.google.android.material.bottomnavigation.BottomNavigationView
+        android:id="@+id/navigation_bar"
         android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:menu="@menu/menu_navigation" />
 
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 53 - 0
src/android/app/src/main/res/layout/card_home_option.xml

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView 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"
+    style="?attr/materialCardViewFilledStyle"
+    android:id="@+id/option_card"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginVertical="8dp"
+    android:layout_marginHorizontal="16dp"
+    android:background="?attr/selectableItemBackground"
+    android:clickable="true"
+    android:focusable="true">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <ImageView
+            android:id="@+id/option_icon"
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:layout_marginStart="28dp"
+            android:layout_gravity="center_vertical"
+            app:tint="?attr/colorPrimary" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="16dp"
+            android:orientation="vertical">
+
+            <com.google.android.material.textview.MaterialTextView
+                style="@style/TextAppearance.Material3.BodyMedium"
+                android:id="@+id/option_title"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:textAlignment="viewStart"
+                tools:text="@string/install_prod_keys" />
+
+            <com.google.android.material.textview.MaterialTextView
+                style="@style/TextAppearance.Material3.BodySmall"
+                android:id="@+id/option_description"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:textAlignment="viewStart"
+                tools:text="@string/install_prod_keys_description" />
+
+        </LinearLayout>
+
+    </LinearLayout>
+
+</com.google.android.material.card.MaterialCardView>

+ 80 - 0
src/android/app/src/main/res/layout/fragment_games.xml

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+    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:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="?attr/colorSurface">
+
+    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+        android:id="@+id/swipe_refresh"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:clipToPadding="false"
+        app:layout_behavior="@string/searchbar_scrolling_view_behavior">
+
+        <RelativeLayout
+            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"
+        app:liftOnScrollTargetViewId="@id/grid_games">
+
+        <FrameLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:fitsSystemWindows="true">
+
+            <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" />
+
+        </FrameLayout>
+
+    </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 - 37
src/android/app/src/main/res/layout/fragment_grid.xml

@@ -1,37 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
-        android:id="@+id/swipe_refresh"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-
-        <RelativeLayout
-            android:layout_width="match_parent"
-            android:layout_height="match_parent">
-
-            <TextView
-                android:id="@+id/gamelist_empty_text"
-                android:layout_width="match_parent"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:text="@string/empty_gamelist"
-                android:textSize="18sp"
-                android: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>
-
-</FrameLayout>

+ 30 - 0
src/android/app/src/main/res/layout/fragment_options.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.core.widget.NestedScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/scroll_view_options"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="?attr/colorSurface"
+    android:clipToPadding="false">
+
+    <androidx.appcompat.widget.LinearLayoutCompat
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical"
+        android:background="?attr/colorSurface">
+
+        <ImageView
+            android:layout_width="128dp"
+            android:layout_height="128dp"
+            android:layout_margin="64dp"
+            android:layout_gravity="center_horizontal"
+            android:src="@drawable/ic_yuzu_themed" />
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/options_list"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent" />
+
+    </androidx.appcompat.widget.LinearLayoutCompat>
+
+</androidx.core.widget.NestedScrollView>

+ 0 - 47
src/android/app/src/main/res/menu/menu_game_grid.xml

@@ -1,47 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <item
-        android:id="@+id/button_file_menu"
-        android:icon="@drawable/ic_folder"
-        android:title="@string/select_game_folder"
-        app:showAsAction="ifRoom">
-
-        <menu>
-
-            <item
-                android:id="@+id/button_add_directory"
-                android:icon="@drawable/ic_folder"
-                android:title="@string/select_game_folder"
-                app:showAsAction="ifRoom" />
-
-            <item
-                android:id="@+id/button_install_keys"
-                android:icon="@drawable/ic_install"
-                android:title="@string/install_keys"
-                app:showAsAction="ifRoom" />
-
-            <item
-                android:id="@+id/button_install_amiibo_keys"
-                android:icon="@drawable/ic_install"
-                android:title="@string/install_amiibo_keys"
-                app:showAsAction="ifRoom" />
-
-            <item
-                android:id="@+id/button_select_gpu_driver"
-                android:icon="@drawable/ic_settings"
-                android:title="@string/select_gpu_driver"
-                app:showAsAction="ifRoom" />
-
-        </menu>
-
-    </item>
-
-    <item
-        android:id="@+id/menu_settings_core"
-        android:icon="@drawable/ic_settings"
-        android:title="@string/grid_menu_core_settings"
-        app:showAsAction="ifRoom" />
-
-</menu>

+ 14 - 0
src/android/app/src/main/res/menu/menu_navigation.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <item
+        android:id="@+id/gamesFragment"
+        android:icon="@drawable/ic_controller"
+        android:title="@string/home_games" />
+
+    <item
+        android:id="@+id/optionsFragment"
+        android:icon="@drawable/ic_options"
+        android:title="@string/home_options" />
+
+</menu>

+ 17 - 0
src/android/app/src/main/res/navigation/home_navigation.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<navigation xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/home_navigation"
+    app:startDestination="@id/gamesFragment">
+
+    <fragment
+        android:id="@+id/gamesFragment"
+        android:name="org.yuzu.yuzu_emu.ui.GamesFragment"
+        android:label="PlatformGamesFragment" />
+
+    <fragment
+        android:id="@+id/optionsFragment"
+        android:name="org.yuzu.yuzu_emu.fragments.OptionsFragment"
+        android:label="OptionsFragment" />
+
+</navigation>

+ 6 - 1
src/android/app/src/main/res/values/dimens.xml

@@ -1,10 +1,15 @@
 <resources>
     <dimen name="spacing_small">4dp</dimen>
+    <dimen name="spacing_med">8dp</dimen>
     <dimen name="spacing_medlarge">12dp</dimen>
     <dimen name="spacing_large">16dp</dimen>
     <dimen name="spacing_xtralarge">32dp</dimen>
     <dimen name="spacing_list">64dp</dimen>
-    <dimen name="spacing_fab">72dp</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="menu_width">256dp</dimen>
     <dimen name="card_width">160dp</dimen>
 

+ 18 - 11
src/android/app/src/main/res/values/strings.xml

@@ -9,6 +9,24 @@
     <string name="app_notification_channel_description">yuzu Switch emulator notifications</string>
     <string name="app_notification_running">yuzu is running</string>
 
+    <!-- Home strings -->
+    <string name="home_games">Games</string>
+    <string name="home_options">Options</string>
+    <string name="add_games">Add Games</string>
+    <string name="add_games_description">Select your games folder</string>
+    <string name="home_search_games">Search Games</string>
+    <string name="install_prod_keys">Install Prod.keys</string>
+    <string name="install_prod_keys_description">Required to decrypt retail games</string>
+    <string name="install_amiibo_keys">Install Amiibo Keys</string>
+    <string name="install_amiibo_keys_description">Required to use Amiibo in game</string>
+    <string name="install_keys_success">Keys successfully installed</string>
+    <string name="install_keys_failure">Keys file (prod.keys) is invalid</string>
+    <string name="install_amiibo_keys_failure">Keys file (key_retail.bin) is invalid</string>
+    <string name="install_gpu_driver">Install GPU Driver</string>
+    <string name="install_gpu_driver_description">Use a different driver for potentially better performance or accuracy</string>
+    <string name="settings">Settings</string>
+    <string name="settings_description">Configure emulator settings</string>
+
     <!-- General settings strings -->
     <string name="frame_limit_enable">Enable limit speed</string>
     <string name="frame_limit_enable_description">When enabled, emulation speed will be limited to a specified percentage of normal speed.</string>
@@ -51,17 +69,6 @@
     <string name="error_saving">Error saving %1$s.ini: %2$s</string>
     <string name="loading">Loading...</string>
 
-    <!-- Game Grid Screen-->
-    <string name="grid_menu_core_settings">Settings</string>
-
-    <!-- Add Directory Screen-->
-    <string name="select_game_folder">Select game folder</string>
-    <string name="install_keys">Install keys</string>
-    <string name="install_amiibo_keys">Install amiibo keys</string>
-    <string name="install_keys_success">Keys successfully installed</string>
-    <string name="install_keys_failure">Keys file (prod.keys) is invalid</string>
-    <string name="install_amiibo_keys_failure">Keys file (key_retail.bin) is invalid</string>
-
     <!-- GPU driver installation -->
     <string name="select_gpu_driver">Select GPU driver</string>
     <string name="select_gpu_driver_title">Would you like to replace your current GPU driver?</string>