Create DaysAndHoursPickerView.kt with DaysAndHoursPickerViewTest.kt
MAILAND-1672, MAILAND-1670
This commit is contained in:
parent
a41eaa0f0c
commit
d2662f4927
|
@ -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))
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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"/>
|
||||
|
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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 -->
|
||||
|
|
Loading…
Reference in New Issue