Commit b12f2558 authored by Tiger Oakes's avatar Tiger Oakes Committed by Tiger Oakes
Browse files

Issue #3480 - Port Fennec basic color picker

parent f43faec9
......@@ -24,7 +24,9 @@ android {
dependencies {
implementation project(':browser-session')
implementation project(':concept-engine')
implementation project(':lib-state')
implementation project(':support-ktx')
implementation project(':support-utils')
implementation Dependencies.androidx_core_ktx
implementation Dependencies.kotlin_stdlib
......
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.feature.prompts
import android.graphics.Color
import android.graphics.PorterDuff.Mode.MULTIPLY
import android.graphics.PorterDuff.Mode.SRC_IN
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.VisibleForTesting
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.support.utils.ColorUtils
/**
* Represents an item in the [BasicColorAdapter] list.
*
* @property color color int that this item corresponds to.
* @property contentDescription accessibility description of this color.
* @property selected if true, this is the color that will be set when the dialog is closed.
*/
data class ColorItem(
@ColorInt val color: Int,
val contentDescription: String,
val selected: Boolean = false
)
private object ColorItemDiffCallback : DiffUtil.ItemCallback<ColorItem>() {
override fun areItemsTheSame(oldItem: ColorItem, newItem: ColorItem) =
oldItem.color == newItem.color
override fun areContentsTheSame(oldItem: ColorItem, newItem: ColorItem) =
oldItem == newItem
}
/**
* RecyclerView adapter for displaying color items.
*/
internal class BasicColorAdapter(
private val onColorSelected: (Int) -> Unit
) : ListAdapter<ColorItem, ColorViewHolder>(ColorItemDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ColorViewHolder {
val view = LayoutInflater
.from(parent.context)
.inflate(R.layout.mozac_feature_prompts_color_item, parent, false)
return ColorViewHolder(view, onColorSelected)
}
override fun onBindViewHolder(holder: ColorViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
/**
* View holder for a color item.
*/
internal class ColorViewHolder(
itemView: View,
private val onColorSelected: (Int) -> Unit
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
@VisibleForTesting
@ColorInt
internal var color: Int = Color.BLACK
private val checkDrawable: Drawable? by lazy {
// Get the height of the row
val typedValue = TypedValue()
itemView.context.theme.resolveAttribute(android.R.attr.listPreferredItemHeight, typedValue, true)
var height = typedValue.getDimension(itemView.context.resources.displayMetrics).toInt()
// Remove padding for the shadow
val backgroundPadding = Rect()
itemView.context.getDrawable(R.drawable.color_picker_row_bg)?.getPadding(backgroundPadding)
height -= backgroundPadding.top + backgroundPadding.bottom
itemView.context.getDrawable(R.drawable.color_picker_checkmark)?.apply {
setBounds(0, 0, height, height)
}
}
init {
itemView.setOnClickListener(this)
}
fun bind(colorItem: ColorItem) {
// Save the color for the onClick callback
color = colorItem.color
// Set the background to look like this item's color
itemView.background = itemView.background.apply {
colorFilter = PorterDuffColorFilter(colorItem.color, MULTIPLY)
}
itemView.contentDescription = colorItem.contentDescription
// Display the check mark
val check = if (colorItem.selected) {
checkDrawable?.apply {
colorFilter = PorterDuffColorFilter(ColorUtils.getReadableTextColor(color), SRC_IN)
}
} else {
null
}
itemView.isActivated = colorItem.selected
(itemView as TextView).setCompoundDrawablesRelative(check, null, null, null)
}
override fun onClick(v: View?) {
onColorSelected(color)
}
}
......@@ -8,12 +8,10 @@ import android.annotation.SuppressLint
import android.app.Dialog
import android.content.DialogInterface
import android.graphics.Color
import android.graphics.PorterDuff
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager
......@@ -22,11 +20,14 @@ import androidx.recyclerview.widget.RecyclerView
private const val KEY_SELECTED_COLOR = "KEY_SELECTED_COLOR"
/**
* [android.support.v4.app.DialogFragment] implementation for a color picker dialog.
* [androidx.fragment.app.DialogFragment] implementation for a color picker dialog.
*/
internal class ColorPickerDialogFragment : PromptDialogFragment() {
internal class ColorPickerDialogFragment : PromptDialogFragment(), DialogInterface.OnClickListener {
private lateinit var selectColorTexView: TextView
@ColorInt
private var initiallySelectedCustomColor: Int? = null
private lateinit var defaultColors: List<ColorItem>
private lateinit var listAdapter: BasicColorAdapter
@VisibleForTesting
internal var selectedColor: Int
......@@ -35,138 +36,113 @@ internal class ColorPickerDialogFragment : PromptDialogFragment() {
safeArguments.putInt(KEY_SELECTED_COLOR, value)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(requireContext())
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
AlertDialog.Builder(requireContext())
.setCancelable(true)
.setNegativeButton(R.string.mozac_feature_prompts_cancel) { _, _ ->
feature?.onCancel(sessionId)
}
.setPositiveButton(R.string.mozac_feature_prompts_set_date) { _, _ ->
onPositiveClickAction()
}
.setTitle(R.string.mozac_feature_prompts_choose_a_color)
.setNegativeButton(R.string.mozac_feature_prompts_cancel, this)
.setPositiveButton(R.string.mozac_feature_prompts_set_date, this)
.setView(createDialogContentView())
return builder.create()
}
.create()
override fun onCancel(dialog: DialogInterface?) {
super.onCancel(dialog)
feature?.onCancel(sessionId)
onClick(dialog, DialogInterface.BUTTON_NEGATIVE)
}
companion object {
fun newInstance(sessionId: String, defaultColor: String): ColorPickerDialogFragment {
val fragment = ColorPickerDialogFragment()
val arguments = fragment.arguments ?: Bundle()
with(arguments) {
putString(KEY_SESSION_ID, sessionId)
putInt(KEY_SELECTED_COLOR, defaultColor.toColor())
}
fragment.arguments = arguments
return fragment
override fun onClick(dialog: DialogInterface?, which: Int) {
when (which) {
DialogInterface.BUTTON_POSITIVE -> feature?.onConfirm(sessionId, selectedColor.toHexColor())
DialogInterface.BUTTON_NEGATIVE -> feature?.onCancel(sessionId)
}
}
@SuppressLint("InflateParams")
internal fun createDialogContentView(): View {
val inflater = LayoutInflater.from(requireContext())
val view = inflater.inflate(R.layout.mozac_feature_prompts_color_picker_dialogs, null)
initSelectedColor(view)
initRecyclerView(view, inflater)
val view = LayoutInflater
.from(requireContext())
.inflate(R.layout.mozac_feature_prompts_color_picker_dialogs, null)
return view
}
// Save the color selected when this dialog opened to show at the end
initiallySelectedCustomColor = selectedColor
private fun onPositiveClickAction() {
feature?.onConfirm(sessionId, selectedColor.toHexColor())
}
// Load list of colors from resources
val typedArray = resources.obtainTypedArray(R.array.mozac_feature_prompts_default_colors)
fun onColorChange(newColor: Int) {
selectedColor = newColor
selectColorTexView.changeColor(newColor)
}
defaultColors = List(typedArray.length()) { i ->
val color = typedArray.getColor(i, Color.BLACK)
if (color == initiallySelectedCustomColor) {
// No need to save the initial color, its already in the list
initiallySelectedCustomColor = null
}
/**
* RecyclerView adapter for displaying color items.
*/
internal class ColorAdapter(
private val fragment: ColorPickerDialogFragment,
private val inflater: LayoutInflater
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
@Suppress("MagicNumber")
internal val defaultColors = arrayOf(
Color.rgb(215, 57, 32),
Color.rgb(255, 134, 5),
Color.rgb(255, 203, 19),
Color.rgb(95, 173, 71),
Color.rgb(33, 161, 222),
Color.rgb(16, 36, 87),
Color.rgb(91, 32, 103),
Color.rgb(212, 221, 228),
Color.WHITE
)
override fun getItemCount(): Int = defaultColors.size
override fun onCreateViewHolder(parent: ViewGroup, type: Int): RecyclerView.ViewHolder {
val view = inflater.inflate(R.layout.mozac_feature_prompts_color_item, parent, false)
return ColorViewHolder(view)
color.toColorItem()
}
typedArray.recycle()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
setupRecyclerView(view)
onColorChange(selectedColor)
return view
}
with(holder as ColorViewHolder) {
val color = defaultColors[position]
bind(color, fragment)
private fun setupRecyclerView(view: View) {
listAdapter = BasicColorAdapter(this::onColorChange)
view.findViewById<RecyclerView>(R.id.recyclerView).apply {
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false).apply {
stackFromEnd = true
}
adapter = listAdapter
setHasFixedSize(true)
itemAnimator = null
}
}
/**
* View holder for a color item.
* Called when a new color is selected by the user.
*/
internal class ColorViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(color: Int, fragment: ColorPickerDialogFragment) {
with(itemView) {
changeColor(color)
tag = color
@VisibleForTesting
internal fun onColorChange(newColor: Int) {
selectedColor = newColor
setOnClickListener {
val newColor = it.tag as Int
fragment.onColorChange(newColor)
}
}
val colorItems = defaultColors.toMutableList()
val index = colorItems.indexOfFirst { it.color == newColor }
val lastColor = if (index > -1) {
colorItems[index] = colorItems[index].copy(selected = true)
initiallySelectedCustomColor
} else {
newColor
}
if (lastColor != null) {
colorItems.add(lastColor.toColorItem(selected = lastColor == newColor))
}
}
private fun initSelectedColor(view: View) {
selectColorTexView = view.findViewById(R.id.selected_color)
selectColorTexView.changeColor(selectedColor)
listAdapter.submitList(colorItems)
}
private fun initRecyclerView(view: View, inflater: LayoutInflater) {
view.findViewById<RecyclerView>(R.id.recyclerView).apply {
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
adapter = ColorAdapter(this@ColorPickerDialogFragment, inflater)
companion object {
fun newInstance(sessionId: String, defaultColor: String) = ColorPickerDialogFragment().apply {
arguments = (arguments ?: Bundle()).apply {
putString(KEY_SESSION_ID, sessionId)
putInt(KEY_SELECTED_COLOR, defaultColor.toColor())
}
}
}
}
@Suppress("Deprecation")
private fun View.changeColor(newColor: Int) {
background.setColorFilter(newColor, PorterDuff.Mode.MULTIPLY)
internal fun Int.toColorItem(selected: Boolean = false): ColorItem {
return ColorItem(
color = this,
contentDescription = toHexColor(),
selected = selected
)
}
internal fun String.toColor(): Int {
return try {
Color.parseColor(this)
} catch (e: IllegalArgumentException) {
0
Color.BLACK
}
}
......
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:innerRadius="15dip"
android:thickness="4dip"
android:useLevel="false">
<solid android:color="@android:color/white"/>
</shape>
......@@ -2,15 +2,12 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/color_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:gravity="center_vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="@android:drawable/editbox_background"
android:minHeight="?android:attr/listPreferredItemHeightSmall"/>
\ No newline at end of file
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/color_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:gravity="center_vertical"
android:background="@drawable/color_picker_row_bg"
android:minHeight="?android:attr/listPreferredItemHeight" />
......@@ -2,49 +2,11 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<LinearLayout
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="?android:attr/listPreferredItemPaddingLeft"
android:paddingStart="?android:attr/listPreferredItemPaddingLeft"
android:paddingEnd="?android:attr/listPreferredItemPaddingLeft"
android:orientation="horizontal">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?android:attr/textAppearanceLarge"
android:text="@string/mozac_feature_prompts_choose_a_color"/>
<TextView
android:id="@+id/selected_color"
android:layout_width="32dp"
android:layout_height="32dp"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_gravity="center_vertical|center_horizontal"
android:layout_marginStart="5dp"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="@android:drawable/editbox_background"
android:minHeight="?android:attr/listPreferredItemHeightSmall"/>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
android:paddingTop="16dp"
tools:listitem="@layout/mozac_feature_prompts_color_item" />
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<resources>
<array name="mozac_feature_prompts_default_colors">
<item>#D73920</item>
<item>#FF8605</item>
<item>#FFCB13</item>
<item>#5FAD47</item>
<item>#21A1DE</item>
<item>#102457</item>
<item>#5B2067</item>
<item>#D4DDE4</item>
<item>#FFFFFF</item>
</array>
</resources>
......@@ -10,11 +10,9 @@ import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.ext.appCompatContext
import mozilla.components.feature.prompts.ColorPickerDialogFragment.ColorAdapter
import mozilla.components.feature.prompts.ColorPickerDialogFragment.ColorViewHolder
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import mozilla.ext.appCompatContext
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
......@@ -115,12 +113,15 @@ class ColorPickerDialogFragmentTest {
doReturn(appCompatContext).`when`(fragment).requireContext()
val adapter = getAdapterFrom(fragment)
val holder = adapter.onCreateViewHolder(LinearLayout(testContext), 0) as ColorViewHolder
val labelView = holder.itemView as TextView
val holder = adapter.onCreateViewHolder(LinearLayout(testContext), 0)
adapter.bindViewHolder(holder, 0)
val selectedColor = adapter.defaultColors.first()
assertEquals(labelView.tag as Int, selectedColor)
val selectedColor = appCompatContext.resources
.obtainTypedArray(R.array.mozac_feature_prompts_default_colors).let {
it.getColor(0, 0)
}
assertEquals(selectedColor, holder.color)
}
@Test
......@@ -132,20 +133,23 @@ class ColorPickerDialogFragmentTest {
doReturn(appCompatContext).`when`(fragment).requireContext()
val adapter = getAdapterFrom(fragment)
val holder = adapter.onCreateViewHolder(LinearLayout(testContext), 0) as ColorViewHolder
val holder = adapter.onCreateViewHolder(LinearLayout(testContext), 0)
val colorItem = holder.itemView as TextView
adapter.bindViewHolder(holder, 0)
colorItem.performClick()
val selectedColor = adapter.defaultColors.first()
val selectedColor = appCompatContext.resources
.obtainTypedArray(R.array.mozac_feature_prompts_default_colors).let {
it.getColor(0, 0)
}
assertEquals(fragment.selectedColor, selectedColor)
}
private fun getAdapterFrom(fragment: ColorPickerDialogFragment): ColorAdapter {
private fun getAdapterFrom(fragment: ColorPickerDialogFragment): BasicColorAdapter {
val view = fragment.createDialogContentView()
val recyclerViewId = R.id.recyclerView
return view.findViewById<RecyclerView>(recyclerViewId).adapter as ColorAdapter
return view.findViewById<RecyclerView>(recyclerViewId).adapter as BasicColorAdapter
}
}
\ No newline at end of file
}
......@@ -34,6 +34,9 @@ permalink: /changelog/
* **support-ktx**
* Added `putCompoundDrawablesRelative` and `putCompoundDrawablesRelativeWithIntrinsicBounds`, aliases of `setCompoundDrawablesRelative` that use Kotlin named and default arguments.
* **feature-prompts**