feat: Add list view toggle and separate applets settings category

- Add list/grid view toggle button to games fragment
- Implement dual-view GameAdapter with separate ViewHolders for grid/list layouts
- Create compact card_game_list.xml layout for list view
- Add "Applets on Android" settings category to separate applet settings from Zep Zone
- Add logging settings with log filter configuration
- Add view toggle icons (ic_view_list, ic_view_grid)
- Update strings.xml with new UI text and settings descriptions

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2025-08-21 16:47:08 +10:00
parent 2d43a2d47b
commit 00d6eb12d9
11 changed files with 268 additions and 52 deletions

View File

@@ -23,6 +23,7 @@ import org.citron.citron_emu.HomeNavigationDirections
import org.citron.citron_emu.R
import org.citron.citron_emu.CitronApplication
import org.citron.citron_emu.databinding.CardGameBinding
import org.citron.citron_emu.databinding.CardGameListBinding
import org.citron.citron_emu.model.Game
import org.citron.citron_emu.model.GamesViewModel
import org.citron.citron_emu.utils.GameIconUtils
@@ -30,13 +31,41 @@ import org.citron.citron_emu.utils.ViewUtils.marquee
import org.citron.citron_emu.viewholder.AbstractViewHolder
class GameAdapter(private val activity: AppCompatActivity) :
AbstractDiffAdapter<Game, GameAdapter.GameViewHolder>(exact = false) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
.also { return GameViewHolder(it) }
AbstractDiffAdapter<Game, AbstractViewHolder<Game>>(exact = false) {
companion object {
const val VIEW_TYPE_GRID = 0
const val VIEW_TYPE_LIST = 1
}
inner class GameViewHolder(val binding: CardGameBinding) :
private var isListView = false
fun setListView(listView: Boolean) {
if (isListView != listView) {
isListView = listView
notifyDataSetChanged()
}
}
override fun getItemViewType(position: Int): Int {
return if (isListView) VIEW_TYPE_LIST else VIEW_TYPE_GRID
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractViewHolder<Game> {
return when (viewType) {
VIEW_TYPE_GRID -> {
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
GameGridViewHolder(binding)
}
VIEW_TYPE_LIST -> {
val binding = CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
GameListViewHolder(binding)
}
else -> throw IllegalArgumentException("Invalid view type")
}
}
inner class GameGridViewHolder(val binding: CardGameBinding) :
AbstractViewHolder<Game>(binding) {
override fun bind(model: Game) {
binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
@@ -50,50 +79,79 @@ class GameAdapter(private val activity: AppCompatActivity) :
}
fun onClick(game: Game) {
val gameExists = DocumentFile.fromSingleUri(
CitronApplication.appContext,
Uri.parse(game.path)
)?.exists() == true
if (!gameExists) {
Toast.makeText(
CitronApplication.appContext,
R.string.loader_error_file_not_found,
Toast.LENGTH_LONG
).show()
ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
return
}
val preferences =
PreferenceManager.getDefaultSharedPreferences(CitronApplication.appContext)
preferences.edit()
.putLong(
game.keyLastPlayedTime,
System.currentTimeMillis()
)
.apply()
activity.lifecycleScope.launch {
withContext(Dispatchers.IO) {
val shortcut =
ShortcutInfoCompat.Builder(CitronApplication.appContext, game.path)
.setShortLabel(game.title)
.setIcon(GameIconUtils.getShortcutIcon(activity, game))
.setIntent(game.launchIntent)
.build()
ShortcutManagerCompat.pushDynamicShortcut(CitronApplication.appContext, shortcut)
}
}
val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true)
binding.root.findNavController().navigate(action)
handleGameClick(game)
}
fun onLongClick(game: Game): Boolean {
val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game)
binding.root.findNavController().navigate(action)
return true
return handleGameLongClick(game)
}
}
inner class GameListViewHolder(val binding: CardGameListBinding) :
AbstractViewHolder<Game>(binding) {
override fun bind(model: Game) {
binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
GameIconUtils.loadGameIcon(model, binding.imageGameScreen)
binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ")
binding.cardGame.setOnClickListener { onClick(model) }
binding.cardGame.setOnLongClickListener { onLongClick(model) }
}
fun onClick(game: Game) {
handleGameClick(game)
}
fun onLongClick(game: Game): Boolean {
return handleGameLongClick(game)
}
}
private fun handleGameClick(game: Game) {
val gameExists = DocumentFile.fromSingleUri(
CitronApplication.appContext,
Uri.parse(game.path)
)?.exists() == true
if (!gameExists) {
Toast.makeText(
CitronApplication.appContext,
R.string.loader_error_file_not_found,
Toast.LENGTH_LONG
).show()
ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
return
}
val preferences =
PreferenceManager.getDefaultSharedPreferences(CitronApplication.appContext)
preferences.edit()
.putLong(
game.keyLastPlayedTime,
System.currentTimeMillis()
)
.apply()
activity.lifecycleScope.launch {
withContext(Dispatchers.IO) {
val shortcut =
ShortcutInfoCompat.Builder(CitronApplication.appContext, game.path)
.setShortLabel(game.title)
.setIcon(GameIconUtils.getShortcutIcon(activity, game))
.setIntent(game.launchIntent)
.build()
ShortcutManagerCompat.pushDynamicShortcut(CitronApplication.appContext, shortcut)
}
}
val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true)
activity.findNavController(R.id.fragment_container).navigate(action)
}
private fun handleGameLongClick(game: Game): Boolean {
val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game)
activity.findNavController(R.id.fragment_container).navigate(action)
return true
}
}

View File

@@ -24,7 +24,8 @@ object Settings {
SECTION_INPUT_PLAYER_EIGHT,
SECTION_THEME(R.string.preferences_theme),
SECTION_DEBUG(R.string.preferences_debug),
SECTION_ZEP_ZONE(R.string.preferences_zep_zone);
SECTION_ZEP_ZONE(R.string.preferences_zep_zone),
SECTION_APPLETS_ANDROID(R.string.preferences_applets_android);
}
fun getPlayerString(player: Int): String =

View File

@@ -7,7 +7,8 @@ import org.citron.citron_emu.utils.NativeConfig
enum class StringSetting(override val key: String) : AbstractStringSetting {
DRIVER_PATH("driver_path"),
DEVICE_NAME("device_name");
DEVICE_NAME("device_name"),
LOG_FILTER("log_filter");
override fun getString(needsGlobal: Boolean): String = NativeConfig.getString(key, needsGlobal)

View File

@@ -100,6 +100,7 @@ class SettingsFragmentPresenter(
MenuTag.SECTION_THEME -> addThemeSettings(sl)
MenuTag.SECTION_DEBUG -> addDebugSettings(sl)
MenuTag.SECTION_ZEP_ZONE -> addZepZoneSettings(sl)
MenuTag.SECTION_APPLETS_ANDROID -> addAppletsAndroidSettings(sl)
}
settingsList = sl
adapter.submitList(settingsList) {
@@ -151,6 +152,14 @@ class SettingsFragmentPresenter(
menuKey = MenuTag.SECTION_ZEP_ZONE
)
)
add(
SubmenuSetting(
titleId = R.string.preferences_applets_android,
descriptionId = R.string.preferences_applets_android_description,
iconId = R.drawable.ic_applet,
menuKey = MenuTag.SECTION_APPLETS_ANDROID
)
)
add(
RunnableSetting(
titleId = R.string.reset_to_default,
@@ -981,6 +990,15 @@ class SettingsFragmentPresenter(
add(IntSetting.CPU_ACCURACY.key)
add(BooleanSetting.CPU_DEBUG_MODE.key)
add(SettingsItem.FASTMEM_COMBINED)
add(HeaderSetting(R.string.logging))
add(
StringInputSetting(
StringSetting.LOG_FILTER,
titleId = R.string.log_filter,
descriptionId = R.string.log_filter_description
)
)
}
}
@@ -1000,7 +1018,11 @@ class SettingsFragmentPresenter(
add(HeaderSetting(R.string.frame_skipping_header))
add(IntSetting.FRAME_SKIPPING.key)
add(IntSetting.FRAME_SKIPPING_MODE.key)
}
}
private fun addAppletsAndroidSettings(sl: ArrayList<SettingsItem>) {
sl.apply {
add(HeaderSetting(R.string.applet_settings_header))
add(IntSetting.CABINET_APPLET_MODE.key)
add(IntSetting.CONTROLLER_APPLET_MODE.key)

View File

@@ -13,6 +13,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import org.citron.citron_emu.R
import org.citron.citron_emu.adapters.GameAdapter
@@ -31,6 +32,9 @@ class GamesFragment : Fragment() {
private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels()
private lateinit var gameAdapter: GameAdapter
private var isListView = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -45,12 +49,19 @@ class GamesFragment : Fragment() {
homeViewModel.setNavigationVisibility(visible = true, animated = true)
homeViewModel.setStatusBarShadeVisibility(true)
gameAdapter = GameAdapter(requireActivity() as AppCompatActivity)
binding.gridGames.apply {
layoutManager = AutofitGridLayoutManager(
requireContext(),
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
)
adapter = GameAdapter(requireActivity() as AppCompatActivity)
adapter = gameAdapter
}
// Set up button for view switching
binding.btnViewToggle.setOnClickListener {
toggleViewMode()
}
binding.swipeRefresh.apply {
@@ -90,14 +101,14 @@ class GamesFragment : Fragment() {
)
}
gamesViewModel.games.collect(viewLifecycleOwner) {
(binding.gridGames.adapter as GameAdapter).submitList(it)
gameAdapter.submitList(it)
}
gamesViewModel.shouldSwapData.collect(
viewLifecycleOwner,
resetState = { gamesViewModel.setShouldSwapData(false) }
) {
if (it) {
(binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value)
gameAdapter.submitList(gamesViewModel.games.value)
}
}
gamesViewModel.shouldScrollToTop.collect(
@@ -108,6 +119,27 @@ class GamesFragment : Fragment() {
setInsets()
}
private fun toggleViewMode() {
isListView = !isListView
if (isListView) {
// Switch to list view
binding.gridGames.layoutManager = LinearLayoutManager(requireContext())
binding.btnViewToggle.setIconResource(R.drawable.ic_view_grid)
binding.btnViewToggle.contentDescription = getString(R.string.switch_to_grid_view)
} else {
// Switch to grid view
binding.gridGames.layoutManager = AutofitGridLayoutManager(
requireContext(),
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
)
binding.btnViewToggle.setIconResource(R.drawable.ic_view_list)
binding.btnViewToggle.contentDescription = getString(R.string.switch_to_list_view)
}
gameAdapter.setListView(isListView)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
@@ -155,6 +187,14 @@ class GamesFragment : Fragment() {
binding.noticeText.updatePadding(bottom = spacingNavigation)
// Update button margins
val buttonSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
binding.btnViewToggle.updateMargins(
left = leftInsets + buttonSpacing,
right = rightInsets + buttonSpacing,
bottom = barInsets.bottom + spacingNavigation + buttonSpacing
)
windowInsets
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M3,3v8h8L11,3L3,3zM9,9L5,9L5,5h4v4zM3,13v8h8v-8L3,13zM9,19L5,19v-4h4v4zM13,3v8h8L21,3h-8zM19,9h-4L15,5h4v4zM13,13v8h8v-8h-8zM19,19h-4v-4h4v4z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M3,13h2v-2L3,11v2zM3,17h2v-2L3,15v2zM3,9h2L5,7L3,7v2zM7,13h14v-2L7,11v2zM7,17h14v-2L7,15v2zM7,7v2h14L21,7L7,7z"/>
</vector>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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="wrap_content">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_game"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:clipToPadding="false"
android:focusable="true"
android:transitionName="card_game"
app:cardCornerRadius="8dp"
app:cardElevation="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_game_screen"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="16dp"
app:shapeAppearance="@style/ShapeAppearance.Material3.Corner.Small"
tools:src="@drawable/default_icon" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/text_game_title"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="2"
android:textAlignment="viewStart"
android:textSize="16sp"
tools:text="The Legend of Zelda: Skyward Sword" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</FrameLayout>

View File

@@ -2,6 +2,7 @@
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -30,6 +31,18 @@
android:defaultFocusHighlightEnabled="false"
tools:listitem="@layout/card_game" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_view_toggle"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_margin="16dp"
android:contentDescription="@string/switch_to_list_view"
app:icon="@drawable/ic_view_list"
app:iconSize="24dp" />
</RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -33,6 +33,8 @@
<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="switch_to_list_view">Switch to list view</string>
<string name="switch_to_grid_view">Switch to grid view</string>
<string name="select_games_folder">Select games folder</string>
<string name="manage_game_folders">Manage game folders</string>
<string name="select_games_folder_description">Allows citron to populate the games list</string>
@@ -256,6 +258,9 @@
<string name="renderer_debug">Graphics debugging</string>
<string name="renderer_debug_description">Sets the graphics API to a slow debugging mode.</string>
<string name="fastmem">Fastmem</string>
<string name="logging">Logging</string>
<string name="log_filter">Log Filter</string>
<string name="log_filter_description">Configure which log messages to display. Format: &lt;class&gt;:&lt;level&gt;. Example: *:Info Service:Debug</string>
<!-- Audio settings strings -->
<string name="audio_output_engine">Output engine</string>
@@ -418,6 +423,8 @@
<string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string>
<string name="preferences_zep_zone">Zep Zone</string>
<string name="preferences_zep_zone_description">Advanced emulation settings</string>
<string name="preferences_applets_android">Applets on Android</string>
<string name="preferences_applets_android_description">System applet configuration settings</string>
<!-- Zep Zone Headers -->
<string name="memory_layout_header">Memory Layout</string>

View File

@@ -634,7 +634,7 @@ struct Values {
Setting<bool> perform_vulkan_check{linkage, true, "perform_vulkan_check", Category::Debugging};
// Miscellaneous
Setting<std::string> log_filter{linkage, "*:Info", "log_filter", Category::Miscellaneous};
Setting<std::string> log_filter{linkage, "*:Info", "log_filter", Category::Debugging};
Setting<bool> use_dev_keys{linkage, false, "use_dev_keys", Category::Miscellaneous};
// Network