android: Enhance shortcut customization with a custom dialog

Adds ability to customize game shortcuts with:
- Custom name input
- Editable icon via image picker
- Ability to stretch to fit or zoom to fit the shortcut icon
This commit is contained in:
Kleidis 2025-03-06 06:20:51 +01:00 committed by OpenSauce
parent 43dbe42b29
commit eafe406153
6 changed files with 234 additions and 18 deletions

View File

@ -19,6 +19,8 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.Bitmap import android.graphics.Bitmap
import android.content.pm.ShortcutInfo import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.graphics.BitmapFactory
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -41,17 +43,30 @@ import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.R import org.citra.citra_emu.R
import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder
import org.citra.citra_emu.databinding.CardGameBinding import org.citra.citra_emu.databinding.CardGameBinding
import org.citra.citra_emu.databinding.DialogShortcutBinding
import org.citra.citra_emu.features.cheats.ui.CheatsFragmentDirections import org.citra.citra_emu.features.cheats.ui.CheatsFragmentDirections
import org.citra.citra_emu.features.settings.ui.SettingsActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.model.Game import org.citra.citra_emu.model.Game
import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.utils.GameIconUtils
import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.GamesViewModel
import androidx.core.net.toUri
import androidx.core.graphics.scale
import androidx.core.content.edit
class GameAdapter(private val activity: AppCompatActivity, private val inflater: LayoutInflater) : class GameAdapter(private val activity: AppCompatActivity, private val inflater: LayoutInflater, private val openImageLauncher: ActivityResultLauncher<String>?) :
ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()), ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
View.OnClickListener, View.OnLongClickListener { View.OnClickListener, View.OnLongClickListener {
private var lastClickTime = 0L private var lastClickTime = 0L
private var imagePath: String? = null
private var dialogShortcutBinding: DialogShortcutBinding? = null
fun handleShortcutImageResult(uri: Uri?) {
val path = uri?.toString()
if (path != null) {
imagePath = path
dialogShortcutBinding!!.imageScaleSwitch.isEnabled = imagePath != null
refreshShortcutDialogIcon()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
// Create a new view. // Create a new view.
@ -222,23 +237,70 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater:
} }
bottomSheetView.findViewById<MaterialButton>(R.id.game_shortcut).setOnClickListener { bottomSheetView.findViewById<MaterialButton>(R.id.game_shortcut).setOnClickListener {
val shortcutManager = activity.getSystemService(ShortcutManager::class.java) val preferences = PreferenceManager.getDefaultSharedPreferences(context)
CoroutineScope(Dispatchers.IO).launch { // Default to false for zoomed in shortcut icons
val bitmap = (bottomSheetView.findViewById<ImageView>(R.id.game_icon).drawable as BitmapDrawable).bitmap preferences.edit() {
val icon = Icon.createWithBitmap(bitmap) putBoolean(
"shouldStretchIcon",
val shortcut = ShortcutInfo.Builder(context, game.title) false
.setShortLabel(game.title) )
.setIcon(icon)
.setIntent(game.launchIntent.apply {
putExtra("launched_from_shortcut", true)
})
.build()
shortcutManager.requestPinShortcut(shortcut, null)
} }
dialogShortcutBinding = DialogShortcutBinding.inflate(activity.layoutInflater)
dialogShortcutBinding!!.shortcutNameInput.setText(game.title)
GameIconUtils.loadGameIcon(activity, game, dialogShortcutBinding!!.shortcutIcon)
dialogShortcutBinding!!.shortcutIcon.setOnClickListener {
openImageLauncher?.launch("image/*")
}
dialogShortcutBinding!!.imageScaleSwitch.setOnCheckedChangeListener { _, isChecked ->
preferences.edit {
putBoolean(
"shouldStretchIcon",
isChecked
)
}
refreshShortcutDialogIcon()
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.create_shortcut)
.setView(dialogShortcutBinding!!.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
val shortcutName = dialogShortcutBinding!!.shortcutNameInput.text.toString()
if (shortcutName.isEmpty()) {
Toast.makeText(context, R.string.shortcut_name_empty, Toast.LENGTH_LONG).show()
return@setPositiveButton
}
val iconBitmap = (dialogShortcutBinding!!.shortcutIcon.drawable as BitmapDrawable).bitmap
val shortcutManager = activity.getSystemService(ShortcutManager::class.java)
CoroutineScope(Dispatchers.IO).launch {
val icon = Icon.createWithBitmap(iconBitmap)
val shortcut = ShortcutInfo.Builder(context, shortcutName)
.setShortLabel(shortcutName)
.setIcon(icon)
.setIntent(game.launchIntent.apply {
putExtra("launchedFromShortcut", true)
})
.build()
shortcutManager?.requestPinShortcut(shortcut, null)
imagePath = null
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
imagePath = null
}
.show()
bottomSheetDialog.dismiss()
} }
bottomSheetView.findViewById<MaterialButton>(R.id.cheats).setOnClickListener { bottomSheetView.findViewById<MaterialButton>(R.id.cheats).setOnClickListener {
val action = CheatsFragmentDirections.actionGlobalCheatsFragment(holder.game.titleId) val action = CheatsFragmentDirections.actionGlobalCheatsFragment(holder.game.titleId)
view.findNavController().navigate(action) view.findNavController().navigate(action)
@ -252,6 +314,48 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater:
bottomSheetDialog.show() bottomSheetDialog.show()
} }
private fun refreshShortcutDialogIcon() {
if (imagePath != null) {
val originalBitmap = BitmapFactory.decodeStream(
CitraApplication.appContext.contentResolver.openInputStream(
imagePath!!.toUri()
)
)
val scaledBitmap = {
val preferences =
PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
if (preferences.getBoolean("shouldStretchIcon", true)) {
// stretch to fit
originalBitmap.scale(108, 108)
} else {
// Zoom in to fit the bitmap while keeping the aspect ratio
val width = originalBitmap.width
val height = originalBitmap.height
val targetSize = 108
if (width > height) {
// Landscape orientation
val scaleFactor = targetSize.toFloat() / height
val scaledWidth = (width * scaleFactor).toInt()
val scaledBmp = originalBitmap.scale(scaledWidth, targetSize)
val startX = (scaledWidth - targetSize) / 2
Bitmap.createBitmap(scaledBmp, startX, 0, targetSize, targetSize)
} else {
val scaleFactor = targetSize.toFloat() / width
val scaledHeight = (height * scaleFactor).toInt()
val scaledBmp = originalBitmap.scale(targetSize, scaledHeight)
val startY = (scaledHeight - targetSize) / 2
Bitmap.createBitmap(scaledBmp, 0, startY, targetSize, targetSize)
}
}
}()
dialogShortcutBinding!!.shortcutIcon.setImageBitmap(scaledBitmap)
}
}
private fun isValidGame(extension: String): Boolean { private fun isValidGame(extension: String): Boolean {
return Game.badExtensions.stream() return Game.badExtensions.stream()
.noneMatch { extension == it.lowercase() } .noneMatch { extension == it.lowercase() }

View File

@ -10,6 +10,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@ -33,6 +34,7 @@ import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.model.Game import org.citra.citra_emu.model.Game
import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.GamesViewModel
import org.citra.citra_emu.viewmodel.HomeViewModel import org.citra.citra_emu.viewmodel.HomeViewModel
import android.net.Uri
class GamesFragment : Fragment() { class GamesFragment : Fragment() {
private var _binding: FragmentGamesBinding? = null private var _binding: FragmentGamesBinding? = null
@ -40,6 +42,13 @@ class GamesFragment : Fragment() {
private val gamesViewModel: GamesViewModel by activityViewModels() private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels()
private lateinit var gameAdapter: GameAdapter
private val openImageLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
gameAdapter.handleShortcutImageResult(uri)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -63,12 +72,18 @@ class GamesFragment : Fragment() {
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
gameAdapter = GameAdapter(
requireActivity() as AppCompatActivity,
inflater,
openImageLauncher
)
binding.gridGames.apply { binding.gridGames.apply {
layoutManager = GridLayoutManager( layoutManager = GridLayoutManager(
requireContext(), requireContext(),
resources.getInteger(R.integer.game_grid_columns) resources.getInteger(R.integer.game_grid_columns)
) )
adapter = GameAdapter(requireActivity() as AppCompatActivity, inflater) adapter = this@GamesFragment.gameAdapter
} }
binding.swipeRefresh.apply { binding.swipeRefresh.apply {

View File

@ -36,6 +36,8 @@ import org.citra.citra_emu.viewmodel.GamesViewModel
import org.citra.citra_emu.viewmodel.HomeViewModel import org.citra.citra_emu.viewmodel.HomeViewModel
import java.time.temporal.ChronoField import java.time.temporal.ChronoField
import java.util.Locale import java.util.Locale
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts
class SearchFragment : Fragment() { class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null private var _binding: FragmentSearchBinding? = null
@ -43,6 +45,13 @@ class SearchFragment : Fragment() {
private val gamesViewModel: GamesViewModel by activityViewModels() private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels()
private lateinit var gameAdapter: GameAdapter
private val openImageLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
gameAdapter.handleShortcutImageResult(uri)
}
private lateinit var preferences: SharedPreferences private lateinit var preferences: SharedPreferences
@ -73,12 +82,18 @@ class SearchFragment : Fragment() {
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
gameAdapter = GameAdapter(
requireActivity() as AppCompatActivity,
inflater,
openImageLauncher
)
binding.gridGamesSearch.apply { binding.gridGamesSearch.apply {
layoutManager = GridLayoutManager( layoutManager = GridLayoutManager(
requireContext(), requireContext(),
resources.getInteger(R.integer.game_grid_columns) resources.getInteger(R.integer.game_grid_columns)
) )
adapter = GameAdapter(requireActivity() as AppCompatActivity, inflater) adapter = this@SearchFragment.gameAdapter
} }
binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@android:color/black"/>
<corners android:radius="21dp"/>
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/black"/>
<corners android:radius="21dp"/>
</shape>
</item>
</selector>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/shortcut_icon"
android:layout_width="96dp"
android:layout_height="96dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:background="?attr/selectableItemBackgroundBorderless"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Medium"/>
<ImageView
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_gravity="center"
android:src="@android:drawable/ic_menu_edit"
android:padding="8dp"
android:contentDescription="@string/edit_icon"
app:tint="?attr/colorAccent"
android:background="@drawable/shortcut_edit_background"
android:alpha="0.8"/>
</FrameLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/image_scale_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_gravity="center_horizontal"
android:enabled="false"
android:text="@string/shortcut_image_stretch_toggle"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/shortcut_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/shortcut_name_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"/>
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@ -477,6 +477,12 @@
<!-- About Game Dialog --> <!-- About Game Dialog -->
<string name="play">Play</string> <string name="play">Play</string>
<string name="shortcut">Shortcut</string> <string name="shortcut">Shortcut</string>
<string name="shortcut_name">Shortcut Name</string>
<string name="edit_icon">Edit icon</string>
<string name="create_shortcut">Create Shortcut</string>
<string name="shortcut_name_empty">Shortcut name cannot be empty</string>
<string name="shortcut_image_stretch_toggle">Stretch to fit image</string>
<!-- Cheats --> <!-- Cheats -->
<string name="cheats">Cheats</string> <string name="cheats">Cheats</string>