Commit 5e32f657 authored by Sebastian Kaspari's avatar Sebastian Kaspari
Browse files

Issue #86: DoorhangerPrompt: Builder for creating a prompt Doorhanger...

Issue #86: DoorhangerPrompt: Builder for creating a prompt Doorhanger providing a way to present decisions to users.
parent 1e47b9da
......@@ -29,6 +29,7 @@ dependencies {
implementation Dependencies.kotlin_stdlib
implementation Dependencies.support_appcompat
implementation Dependencies.support_constraintlayout
implementation Dependencies.support_cardview
testImplementation project(':support-test')
......
......@@ -2,4 +2,6 @@
- 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/. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="mozilla.components.ui.doorhanger" />
package="mozilla.components.ui.doorhanger">
<application android:supportsRtl="true" />
</manifest>
/* 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.ui.doorhanger
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import mozilla.components.support.ktx.android.view.forEach
/**
* Builder for creating a prompt [Doorhanger] providing a way to present decisions to users.
*
* @param title Title text for this prompt.
* @param icon (Optional) icon to be displayed next to the title.
* @param controlGroups A list of control groups to be displayed in the prompt.
* @param buttons A list of buttons to be displayed in the prompt.
* @param onDismiss that is called when the doorhanger is dismissed.
*/
class DoorhangerPrompt(
private val title: String,
private val icon: Drawable? = null,
private val controlGroups: List<ControlGroup> = listOf(),
private val buttons: List<Button> = listOf(),
private val onDismiss: (() -> Unit)? = null
) {
/**
* Creates a [Doorhanger] from the [DoorhangerPrompt] configuration.
*/
fun createDoorhanger(context: Context): Doorhanger {
var doorhanger: Doorhanger? = null
@SuppressLint("InflateParams")
val view = LayoutInflater.from(context).inflate(
R.layout.mozac_ui_doorhanger_prompt,
null,
false)
view.findViewById<TextView>(R.id.title).text = title
icon?.let { drawable ->
view.findViewById<ImageView>(R.id.icon).setImageDrawable(drawable)
}
val buttonContainer = view.findViewById<LinearLayout>(R.id.buttons)
buttons.forEach { button ->
val buttonView = button.createView(context, buttonContainer) {
doorhanger?.dismiss()
button.onClick.invoke()
}
buttonContainer.addView(buttonView)
}
val controlsContainer = view.findViewById<LinearLayout>(R.id.controls)
controlGroups.forEachIndexed { index, group ->
val groupView = group.createView(context, controlsContainer)
controlsContainer.addView(groupView)
if (index < controlGroups.size - 1) {
controlsContainer.addView(
createDividerView(
context,
controlsContainer
)
)
}
}
return Doorhanger(view, onDismiss).also {
doorhanger = it
}
}
/**
*
* @property label a string to display on the button
* @property positive a boolean for whether the button is an affirmative button. The value is implicitly false if
* omitted.
*/
data class Button(
val label: String,
val positive: Boolean = false,
val onClick: () -> Unit
)
/**
* A group of controls to be displayed in a [DoorhangerPrompt].
*
* @param icon An (optional) icon to be shown next to the control group.
* @param controls List of controls to be shown in this group.
*/
data class ControlGroup(
val icon: Drawable? = null,
val controls: List<Control>
)
sealed class Control {
internal abstract fun createView(context: Context, parent: ViewGroup): View
/**
* [Control] implementation for radio buttons. A radio button is a two-states button that can be either checked
* or unchecked.
*/
data class RadioButton(
val label: String
) : Control() {
internal val viewId = View.generateViewId()
var checked = false
override fun createView(context: Context, parent: ViewGroup): View {
val view = LayoutInflater.from(context).inflate(
R.layout.mozac_ui_doorhanger_radiobutton,
parent,
false
)
return (view as android.widget.RadioButton).apply {
this.id = viewId
this.text = label
}
}
}
/**
* [Control] implementation for checkboxes. A checkbox is a specific type of two-states button that can be
* either checked or unchecked.
*/
data class CheckBox(
val label: String,
var checked: Boolean
) : Control() {
override fun createView(context: Context, parent: ViewGroup): View {
val view = LayoutInflater.from(context).inflate(
R.layout.mozac_ui_doorhanger_checkbox,
parent,
false
)
return (view as android.widget.CheckBox).apply {
text = label
isChecked = checked
setOnCheckedChangeListener { _, isChecked -> checked = isChecked }
}
}
}
}
}
internal fun DoorhangerPrompt.ControlGroup.createView(context: Context, parent: ViewGroup): View {
val view = LayoutInflater.from(context).inflate(
R.layout.mozac_ui_doorhanger_controlgroup,
parent,
false)
val iconView = view.findViewById<ImageView>(R.id.icon)
if (icon == null) {
iconView.visibility = View.INVISIBLE
} else {
iconView.setImageDrawable(icon)
}
val groupView = view.findViewById<RadioGroup>(R.id.group)
controls.forEach { control ->
val controlView = control.createView(context, groupView)
groupView.addView(controlView)
}
groupView.setOnCheckedChangeListener { _, checkedId ->
controls.forEach {
if (it is DoorhangerPrompt.Control.RadioButton) {
it.checked = it.viewId == checkedId
}
}
}
groupView.selectFirstRadioButton()
return view
}
internal fun DoorhangerPrompt.Button.createView(context: Context, parent: ViewGroup, onClick: () -> Unit): View {
val view = LayoutInflater.from(context).inflate(
if (positive) {
R.layout.mozac_ui_doorhanger_button_positive
} else {
R.layout.mozac_ui_doorhanger_button
},
parent,
false
) as android.widget.Button
view.text = label
view.setOnClickListener { onClick.invoke() }
return view
}
private fun createDividerView(context: Context, parent: ViewGroup): View {
return LayoutInflater.from(context).inflate(
R.layout.mozac_ui_doorhanger_divider,
parent,
false
)
}
private fun ViewGroup.selectFirstRadioButton() {
var found = false
forEach { view ->
if (view is RadioButton && !found) {
found = true
view.toggle()
}
}
}
<?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/. -->
<Button xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Don't allow"
style="?android:attr/borderlessButtonStyle" />
<?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/. -->
<Button xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Allow" />
\ No newline at end of file
<?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/. -->
<CheckBox xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="4dp" />
<?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/. -->
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp">
<ImageView
android:id="@+id/icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="0dp"
android:importantForAccessibility="no"
android:scaleType="center"
android:tint="#000000"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@android:drawable/ic_menu_camera" />
<RadioGroup
android:id="@+id/group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="0dp"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toTopOf="parent"
tools:layout_height="250dp" />
</android.support.constraint.ConstraintLayout>
\ No newline at end of file
<?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/. -->
<View
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/mozac_ui_doorhanger_divider_color"
tools:ignore="Overdraw" />
<?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/. -->
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="0dp"
android:scaleType="center"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@android:drawable/ic_menu_camera"
android:importantForAccessibility="no"
android:tint="#000000" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:textColor="#000000"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Allow wikipedia.org to use your camera?" />
<LinearLayout
android:id="@+id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:gravity="end"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title"
tools:layout_height="50dp" />
<LinearLayout
android:id="@+id/buttons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:gravity="end"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toBottomOf="@+id/controls"
tools:layout_height="50dp"
tools:layout_width="200dp" />
</android.support.constraint.ConstraintLayout>
\ No newline at end of file
<?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/. -->
<RadioButton xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="4dp" />
<?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/. -->
<resources>
<color name="mozac_ui_doorhanger_divider_color">#ffe4e4e8</color>
</resources>
\ No newline at end of file
/* 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.ui.doorhanger
import android.content.Context
import android.support.v7.content.res.AppCompatResources
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DoorhangerPromptTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
@Test
fun `Create complex prompt doorhanger`() {
var allowButtonClicked = false
var dontAllowButtonClicked = false
val prompt = DoorhangerPrompt(
title = "Allow wikipedia.org to use your camera and microphone?",
controlGroups = listOf(
DoorhangerPrompt.ControlGroup(
icon = AppCompatResources.getDrawable(context, android.R.drawable.ic_menu_camera),
controls = listOf(
DoorhangerPrompt.Control.RadioButton("Front-facing camera"),
DoorhangerPrompt.Control.RadioButton("Selfie camera")
)
),
DoorhangerPrompt.ControlGroup(
icon = AppCompatResources.getDrawable(context, android.R.drawable.ic_btn_speak_now),
controls = listOf(
DoorhangerPrompt.Control.RadioButton("Speakerphone"),
DoorhangerPrompt.Control.RadioButton("Headset microphone"),
DoorhangerPrompt.Control.CheckBox(
"Don't ask again on this site",
checked = true
)
)
)
),
buttons = listOf(
DoorhangerPrompt.Button("Don't allow") {
dontAllowButtonClicked = true
},
DoorhangerPrompt.Button("Allow", positive = true) {
allowButtonClicked = true
}
),
onDismiss = {}
)
val doorhanger = prompt.createDoorhanger(context)
assertNotNull(doorhanger)
val popupWindow = doorhanger.show(View(context))
assertNotNull(popupWindow)
val contentView = popupWindow.contentView
assertNotNull(contentView)
// --------------------------------------------------------------------------------------------
// Title
// --------------------------------------------------------------------------------------------
val titleView = contentView.findViewById<TextView>(R.id.title)
assertNotNull(titleView)
assertEquals("Allow wikipedia.org to use your camera and microphone?", titleView.text)
// --------------------------------------------------------------------------------------------
// Control groups:
// --------------------------------------------------------------------------------------------
val controlsContainer = contentView.findViewById<ViewGroup>(R.id.controls)
assertNotNull(controlsContainer)
assertEquals(3, controlsContainer.childCount) // 2 groups + 1 divider
// --------------------------------------------------------------------------------------------
// First control group:
// --------------------------------------------------------------------------------------------
val group1 = controlsContainer.getChildAt(0).findViewById<ViewGroup>(R.id.group)
assertEquals(2, group1.childCount)
val radioButton11 = group1.getChildAt(0) as RadioButton
assertEquals("Front-facing camera", radioButton11.text)
assertTrue(radioButton11.isChecked)
val radioButton12 = group1.getChildAt(1) as RadioButton
assertEquals("Selfie camera", radioButton12.text)
assertFalse(radioButton12.isChecked)
// --------------------------------------------------------------------------------------------
// Second control group:
// --------------------------------------------------------------------------------------------
val group2 = controlsContainer.getChildAt(2).findViewById<ViewGroup>(R.id.group)
assertEquals(3, group2.childCount)
val radioButton21 = group2.getChildAt(0) as RadioButton
assertEquals("Speakerphone", radioButton21.text)
assertTrue(radioButton21.isChecked)
val radioButton22 = group2.getChildAt(1) as RadioButton
assertEquals("Headset microphone", radioButton22.text)
assertFalse(radioButton22.isChecked)
val checkBox23 = group2.getChildAt(2) as CheckBox
assertEquals("Don't ask again on this site", checkBox23.text)
assertTrue(checkBox23.isChecked)
// --------------------------------------------------------------------------------------------
// Buttons
// --------------------------------------------------------------------------------------------
val buttonsContainer = contentView.findViewById<ViewGroup>(R.id.buttons)
assertNotNull(buttonsContainer)
assertEquals(2, buttonsContainer.childCount)
val button1 = buttonsContainer.getChildAt(0) as Button
assertEquals("Don't allow", button1.text)
val button2 = buttonsContainer.getChildAt(1) as Button
assertEquals("Allow", button2.text)
assertFalse(dontAllowButtonClicked)
button1.performClick()
assertTrue(dontAllowButtonClicked)
assertFalse(allowButtonClicked)
button2.performClick()
assertTrue(allowButtonClicked)
}
@Test
fun `RadioButton view updates data class`() {
val button1 = DoorhangerPrompt.Control.RadioButton("Front-facing camera")