Redesign cards in grid view

* Refactor some classes and clean up
* Refresh style on the fly
This commit is contained in:
Willi Ye 2020-07-17 17:53:44 +02:00 committed by ◱ PixelyIon
parent b23779bda1
commit c3e54d1abf
14 changed files with 479 additions and 361 deletions

View File

@ -15,10 +15,11 @@ import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import emu.skyline.adapter.AppItem import emu.skyline.data.AppItem
import kotlinx.android.synthetic.main.app_dialog.* import kotlinx.android.synthetic.main.app_dialog.*
/** /**
@ -26,7 +27,7 @@ import kotlinx.android.synthetic.main.app_dialog.*
* *
* @param item This is used to hold the [AppItem] between instances * @param item This is used to hold the [AppItem] between instances
*/ */
class AppDialog(val item : AppItem? = null) : BottomSheetDialogFragment() { class AppDialog(val item : AppItem) : BottomSheetDialogFragment() {
/** /**
* This inflates the layout of the dialog after initial view creation * This inflates the layout of the dialog after initial view creation
@ -47,10 +48,9 @@ class AppDialog(val item : AppItem? = null) : BottomSheetDialogFragment() {
dialog?.setOnKeyListener { _, keyCode, event -> dialog?.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BUTTON_B && event.action == KeyEvent.ACTION_DOWN) { if (keyCode == KeyEvent.KEYCODE_BUTTON_B && event.action == KeyEvent.ACTION_DOWN) {
dialog?.onBackPressed() dialog?.onBackPressed()
true return@setOnKeyListener true
} else {
false
} }
false
} }
} }
@ -60,38 +60,35 @@ class AppDialog(val item : AppItem? = null) : BottomSheetDialogFragment() {
override fun onActivityCreated(savedInstanceState : Bundle?) { override fun onActivityCreated(savedInstanceState : Bundle?) {
super.onActivityCreated(savedInstanceState) super.onActivityCreated(savedInstanceState)
if (item is AppItem) { val missingIcon = ContextCompat.getDrawable(requireActivity(), R.drawable.default_icon)!!.toBitmap(256, 256)
val missingIcon = context?.resources?.getDrawable(R.drawable.default_icon, context?.theme)?.toBitmap(256, 256)
game_icon.setImageBitmap(item.icon ?: missingIcon) game_icon.setImageBitmap(item.icon ?: missingIcon)
game_title.text = item.title game_title.text = item.title
game_subtitle.text = item.subTitle ?: getString(R.string.metadata_missing) game_subtitle.text = item.subTitle ?: getString(R.string.metadata_missing)
game_play.setOnClickListener { game_play.setOnClickListener {
val intent = Intent(activity, EmulationActivity::class.java) val intent = Intent(activity, EmulationActivity::class.java)
intent.data = item.uri intent.data = item.uri
startActivity(intent) startActivity(intent)
} }
val shortcutManager = activity?.getSystemService(ShortcutManager::class.java)!! val shortcutManager = requireActivity().getSystemService(ShortcutManager::class.java)
game_pin.isEnabled = shortcutManager.isRequestPinShortcutSupported game_pin.isEnabled = shortcutManager.isRequestPinShortcutSupported
game_pin.setOnClickListener { game_pin.setOnClickListener {
val info = ShortcutInfo.Builder(context, item.title) val info = ShortcutInfo.Builder(context, item.title)
info.setShortLabel(item.meta.name) info.setShortLabel(item.meta.name)
info.setActivity(ComponentName(context!!, EmulationActivity::class.java)) info.setActivity(ComponentName(requireActivity(), EmulationActivity::class.java))
info.setIcon(Icon.createWithAdaptiveBitmap(item.icon ?: missingIcon)) info.setIcon(Icon.createWithAdaptiveBitmap(item.icon ?: missingIcon))
val intent = Intent(context, EmulationActivity::class.java) val intent = Intent(context, EmulationActivity::class.java)
intent.data = item.uri intent.data = item.uri
intent.action = Intent.ACTION_VIEW intent.action = Intent.ACTION_VIEW
info.setIntent(intent) info.setIntent(intent)
shortcutManager.requestPinShortcut(info.build(), null) shortcutManager.requestPinShortcut(info.build(), null)
} }
} else
activity?.supportFragmentManager?.beginTransaction()?.remove(this)?.commit()
} }
} }

View File

@ -25,9 +25,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import emu.skyline.adapter.AppAdapter import emu.skyline.adapter.AppAdapter
import emu.skyline.adapter.AppItem
import emu.skyline.adapter.GridLayoutSpan import emu.skyline.adapter.GridLayoutSpan
import emu.skyline.adapter.LayoutType import emu.skyline.adapter.LayoutType
import emu.skyline.data.AppItem
import emu.skyline.loader.RomFile import emu.skyline.loader.RomFile
import emu.skyline.loader.RomFormat import emu.skyline.loader.RomFormat
import kotlinx.android.synthetic.main.main_activity.* import kotlinx.android.synthetic.main.main_activity.*
@ -37,7 +37,7 @@ import java.io.IOException
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.math.ceil import kotlin.math.ceil
class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClickListener { class MainActivity : AppCompatActivity(), View.OnClickListener {
/** /**
* This is used to get/set shared preferences * This is used to get/set shared preferences
*/ */
@ -51,14 +51,14 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
/** /**
* This adds all files in [directory] with [extension] as an entry in [adapter] using [loader] to load metadata * This adds all files in [directory] with [extension] as an entry in [adapter] using [loader] to load metadata
*/ */
private fun addEntries(extension : String, romFormat : RomFormat, directory : DocumentFile, found : Boolean = false) : Boolean { private fun addEntries(romFormat : RomFormat, directory : DocumentFile, found : Boolean = false) : Boolean {
var foundCurrent = found var foundCurrent = found
directory.listFiles().forEach { file -> directory.listFiles().forEach { file ->
if (file.isDirectory) { if (file.isDirectory) {
foundCurrent = addEntries(extension, romFormat, file, foundCurrent) foundCurrent = addEntries(romFormat, file, foundCurrent)
} else { } else {
if (extension.equals(file.name?.substringAfterLast("."), ignoreCase = true)) { if (romFormat.extension.equals(file.name?.substringAfterLast("."), ignoreCase = true)) {
val romFd = contentResolver.openFileDescriptor(file.uri, "r")!! val romFd = contentResolver.openFileDescriptor(file.uri, "r")!!
val romFile = RomFile(this, romFormat, romFd) val romFile = RomFile(this, romFormat, romFd)
@ -111,14 +111,17 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
try { try {
runOnUiThread { adapter.clear() } runOnUiThread { adapter.clear() }
var foundRoms = addEntries("nro", RomFormat.NRO, DocumentFile.fromTreeUri(this, Uri.parse(sharedPreferences.getString("search_location", "")))!!) val searchLocation = DocumentFile.fromTreeUri(this, Uri.parse(sharedPreferences.getString("search_location", "")))!!
foundRoms = foundRoms or addEntries("nso", RomFormat.NSO, DocumentFile.fromTreeUri(this, Uri.parse(sharedPreferences.getString("search_location", "")))!!)
foundRoms = foundRoms or addEntries("nca", RomFormat.NCA, DocumentFile.fromTreeUri(this, Uri.parse(sharedPreferences.getString("search_location", "")))!!) var foundRoms = addEntries(RomFormat.NRO, searchLocation)
foundRoms = foundRoms or addEntries("nsp", RomFormat.NSP, DocumentFile.fromTreeUri(this, Uri.parse(sharedPreferences.getString("search_location", "")))!!) foundRoms = foundRoms or addEntries(RomFormat.NSO, searchLocation)
foundRoms = foundRoms or addEntries(RomFormat.NCA, searchLocation)
foundRoms = foundRoms or addEntries(RomFormat.NSP, searchLocation)
runOnUiThread { runOnUiThread {
if (!foundRoms) if (!foundRoms) {
adapter.addHeader(getString(R.string.no_rom)) adapter.addHeader(getString(R.string.no_rom))
}
try { try {
adapter.save(File("${applicationInfo.dataDir}/roms.bin")) adapter.save(File("${applicationInfo.dataDir}/roms.bin"))
@ -172,28 +175,7 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
open_fab.setOnClickListener(this) open_fab.setOnClickListener(this)
log_fab.setOnClickListener(this) log_fab.setOnClickListener(this)
val layoutType = LayoutType.values()[sharedPreferences.getString("layout_type", "1")!!.toInt()] setupAppList()
adapter = AppAdapter(this, layoutType)
app_list.adapter = adapter
when (layoutType) {
LayoutType.List -> {
app_list.layoutManager = LinearLayoutManager(this)
app_list.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
}
LayoutType.Grid -> {
val itemWidth = 225
val metrics = resources.displayMetrics
val span = ceil((metrics.widthPixels / metrics.density) / itemWidth).toInt()
val layoutManager = GridLayoutManager(this, span)
layoutManager.spanSizeLookup = GridLayoutSpan(adapter, span)
app_list.layoutManager = layoutManager
}
}
app_list.addOnScrollListener(object : RecyclerView.OnScrollListener() { app_list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
var y : Int = 0 var y : Int = 0
@ -202,23 +184,43 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
y += dy y += dy
if (!app_list.isInTouchMode) { if (!app_list.isInTouchMode) {
if (y == 0) toolbar_layout.setExpanded(y == 0)
toolbar_layout.setExpanded(true)
else
toolbar_layout.setExpanded(false)
} }
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
} }
}) })
}
private fun setupAppList() {
val itemWidth = 225
val metrics = resources.displayMetrics
val gridSpan = ceil((metrics.widthPixels / metrics.density) / itemWidth).toInt()
val layoutType = LayoutType.values()[sharedPreferences.getString("layout_type", "1")!!.toInt()]
adapter = AppAdapter(layoutType = layoutType, gridSpan = gridSpan, onClick = selectStartGame, onLongClick = selectShowGameDialog)
app_list.adapter = adapter
app_list.layoutManager = when (layoutType) {
LayoutType.List -> LinearLayoutManager(this).also { app_list.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) }
LayoutType.Grid, LayoutType.GridCompact -> GridLayoutManager(this, gridSpan).apply {
spanSizeLookup = GridLayoutSpan(adapter, gridSpan).also {
if (app_list.itemDecorationCount > 0) {
app_list.removeItemDecorationAt(0)
}
}
}
}
if (sharedPreferences.getString("search_location", "") == "") { if (sharedPreferences.getString("search_location", "") == "") {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.flags = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION intent.flags = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
startActivityForResult(intent, 1) startActivityForResult(intent, 1)
} else } else {
refreshAdapter(!sharedPreferences.getBoolean("refresh_required", false)) refreshAdapter(!sharedPreferences.getBoolean("refresh_required", false))
}
} }
/** /**
@ -245,12 +247,11 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
} }
/** /**
* This handles on-click interaction with [R.id.log_fab], [R.id.open_fab], [R.id.app_item_linear] and [R.id.app_item_grid] * This handles on-click interaction with [R.id.log_fab], [R.id.open_fab]
*/ */
override fun onClick(view : View) { override fun onClick(view : View) {
when (view.id) { when (view.id) {
R.id.log_fab -> startActivity(Intent(this, LogActivity::class.java)) R.id.log_fab -> startActivity(Intent(this, LogActivity::class.java))
R.id.open_fab -> { R.id.open_fab -> {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
@ -258,40 +259,19 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
startActivityForResult(intent, 2) startActivityForResult(intent, 2)
} }
R.id.app_item_linear, R.id.app_item_grid -> {
val tag = view.tag
if (tag is AppItem) {
if (sharedPreferences.getBoolean("select_action", false)) {
val dialog = AppDialog(tag)
dialog.show(supportFragmentManager, "game")
} else {
val intent = Intent(this, EmulationActivity::class.java)
intent.data = tag.uri
startActivity(intent)
}
}
}
} }
} }
/** private val selectStartGame : (appItem : AppItem) -> Unit = {
* This handles long-click interaction with [R.id.app_item_linear] and [R.id.app_item_grid] if (sharedPreferences.getBoolean("select_action", false)) {
*/ AppDialog(it).show(supportFragmentManager, "game")
override fun onLongClick(view : View?) : Boolean { } else {
when (view?.id) { startActivity(Intent(this, EmulationActivity::class.java).apply { data = it.uri })
R.id.app_item_linear, R.id.app_item_grid -> {
val tag = view.tag
if (tag is AppItem) {
val dialog = AppDialog(tag)
dialog.show(supportFragmentManager, "game")
return true
}
}
} }
}
return false private val selectShowGameDialog : (appItem : AppItem) -> Unit = {
AppDialog(it).show(supportFragmentManager, "game")
} }
/** /**
@ -350,4 +330,13 @@ class MainActivity : AppCompatActivity(), View.OnClickListener, View.OnLongClick
} }
} }
} }
override fun onResume() {
super.onResume()
val layoutType = LayoutType.values()[sharedPreferences.getString("layout_type", "1")!!.toInt()]
if (layoutType != adapter.layoutType) {
setupAppList()
}
}
} }

View File

@ -7,80 +7,42 @@ package emu.skyline.adapter
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.net.Uri import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window import android.view.Window
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import emu.skyline.R import emu.skyline.R
import emu.skyline.adapter.ElementType.Header import emu.skyline.data.AppItem
import emu.skyline.adapter.ElementType.Item
import emu.skyline.loader.AppEntry
/**
* This class is a wrapper around [AppEntry], it is used for passing around game metadata
*/
class AppItem(val meta : AppEntry) : BaseItem() {
/**
* The icon of the application
*/
val icon : Bitmap?
get() = meta.icon
/**
* The title of the application
*/
val title : String
get() = meta.name
/**
* The string used as the sub-title, we currently use the author
*/
val subTitle : String?
get() = meta.author
/**
* The URI of the application's image file
*/
val uri : Uri
get() = meta.uri
/**
* The format of the application ROM as a string
*/
private val type : String
get() = meta.format.name
/**
* The name and author is used as the key
*/
override fun key() : String? {
return if (meta.author != null) meta.name + " " + meta.author else meta.name
}
}
/** /**
* This enumerates the type of layouts the menu can be in * This enumerates the type of layouts the menu can be in
*/ */
enum class LayoutType { enum class LayoutType(val layoutRes : Int) {
List, List(R.layout.app_item_linear),
Grid, Grid(R.layout.app_item_grid),
GridCompact(R.layout.app_item_grid_compact)
} }
private typealias AppInteractionFunc = (appItem : AppItem) -> Unit
/** /**
* This adapter is used to display all found applications using their metadata * This adapter is used to display all found applications using their metadata
*/ */
internal class AppAdapter(val context : Context?, private val layoutType : LayoutType) : HeaderAdapter<AppItem, BaseHeader, RecyclerView.ViewHolder>(), View.OnClickListener { internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : Int, private val onClick : AppInteractionFunc, private val onLongClick : AppInteractionFunc) : HeaderAdapter<AppItem, BaseHeader, RecyclerView.ViewHolder>() {
private val missingIcon = context?.resources?.getDrawable(R.drawable.default_icon, context.theme)?.toBitmap(256, 256)
private val missingString = context?.getString(R.string.metadata_missing) private lateinit var context : Context
private val missingIcon by lazy { ContextCompat.getDrawable(context, R.drawable.default_icon)!!.toBitmap(256, 256) }
private val missingString by lazy { context.getString(R.string.metadata_missing) }
/** /**
* This adds a header to the view with the contents of [string] * This adds a header to the view with the contents of [string]
@ -89,29 +51,6 @@ internal class AppAdapter(val context : Context?, private val layoutType : Layou
super.addHeader(BaseHeader(string)) super.addHeader(BaseHeader(string))
} }
/**
* The onClick handler for the supplied [view], used for the icon preview
*/
override fun onClick(view : View) {
val position = view.tag as Int
if (getItem(position) is AppItem) {
val item = getItem(position) as AppItem
if (view.id == R.id.icon) {
val builder = Dialog(context!!)
builder.requestWindowFeature(Window.FEATURE_NO_TITLE)
builder.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
val imageView = ImageView(context)
imageView.setImageBitmap(item.icon ?: missingIcon)
builder.addContentView(imageView, RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
builder.show()
}
}
}
/** /**
* The ViewHolder used by items is used to hold the views associated with an item * The ViewHolder used by items is used to hold the views associated with an item
* *
@ -134,38 +73,49 @@ internal class AppAdapter(val context : Context?, private val layoutType : Layou
* This function creates the view-holder of type [viewType] with the layout parent as [parent] * This function creates the view-holder of type [viewType] with the layout parent as [parent]
*/ */
override fun onCreateViewHolder(parent : ViewGroup, viewType : Int) : RecyclerView.ViewHolder { override fun onCreateViewHolder(parent : ViewGroup, viewType : Int) : RecyclerView.ViewHolder {
context = parent.context
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
var holder : RecyclerView.ViewHolder? = null val view = when (ElementType.values()[viewType]) {
ElementType.Item -> inflater.inflate(layoutType.layoutRes, parent, false)
if (viewType == Item.ordinal) { ElementType.Header -> inflater.inflate(R.layout.section_item, parent, false)
val view = inflater.inflate(if (layoutType == LayoutType.List) R.layout.app_item_linear else R.layout.app_item_grid, parent, false)
holder = ItemViewHolder(view, view.findViewById(R.id.icon), view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle))
if (layoutType == LayoutType.List) {
if (context is View.OnClickListener)
view.setOnClickListener(context as View.OnClickListener)
if (context is View.OnLongClickListener)
view.setOnLongClickListener(context as View.OnLongClickListener)
} else {
holder.card = view.findViewById(R.id.app_item_grid)
if (context is View.OnClickListener)
holder.card!!.setOnClickListener(context as View.OnClickListener)
if (context is View.OnLongClickListener)
holder.card!!.setOnLongClickListener(context as View.OnLongClickListener)
holder.title.isSelected = true
}
} else if (viewType == Header.ordinal) {
val view = inflater.inflate(R.layout.section_item, parent, false)
holder = HeaderViewHolder(view)
holder.header = view.findViewById(R.id.text_title)
} }
return holder!! Log.i("blaa", "onCreateViewHolder")
return when (ElementType.values()[viewType]) {
ElementType.Item -> {
ItemViewHolder(view, view.findViewById(R.id.icon), view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle)).apply {
if (layoutType == LayoutType.List) {
view.apply {
if (context is View.OnClickListener) {
setOnClickListener(context as View.OnClickListener)
}
if (context is View.OnLongClickListener) {
setOnLongClickListener(context as View.OnLongClickListener)
}
}
} else {
card = view.findViewById(R.id.app_item_grid)
card!!.apply {
if (context is View.OnClickListener) {
setOnClickListener(context as View.OnClickListener)
}
if (context is View.OnLongClickListener) {
setOnLongClickListener(context as View.OnLongClickListener)
}
}
title.isSelected = true
}
}
}
ElementType.Header -> {
HeaderViewHolder(view).apply {
header = view.findViewById(R.id.text_title)
}
}
}
} }
/** /**
@ -183,16 +133,45 @@ internal class AppAdapter(val context : Context?, private val layoutType : Layou
holder.icon.setImageBitmap(item.icon ?: missingIcon) holder.icon.setImageBitmap(item.icon ?: missingIcon)
if (layoutType == LayoutType.List) { if (layoutType == LayoutType.List) {
holder.icon.setOnClickListener(this) holder.icon.setOnClickListener { showIconDialog(item) }
holder.icon.tag = position
} }
holder.card?.tag = item when (layoutType) {
holder.parent.tag = item LayoutType.List -> holder.itemView
LayoutType.Grid, LayoutType.GridCompact -> holder.card!!
}.apply {
setOnClickListener { onClick.invoke(item) }
setOnLongClickListener { true.also { onLongClick.invoke(item) } }
}
// Increase margin of edges to avoid huge gap in between items
if (layoutType == LayoutType.Grid || layoutType == LayoutType.GridCompact) {
holder.itemView.layoutParams = LinearLayout.LayoutParams(holder.itemView.layoutParams.width, holder.itemView.layoutParams.height).apply {
if (position % gridSpan == 0) {
marginStart = holder.itemView.resources.getDimensionPixelSize(R.dimen.app_card_margin) * 2
} else if (position % gridSpan == gridSpan - 1) {
marginEnd = holder.itemView.resources.getDimensionPixelSize(R.dimen.app_card_margin) * 2
}
}
holder.itemView.requestLayout()
}
} else if (item is BaseHeader) { } else if (item is BaseHeader) {
val holder = viewHolder as HeaderViewHolder val holder = viewHolder as HeaderViewHolder
holder.header!!.text = item.title holder.header!!.text = item.title
} }
} }
private fun showIconDialog(appItem : AppItem) {
val builder = Dialog(context)
builder.requestWindowFeature(Window.FEATURE_NO_TITLE)
builder.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
val imageView = ImageView(context)
imageView.setImageBitmap(appItem.icon ?: missingIcon)
builder.addContentView(imageView, RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
builder.show()
}
} }

View File

@ -10,6 +10,7 @@ import android.widget.Filter
import android.widget.Filterable import android.widget.Filterable
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import emu.skyline.data.BaseItem
import info.debatty.java.stringsimilarity.Cosine import info.debatty.java.stringsimilarity.Cosine
import info.debatty.java.stringsimilarity.JaroWinkler import info.debatty.java.stringsimilarity.JaroWinkler
import java.io.* import java.io.*
@ -35,16 +36,6 @@ abstract class BaseElement constructor(val elementType : ElementType) : Serializ
*/ */
class BaseHeader constructor(val title : String) : BaseElement(ElementType.Header) class BaseHeader constructor(val title : String) : BaseElement(ElementType.Header)
/**
* This is an abstract class that all adapter item classes inherit from
*/
abstract class BaseItem : BaseElement(ElementType.Item) {
/**
* This function returns a string used for searching
*/
abstract fun key() : String?
}
/** /**
* This adapter has the ability to have 2 types of elements specifically headers and items * This adapter has the ability to have 2 types of elements specifically headers and items
*/ */
@ -69,9 +60,9 @@ abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHeader?, Vie
*/ */
fun addItem(item : ItemType) { fun addItem(item : ItemType) {
elementArray.add(item) elementArray.add(item)
if (searchTerm.isNotEmpty()) if (searchTerm.isNotEmpty()) {
filter.filter(searchTerm) filter.filter(searchTerm)
else { } else {
visibleArray.add(elementArray.size - 1) visibleArray.add(elementArray.size - 1)
notifyDataSetChanged() notifyDataSetChanged()
} }
@ -146,93 +137,91 @@ abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHeader?, Vie
/** /**
* This returns an instance of the filter object which is used to search for items in the view * This returns an instance of the filter object which is used to search for items in the view
*/ */
override fun getFilter() : Filter { override fun getFilter() : Filter = object : Filter() {
return object : Filter() { /**
/** * We use Jaro-Winkler distance for string similarity (https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance)
* We use Jaro-Winkler distance for string similarity (https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance) */
*/ private val jw = JaroWinkler()
private val jw = JaroWinkler()
/** /**
* We use Cosine similarity for string similarity (https://en.wikipedia.org/wiki/Cosine_similarity) * We use Cosine similarity for string similarity (https://en.wikipedia.org/wiki/Cosine_similarity)
*/ */
private val cos = Cosine() private val cos = Cosine()
/** /**
* This class is used to store the results of the item sorting * This class is used to store the results of the item sorting
* *
* @param score The score of this result * @param score The score of this result
* @param index The index of this item * @param index The index of this item
*/ */
inner class ScoredItem(val score : Double, val index : Int) {} inner class ScoredItem(val score : Double, val index : Int) {}
/** /**
* This sorts the items in [keyArray] in relation to how similar they are to [term] * This sorts the items in [keyArray] in relation to how similar they are to [term]
*/ */
fun extractSorted(term : String, keyArray : ArrayList<String>) : Array<ScoredItem> { fun extractSorted(term : String, keyArray : ArrayList<String>) : Array<ScoredItem> {
val scoredItems : MutableList<ScoredItem> = ArrayList() val scoredItems : MutableList<ScoredItem> = ArrayList()
keyArray.forEachIndexed { index, item -> keyArray.forEachIndexed { index, item ->
val similarity = (jw.similarity(term, item) + cos.similarity(term, item)) / 2 val similarity = (jw.similarity(term, item) + cos.similarity(term, item)) / 2
if (similarity != 0.0) if (similarity != 0.0)
scoredItems.add(ScoredItem(similarity, index)) scoredItems.add(ScoredItem(similarity, index))
}
scoredItems.sortWith(compareByDescending { it.score })
return scoredItems.toTypedArray()
} }
/** scoredItems.sortWith(compareByDescending { it.score })
* This performs filtering on the items in [elementArray] based on similarity to [term]
*/
override fun performFiltering(term : CharSequence) : FilterResults {
val results = FilterResults()
searchTerm = (term as String).toLowerCase(Locale.getDefault())
if (term.isEmpty()) { return scoredItems.toTypedArray()
results.values = elementArray.indices.toMutableList() }
results.count = elementArray.size
} else {
val filterData = ArrayList<Int>()
val keyArray = ArrayList<String>() /**
val keyIndex = SparseIntArray() * This performs filtering on the items in [elementArray] based on similarity to [term]
*/
override fun performFiltering(term : CharSequence) : FilterResults {
val results = FilterResults()
searchTerm = (term as String).toLowerCase(Locale.getDefault())
for (index in elementArray.indices) { if (term.isEmpty()) {
val item = elementArray[index]!! results.values = elementArray.indices.toMutableList()
results.count = elementArray.size
} else {
val filterData = ArrayList<Int>()
if (item is BaseItem) { val keyArray = ArrayList<String>()
keyIndex.append(keyArray.size, index) val keyIndex = SparseIntArray()
keyArray.add(item.key()!!.toLowerCase(Locale.getDefault()))
} for (index in elementArray.indices) {
val item = elementArray[index]!!
if (item is BaseItem) {
keyIndex.append(keyArray.size, index)
keyArray.add(item.key()!!.toLowerCase(Locale.getDefault()))
} }
val topResults = extractSorted(searchTerm, keyArray)
val avgScore = topResults.sumByDouble { it.score } / topResults.size
for (result in topResults)
if (result.score > avgScore)
filterData.add(keyIndex[result.index])
results.values = filterData
results.count = filterData.size
} }
return results val topResults = extractSorted(searchTerm, keyArray)
val avgScore = topResults.sumByDouble { it.score } / topResults.size
for (result in topResults)
if (result.score > avgScore)
filterData.add(keyIndex[result.index])
results.values = filterData
results.count = filterData.size
} }
/** return results
* This publishes the results that were calculated in [performFiltering] to the view }
*/
override fun publishResults(charSequence : CharSequence, results : FilterResults) {
if (results.values is ArrayList<*>) {
@Suppress("UNCHECKED_CAST")
visibleArray = results.values as ArrayList<Int>
notifyDataSetChanged() /**
} * This publishes the results that were calculated in [performFiltering] to the view
*/
override fun publishResults(charSequence : CharSequence, results : FilterResults) {
if (results.values is ArrayList<*>) {
@Suppress("UNCHECKED_CAST")
visibleArray = results.values as ArrayList<Int>
notifyDataSetChanged()
} }
} }
} }
@ -244,7 +233,7 @@ abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHeader?, Vie
* @param adapter The adapter which is used to deduce the type of the item based on the position * @param adapter The adapter which is used to deduce the type of the item based on the position
* @param headerSpan The span size to return for headers * @param headerSpan The span size to return for headers
*/ */
class GridLayoutSpan<ItemType : BaseItem?, HeaderType : BaseHeader?, ViewHolder : RecyclerView.ViewHolder?>(val adapter : HeaderAdapter<ItemType, HeaderType, ViewHolder>, var headerSpan : Int) : GridLayoutManager.SpanSizeLookup() { class GridLayoutSpan<ItemType : BaseItem?, HeaderType : BaseHeader?, ViewHolder : RecyclerView.ViewHolder?>(val adapter : HeaderAdapter<ItemType, HeaderType, ViewHolder>, private val headerSpan : Int) : GridLayoutManager.SpanSizeLookup() {
/** /**
* This returns the size of the span based on the type of the element at [position] * This returns the size of the span based on the type of the element at [position]
*/ */

View File

@ -10,12 +10,12 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.OnLongClickListener
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import emu.skyline.R import emu.skyline.R
import emu.skyline.data.BaseItem
/** /**
* This class is used to hold all data about a log entry * This class is used to hold all data about a log entry
@ -32,7 +32,7 @@ internal class LogItem(val message : String, val level : String) : BaseItem() {
/** /**
* This adapter is used for displaying logs outputted by the application * This adapter is used for displaying logs outputted by the application
*/ */
internal class LogAdapter internal constructor(val context : Context, val compact : Boolean, private val debug_level : Int, private val level_str : Array<String>) : HeaderAdapter<LogItem, BaseHeader, RecyclerView.ViewHolder>(), OnLongClickListener { internal class LogAdapter internal constructor(val context : Context, val compact : Boolean, private val debug_level : Int, private val level_str : Array<String>) : HeaderAdapter<LogItem, BaseHeader, RecyclerView.ViewHolder>() {
private val clipboard : ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager private val clipboard : ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
/** /**
@ -72,41 +72,33 @@ internal class LogAdapter internal constructor(val context : Context, val compac
*/ */
private class HeaderViewHolder(val parent : View, var header : TextView) : RecyclerView.ViewHolder(parent) private class HeaderViewHolder(val parent : View, var header : TextView) : RecyclerView.ViewHolder(parent)
/**
* The onLongClick handler for the supplied [view], used to copy a log into the clipboard
*/
override fun onLongClick(view : View) : Boolean {
val item = view.tag as LogItem
clipboard.setPrimaryClip(ClipData.newPlainText("Log Message", item.message + " (" + item.level + ")"))
Toast.makeText(view.context, "Copied to clipboard", Toast.LENGTH_LONG).show()
return false
}
/** /**
* This function creates the view-holder of type [viewType] with the layout parent as [parent] * This function creates the view-holder of type [viewType] with the layout parent as [parent]
*/ */
override fun onCreateViewHolder(parent : ViewGroup, viewType : Int) : RecyclerView.ViewHolder { override fun onCreateViewHolder(parent : ViewGroup, viewType : Int) : RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
var holder : RecyclerView.ViewHolder? = null
if (viewType == ElementType.Item.ordinal) { val view = when (ElementType.values()[viewType]) {
if (compact) { ElementType.Item -> {
val view = inflater.inflate(R.layout.log_item_compact, parent, false) inflater.inflate(if (compact) R.layout.log_item_compact else R.layout.log_item, parent, false)
holder = ItemViewHolder(view, view.findViewById(R.id.text_title)) }
ElementType.Header -> {
view.setOnLongClickListener(this) inflater.inflate(R.layout.log_item, parent, false)
} else {
val view = inflater.inflate(R.layout.log_item, parent, false)
holder = ItemViewHolder(view, view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle))
view.setOnLongClickListener(this)
} }
} else if (viewType == ElementType.Header.ordinal) {
val view = inflater.inflate(R.layout.section_item, parent, false)
holder = HeaderViewHolder(view, view.findViewById(R.id.text_title))
} }
return holder!! return when (ElementType.values()[viewType]) {
ElementType.Item -> {
if (compact) {
ItemViewHolder(view, view.findViewById(R.id.text_title))
} else {
ItemViewHolder(view, view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle))
}
}
ElementType.Header -> {
HeaderViewHolder(view, view.findViewById(R.id.text_title))
}
}
} }
/** /**
@ -121,7 +113,10 @@ internal class LogAdapter internal constructor(val context : Context, val compac
holder.title.text = item.message holder.title.text = item.message
holder.subtitle?.text = item.level holder.subtitle?.text = item.level
holder.parent.tag = item holder.parent.setOnClickListener {
clipboard.setPrimaryClip(ClipData.newPlainText("Log Message", item.message + " (" + item.level + ")"))
Toast.makeText(holder.itemView.context, "Copied to clipboard", Toast.LENGTH_LONG).show()
}
} else if (item is BaseHeader) { } else if (item is BaseHeader) {
val holder = viewHolder as HeaderViewHolder val holder = viewHolder as HeaderViewHolder

View File

@ -0,0 +1,52 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.data
import android.graphics.Bitmap
import android.net.Uri
import emu.skyline.loader.AppEntry
/**
* This class is a wrapper around [AppEntry], it is used for passing around game metadata
*/
class AppItem(val meta : AppEntry) : BaseItem() {
/**
* The icon of the application
*/
val icon : Bitmap?
get() = meta.icon
/**
* The title of the application
*/
val title : String
get() = meta.name
/**
* The string used as the sub-title, we currently use the author
*/
val subTitle : String?
get() = meta.author
/**
* The URI of the application's image file
*/
val uri : Uri
get() = meta.uri
/**
* The format of the application ROM as a string
*/
private val type : String
get() = meta.format.name
/**
* The name and author is used as the key
*/
override fun key() : String? {
return if (meta.author != null) meta.name + " " + meta.author else meta.name
}
}

View File

@ -0,0 +1,19 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.data
import emu.skyline.adapter.BaseElement
import emu.skyline.adapter.ElementType
/**
* This is an abstract class that all adapter item classes inherit from
*/
abstract class BaseItem : BaseElement(ElementType.Item) {
/**
* This function returns a string used for searching
*/
abstract fun key() : String?
}

View File

@ -13,7 +13,6 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.view.Surface
import java.io.IOException import java.io.IOException
import java.io.ObjectInputStream import java.io.ObjectInputStream
import java.io.ObjectOutputStream import java.io.ObjectOutputStream
@ -23,12 +22,12 @@ import java.util.*
/** /**
* An enumeration of all supported ROM formats * An enumeration of all supported ROM formats
*/ */
enum class RomFormat(val format: Int){ enum class RomFormat(val extension : String) {
NRO(0), NRO("nro"),
NSO(1), NSO("nso"),
NCA(2), NCA("nca"),
XCI(3), XCI("xci"),
NSP(4), NSP("nsp"),
} }
/** /**
@ -185,7 +184,7 @@ internal class RomFile(val context : Context, val format : RomFormat, val file :
fun getAppEntry(uri : Uri) : AppEntry { fun getAppEntry(uri : Uri) : AppEntry {
return if (hasAssets(instance)) { return if (hasAssets(instance)) {
val rawIcon = getIcon(instance) val rawIcon = getIcon(instance)
val icon = if (rawIcon.size != 0) BitmapFactory.decodeByteArray(rawIcon, 0, rawIcon.size) else null val icon = if (rawIcon.isNotEmpty()) BitmapFactory.decodeByteArray(rawIcon, 0, rawIcon.size) else null
AppEntry(getApplicationName(instance), getApplicationPublisher(instance), format, uri, icon) AppEntry(getApplicationName(instance), getApplicationPublisher(instance), format, uri, icon)
} else { } else {

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="#80000000"
android:centerColor="#00000000"
android:startColor="#00000000"
android:type="linear" />
</shape>

View File

@ -1,21 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:gravity="center">
<androidx.cardview.widget.CardView xmlns:card_view="http://schemas.android.com/apk/res-auto" <androidx.cardview.widget.CardView
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/app_item_grid" android:id="@+id/app_item_grid"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_margin="15dp" android:layout_gravity="center"
android:layout_margin="@dimen/app_card_margin_half"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:foreground="?attr/selectableItemBackground" android:foreground="?attr/selectableItemBackground"
card_view:cardCornerRadius="4dp"> app:cardCornerRadius="4dp"
app:cardElevation="@dimen/app_card_margin"
app:cardUseCompatPadding="true">
<RelativeLayout <LinearLayout
android:layout_width="155dp" android:layout_width="155dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
@ -25,16 +28,13 @@
android:id="@+id/icon" android:id="@+id/icon"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="155dp" android:layout_height="155dp"
android:layout_alignParentTop="false"
android:layout_centerHorizontal="true"
android:contentDescription="@string/icon" android:contentDescription="@string/icon"
android:scaleType="centerCrop" /> tools:src="@drawable/default_icon" />
<TextView <TextView
android:id="@+id/text_title" android:id="@+id/text_title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/icon"
android:ellipsize="marquee" android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever" android:marqueeRepeatLimit="marquee_forever"
android:paddingStart="15dp" android:paddingStart="15dp"
@ -43,14 +43,12 @@
android:singleLine="true" android:singleLine="true"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
tools:ignore="RelativeOverlap" /> tools:text="Title" />
<TextView <TextView
android:id="@+id/text_subtitle" android:id="@+id/text_subtitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/text_title"
android:layout_alignStart="@id/text_title"
android:ellipsize="marquee" android:ellipsize="marquee"
android:fadingEdge="horizontal" android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever" android:marqueeRepeatLimit="marquee_forever"
@ -58,7 +56,8 @@
android:singleLine="true" android:singleLine="true"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceListItemSecondary" android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="@android:color/tertiary_text_light" /> android:textColor="@android:color/tertiary_text_light"
</RelativeLayout> tools:text="Subtitle" />
</LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
</LinearLayout> </FrameLayout>

View File

@ -0,0 +1,81 @@
<?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">
<androidx.cardview.widget.CardView
android:id="@+id/app_item_grid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="@dimen/app_card_margin_half"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardCornerRadius="4dp"
app:cardElevation="@dimen/app_card_margin"
app:cardUseCompatPadding="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="155dp"
android:layout_height="155dp">
<ImageView
android:id="@+id/icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentTop="false"
android:layout_centerHorizontal="true"
android:contentDescription="@string/icon"
android:foreground="@drawable/background_gradient"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/default_icon" />
<TextView
android:id="@+id/text_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="242.25"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="@android:color/white"
android:textStyle="bold"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@id/text_subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
tools:text="Title" />
<TextView
android:id="@+id/text_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:alpha="242.25"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="@android:color/white"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
tools:text="Subtitle" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</FrameLayout>

View File

@ -1,40 +1,43 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/app_item_linear"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground" android:background="?attr/selectableItemBackground"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:orientation="vertical" android:padding="16dp">
android:padding="15dp">
<ImageView <ImageView
android:id="@+id/icon" android:id="@+id/icon"
android:layout_width="50dp" android:layout_width="50dp"
android:layout_height="50dp" android:layout_height="50dp"
android:layout_alignParentStart="true" android:contentDescription="@string/icon"
android:layout_alignParentTop="false" app:layout_constraintStart_toStartOf="parent"
android:layout_centerVertical="true" app:layout_constraintTop_toTopOf="parent"
android:layout_marginEnd="10dp" tools:src="@drawable/default_icon" />
android:contentDescription="@string/icon" />
<TextView <TextView
android:id="@+id/text_title" android:id="@+id/text_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignTop="@id/icon" android:layout_marginStart="10dp"
android:layout_toEndOf="@id/icon"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
tools:ignore="RelativeOverlap" /> app:layout_constraintBottom_toTopOf="@+id/text_subtitle"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Title" />
<TextView <TextView
android:id="@+id/text_subtitle" android:id="@+id/text_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/text_title" android:layout_marginStart="10dp"
android:layout_alignStart="@id/text_title"
android:textAppearance="?android:attr/textAppearanceListItemSecondary" android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="@android:color/tertiary_text_light" /> android:textColor="@android:color/tertiary_text_light"
</RelativeLayout> app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toBottomOf="@+id/text_title"
tools:text="SubTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -15,10 +15,12 @@
<string-array name="layout_type"> <string-array name="layout_type">
<item>List</item> <item>List</item>
<item>Grid</item> <item>Grid</item>
<item>Grid Compact</item>
</string-array> </string-array>
<string-array name="layout_type_val"> <string-array name="layout_type_val">
<item>0</item> <item>0</item>
<item>1</item> <item>1</item>
<item>2</item>
</string-array> </string-array>
<string-array name="app_theme"> <string-array name="app_theme">
<item>Light</item> <item>Light</item>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="app_card_margin">8dp</dimen>
<dimen name="app_card_margin_half">4dp</dimen>
</resources>