Create DaysAndHoursPickerView.kt with DaysAndHoursPickerViewTest.kt

MAILAND-1672, MAILAND-1670
This commit is contained in:
Davide Farella 2021-06-11 09:41:29 +02:00 committed by Davide Giuseppe Farella
parent a41eaa0f0c
commit d2662f4927
7 changed files with 477 additions and 3 deletions

View File

@ -0,0 +1,226 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.ui.view
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withClassName
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import ch.protonmail.android.R
import ch.protonmail.android.ui.view.DaysAndHoursPickerView.Companion.MAX_DAYS
import ch.protonmail.android.ui.view.DaysAndHoursPickerView.Companion.MAX_HOURS
import ch.protonmail.android.util.ViewTest
import com.google.android.material.textfield.TextInputEditText
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import me.proton.core.test.kotlin.CoroutinesTest
import org.hamcrest.Matchers.`is`
import org.hamcrest.core.AllOf
import org.junit.runner.RunWith
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Test suite for [MultiLineLabelChipGroupView]
*/
@RunWith(AndroidJUnit4ClassRunner::class)
class DaysAndHoursPickerViewTest : ViewTest<DaysAndHoursPickerView>(::DaysAndHoursPickerView), CoroutinesTest {
// Days
@Test
fun daysAreNotChangedIfCorrect() {
// given
val input = 14
// when
testView.set(days = input, hours = 0)
// then
onDaysView().check(matches(withText("14")))
}
@Test
fun daysAreSetToZeroIfBelowZero() {
// given
val input = -14
// when
testView.set(days = input, hours = 0)
// then
onDaysView().check(matches(withText("0")))
}
@Test
fun daysAreSetToMaxIfTooHigh() {
// given
val input = 999
// when
testView.set(days = input, hours = 0)
// then
onDaysView().check(matches(withText("$MAX_DAYS")))
}
@Test
fun daysAreSetToZeroIfInvalidInput() {
// given
val input = "hello"
// when
onDaysView().perform(ViewActions.typeText(input))
// then
onDaysView().check(matches(withText("0")))
}
// Hours
@Test
fun hoursAreNotChangedIfCorrect() {
// given
val input = 14
// when
testView.set(days = 0, hours = input)
// then
onHoursView().check(matches(withText("14")))
}
@Test
fun hoursAreSetToZeroIfBelowZero() {
// given
val input = -14
// when
testView.set(days = 0, hours = input)
// then
onHoursView().check(matches(withText("0")))
}
@Test
fun hoursAreSetToMaxIfTooHigh() {
// given
val input = 999
// when
testView.set(days = 0, hours = input)
// then
onHoursView().check(matches(withText("$MAX_HOURS")))
}
@Test
fun hoursAreSetToZeroIfInvalidInput() {
// given
val input = "hello"
// when
onHoursView().perform(ViewActions.typeText(input))
// then
onHoursView().check(matches(withText("0")))
}
// Callback
@Test
fun callbackIsNotCalledIfSetSameHourOrDay() = coroutinesTest {
// given
val result = mutableListOf<DaysHoursPair>()
val expected = listOf(
DaysHoursPair(1, 1),
DaysHoursPair(2, 2),
DaysHoursPair(1, 1)
)
val job = launch {
testView.onChange.toList(result)
}
// when
testView.apply {
set(1, 1)
delay(100)
set(2, 2)
delay(100)
set(2, 2)
delay(100)
set(2, 2)
delay(100)
set(1, 1)
delay(100)
}
// then
assertEquals(expected, result)
job.cancel()
}
@Test
fun callbackIsNotCalledForInvalidValues() = coroutinesTest {
// given
val result = mutableListOf<DaysHoursPair>()
val expected = listOf(DaysHoursPair(MAX_DAYS, MAX_HOURS))
val job = launch {
testView.onChange.toList(result)
}
// when
testView.set(99, 99)
delay(100)
// then
assertEquals(expected, result)
job.cancel()
}
private fun onDaysView(): ViewInteraction =
onView(
AllOf.allOf(
withId(R.id.days_and_hours_picker_days_input),
withClassName(`is`(TextInputEditText::class.qualifiedName))
)
)
private fun onHoursView(): ViewInteraction =
onView(
AllOf.allOf(
withId(R.id.days_and_hours_picker_hours_input),
withClassName(`is`(TextInputEditText::class.qualifiedName))
)
)
}

View File

@ -54,7 +54,7 @@ class CheckableButton @JvmOverloads constructor(
checkView = binding.checkableButtonCheck
context.withStyledAttributes(attrs, R.styleable.CheckableButton, defStyleAttr) {
val iconDrawable = getDrawable(R.styleable.CheckableButton_icon)
val iconDrawable = getDrawable(R.styleable.CheckableButton_checkableButtonIcon)
button.setImageDrawable(iconDrawable)
}
}

View File

@ -0,0 +1,136 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.ui.view
import android.content.Context
import android.text.Editable
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.annotation.StyleRes
import androidx.annotation.VisibleForTesting
import androidx.constraintlayout.widget.ConstraintLayout
import ch.protonmail.android.databinding.ViewDaysAndHoursPickerBinding
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.receiveAsFlow
import me.proton.core.presentation.ui.view.ProtonInput
import me.proton.core.presentation.utils.onTextChange
import kotlin.time.milliseconds
/**
* Picker for Hours and Days
*/
@OptIn(FlowPreview::class)
class DaysAndHoursPickerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
private val daysInput: ProtonInput
private val hoursInput: ProtonInput
private val changesBuffer = Channel<DaysHoursPair>(Channel.BUFFERED)
val onChange: Flow<DaysHoursPair> =
changesBuffer.receiveAsFlow()
.distinctUntilChanged()
.debounce(50.milliseconds)
init {
val binding = ViewDaysAndHoursPickerBinding.inflate(
LayoutInflater.from(context),
this
)
daysInput = binding.daysAndHoursPickerDaysInput
hoursInput = binding.daysAndHoursPickerHoursInput
daysInput.onTextChange { text ->
val hours = hoursInput.text?.toString()?.toIntOrNull()
if (normaliseDays(text) == HasChanged.False && hours != null)
changesBuffer.offer(DaysHoursPair(text.toString().toInt(), hours))
}
hoursInput.onTextChange { text ->
val days = daysInput.text?.toString()?.toIntOrNull()
if (normaliseHours(text) == HasChanged.False && days != null)
changesBuffer.offer(DaysHoursPair(days, text.toString().toInt()))
}
}
fun set(days: Int, hours: Int) {
daysInput.text = days.toString()
hoursInput.text = hours.toString()
}
private fun normaliseDays(input: Editable): HasChanged {
val inputInt = input.toString().toIntOrNull()
return when {
inputInt == null || inputInt < 0 -> {
daysInput.text = 0.toString()
HasChanged.True
}
inputInt > MAX_DAYS -> {
daysInput.text = MAX_DAYS.toString()
HasChanged.True
}
else -> {
HasChanged.False
}
}
}
private fun normaliseHours(input: Editable): HasChanged {
val inputInt = input.toString().toIntOrNull()
return when {
inputInt == null || inputInt < 0 -> {
hoursInput.text = 0.toString()
HasChanged.True
}
inputInt > MAX_HOURS -> {
hoursInput.text = MAX_HOURS.toString()
HasChanged.True
}
else -> {
HasChanged.False
}
}
}
private enum class HasChanged {
True, False
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
companion object {
const val MAX_DAYS = 28
const val MAX_HOURS = 23
}
}
data class DaysHoursPair(
val days: Int,
val hours: Int
)

View File

@ -46,7 +46,7 @@
app:layout_constraintHorizontal_bias="0.87"
app:layout_constraintVertical_bias="0.75"
android:background="@drawable/shape_ellipse"
android:backgroundTint="@color/brand"
android:backgroundTint="?brand_norm"
android:src="@drawable/shape_check"
android:visibility="gone"
tools:visibility="visible"/>

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2020 Proton Technologies AG
~
~ This file is part of ProtonMail.
~
~ ProtonMail is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ ProtonMail is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with ProtonMail. If not, see https://www.gnu.org/licenses/.
-->
<merge
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"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"
tools:layout_height="wrap_content">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/days_and_hours_picker_start_guideline"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintGuide_percent="0.1"
android:orientation="vertical"/>
<me.proton.core.presentation.ui.view.ProtonInput
android:id="@+id/days_and_hours_picker_days_input"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/days_and_hours_picker_start_guideline"
app:layout_constraintEnd_toStartOf="@+id/days_and_hours_picker_days_text_view"
app:layout_constraintHorizontal_chainStyle="packed"
android:layout_marginTop="@dimen/padding_l"
android:inputType="number"
android:textAppearance="@style/Proton.Text.Default"
tools:ignore="LabelFor"
android:text="@string/x_0"
tools:text="5" />
<TextView
android:id="@+id/days_and_hours_picker_days_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/days_and_hours_picker_days_input"
app:layout_constraintEnd_toStartOf="@id/days_and_hours_picker_mid_guideline"
android:layout_marginStart="@dimen/padding_l"
android:text="@string/x_days"
android:textAppearance="@style/Proton.Text.Default"
tools:layout_editor_absoluteY="11dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/days_and_hours_picker_mid_guideline"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintGuide_percent="0.47"
android:orientation="vertical"/>
<me.proton.core.presentation.ui.view.ProtonInput
android:id="@+id/days_and_hours_picker_hours_input"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/days_and_hours_picker_mid_guideline"
app:layout_constraintEnd_toStartOf="@+id/days_and_hours_picker_hours_text_view"
app:layout_constraintHorizontal_chainStyle="packed"
android:layout_marginTop="@dimen/padding_l"
android:inputType="number"
android:textAppearance="@style/Proton.Text.Default"
tools:ignore="LabelFor"
android:text="@string/x_0"
tools:text="5" />
<TextView
android:id="@+id/days_and_hours_picker_hours_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/days_and_hours_picker_hours_input"
app:layout_constraintEnd_toEndOf="@id/days_and_hours_picker_end_guideline"
android:layout_marginStart="@dimen/padding_l"
android:text="@string/x_hours"
android:textAppearance="@style/Proton.Text.Default"
tools:layout_editor_absoluteY="11dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/days_and_hours_picker_end_guideline"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintGuide_percent="0.9"
android:orientation="vertical"/>
</merge>

View File

@ -26,7 +26,7 @@
</declare-styleable>
<declare-styleable name="CheckableButton">
<attr name="icon" format="reference"/>
<attr name="checkableButtonIcon" format="reference"/>
</declare-styleable>
<declare-styleable name="LabelChipView">

View File

@ -21,10 +21,14 @@
<!-- region All -->
<string name="x_app_version_name_code">ProtonMail %s (%d)</string>
<string name="x_days">Days</string>
<string name="x_folders">Folders</string>
<string name="x_hours">Hours</string>
<string name="x_labels">Labels</string>
<string name="x_more">More</string>
<string name="x_search">Search</string>
<string name="x_set">Set</string>
<string name="x_0">0</string>
<!-- endregion -->
<!-- region Drawer -->