From 7ce046c8b8304b9a08665e75f8e11c61817c6e60 Mon Sep 17 00:00:00 2001 From: Juraj Kusnier <jurajkusnier@users.noreply.github.com> Date: Wed, 17 Mar 2021 10:22:20 +0100 Subject: [PATCH] Update Generic Duration Picker (EXPOSUREAPP-5746) (#2627) * Customise values * Add sliders for testing * Lint * Fix test * Update strings * Update Duration picker * Update Contact diary test menu * Lint format * Remove runtime exception * Revert changes for fragment-ktx * Formatting * Update DurationExtensionKtTest * Update strings Co-authored-by: Mohamed Metwalli <mohamed.metwalli@sap.com> Co-authored-by: Lukas Lechner <lukas.lechner@sap.com> --- .../ui/ContactDiaryTestFragment.kt | 20 ++++ .../layout/fragment_test_contact_diary.xml | 92 +++++++++++++++++- .../ui/durationpicker/DurationExtension.kt | 14 +-- .../ui/durationpicker/DurationPicker.kt | 94 ++++++++++++++++--- .../src/main/res/layout/duration_picker.xml | 32 +++++-- .../src/main/res/values-de/strings.xml | 4 + .../src/main/res/values/strings.xml | 4 + .../durationpicker/DurationExtensionKtTest.kt | 49 ++++++++-- .../ui/durationpicker/DurationPickerTest.kt | 26 ++--- 9 files changed, 272 insertions(+), 63 deletions(-) diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/contactdiary/ui/ContactDiaryTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/contactdiary/ui/ContactDiaryTestFragment.kt index cfdd87475..051c3aa5a 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/contactdiary/ui/ContactDiaryTestFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/contactdiary/ui/ContactDiaryTestFragment.kt @@ -16,6 +16,7 @@ import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider import de.rki.coronawarnapp.util.viewmodel.cwaViewModels import org.joda.time.Duration import javax.inject.Inject +import kotlin.math.roundToInt @SuppressLint("SetTextI18n") class ContactDiaryTestFragment : @@ -56,6 +57,25 @@ class ContactDiaryTestFragment : durationPicker.setDurationChangeListener(this@ContactDiaryTestFragment) durationPicker.show(parentFragmentManager, "ContactDiaryDurationPickerFragment") } + + durationValue2.setOnClickListener { + val durationPicker = DurationPicker.Builder() + .duration(binding.durationValue2.text.toString()) + .title("Presence tracing title") + .minutes(step = minutesSlider.value.toInt()) + .hours( + min = hoursLimitsRangeSlider.values.first().roundToInt(), + max = hoursLimitsRangeSlider.values.last().roundToInt(), + step = hoursSlider.value.toInt() + ) + .build() + durationPicker.show(parentFragmentManager, "PresenceTracing") + durationPicker.setDurationChangeListener { + durationValue2.text = it.toContactDiaryFormat() + } + } + + hoursLimitsRangeSlider.setValues(0f, 24f) } } diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_contact_diary.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_contact_diary.xml index 8069c616d..573345c1d 100644 --- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_contact_diary.xml +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_contact_diary.xml @@ -18,8 +18,8 @@ style="@style/Card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:backgroundTint="@color/colorContactDiaryListItem" - android:layout_margin="@dimen/spacing_tiny"> + android:layout_margin="@dimen/spacing_tiny" + android:backgroundTint="@color/colorContactDiaryListItem"> <TextView android:id="@+id/duration_picker" @@ -35,15 +35,99 @@ android:layout_width="70dp" android:layout_height="wrap_content" android:layout_marginTop="@dimen/spacing_tiny" + android:background="@drawable/contact_diary_duration_background_default" + android:paddingLeft="13dp" android:paddingTop="11dp" + android:paddingRight="13dp" android:paddingBottom="11dp" + android:text="@string/duration_dialog_default_value" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/duration_picker" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + <androidx.constraintlayout.widget.ConstraintLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_tiny" + android:backgroundTint="@color/colorContactDiaryListItem"> + + <TextView + android:id="@+id/duration_picker_2" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Duration picker 1 minute step" + app:layout_constraintTop_toBottomOf="@id/duration_picker_2" /> + + <TextView + android:id="@+id/hours_limits_title" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Hours limits" + app:layout_constraintTop_toTopOf="parent" /> + + <com.google.android.material.slider.RangeSlider + android:id="@+id/hours_limits_range_slider" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:stepSize="1.0" + android:valueFrom="0.0" + android:valueTo="48.0" + app:layout_constraintTop_toBottomOf="@id/hours_limits_title" /> + + <TextView + android:id="@+id/hours_slider_title" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Hours range" + app:layout_constraintTop_toBottomOf="@id/hours_limits_range_slider" /> + + <com.google.android.material.slider.Slider + android:id="@+id/hours_slider" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:stepSize="1.0" + android:valueFrom="1.0" + android:valueTo="24.0" + app:layout_constraintTop_toBottomOf="@id/hours_slider_title" /> + + <TextView + android:id="@+id/minutes_slider_title" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Minutes range" + app:layout_constraintTop_toBottomOf="@id/hours_slider" /> + + <com.google.android.material.slider.Slider + android:id="@+id/minutes_slider" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:stepSize="1.0" + android:valueFrom="1.0" + android:valueTo="60.0" + app:layout_constraintTop_toBottomOf="@id/minutes_slider_title" /> + + <TextView + android:id="@+id/duration_value_2" + style="@style/bodyNeutral" + android:layout_width="70dp" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:background="@drawable/contact_diary_duration_background_default" android:paddingLeft="13dp" + android:paddingTop="11dp" android:paddingRight="13dp" + android:paddingBottom="11dp" android:text="@string/duration_dialog_default_value" - android:background="@drawable/contact_diary_duration_background_default" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/duration_picker"/> + app:layout_constraintTop_toBottomOf="@id/minutes_slider" /> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtension.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtension.kt index 40c79fb1f..91fc48139 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtension.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtension.kt @@ -3,18 +3,8 @@ package de.rki.coronawarnapp.ui.durationpicker import org.joda.time.Duration fun Duration.toContactDiaryFormat(): String { - val hours = if (standardHours < 10) { - "0$standardHours" - } else { - standardHours.toString() - } - val minutesCleaned = standardMinutes - standardHours * 60 - val minutes = if (minutesCleaned < 10) { - "0$minutesCleaned" - } else { - minutesCleaned.toString() - } - return "$hours:$minutes" + val minutes = standardMinutes - standardHours * 60 + return "%02d:%02d".format(standardHours, minutes) } // returns readable durations with optional prefix and suffix such as "Dauer 01:30 h" diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationPicker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationPicker.kt index 044f141c4..09299192e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationPicker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/durationpicker/DurationPicker.kt @@ -4,11 +4,13 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.IntRange import androidx.fragment.app.DialogFragment import de.rki.coronawarnapp.databinding.DurationPickerBinding import org.joda.time.Duration import org.joda.time.format.PeriodFormatter import org.joda.time.format.PeriodFormatterBuilder +import kotlin.math.max class DurationPicker : DialogFragment() { @@ -17,6 +19,10 @@ class DurationPicker : DialogFragment() { } private var onChangeListener: OnChangeListener? = null + private val hoursArray by lazy { requireArguments().getStringArray(HOURS_KEY).orEmpty() } + private val title by lazy { requireArguments().getString(TITLE_KEY).orEmpty() } + private val minutesArray by lazy { requireArguments().getStringArray(MINUTES_KEY).orEmpty() } + private val binding: Lazy<DurationPickerBinding> = lazy { DurationPickerBinding.inflate(layoutInflater) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -38,16 +44,20 @@ class DurationPicker : DialogFragment() { displayedValues = minutesArray } + with(binding.value.title) { + text = title + } + with(binding.value) { var duration = requireArguments().getString(DURATION_KEY)!!.split(":").toTypedArray() if (duration.size < 2) duration = arrayOf("00", "00") - hours.value = hoursArray.indexOf(duration[0]) - minutes.value = minutesArray.indexOf(duration[1]) + hours.value = max(0, hoursArray.indexOf(duration[0])) + minutes.value = max(0, minutesArray.indexOf(duration[1])) cancelButton.setOnClickListener { dismiss() } okButton.setOnClickListener { - onChangeListener?.onChange(getDuration(hours.value, minutes.value)) + onChangeListener?.onChange(getDuration()) dismiss() } } @@ -57,27 +67,29 @@ class DurationPicker : DialogFragment() { this.onChangeListener = onChangeListener } + private fun getDuration(): Duration { + val durationString = hoursArray[binding.value.hours.value] + ":" + minutesArray[binding.value.minutes.value] + val formatter: PeriodFormatter = PeriodFormatterBuilder() + .appendHours() + .appendLiteral(":") + .appendMinutes() + .toFormatter() + return formatter.parsePeriod(durationString).toStandardDuration() + } + companion object { private const val DURATION_KEY = "duration" private const val TITLE_KEY = "title" - val minutesArray = arrayOf("00", "15", "30", "45") - val hoursArray = Array(24) { "%02d".format(it) } - - fun getDuration(hours: Int, minutes: Int): Duration { - val durationString = hoursArray[hours] + ":" + minutesArray[minutes] - val formatter: PeriodFormatter = PeriodFormatterBuilder() - .appendHours() - .appendLiteral(":") - .appendMinutes() - .toFormatter() - return formatter.parsePeriod(durationString).toStandardDuration() - } + private const val HOURS_KEY = "hours" + private const val MINUTES_KEY = "minutes" private fun newInstance(builder: Builder) = DurationPicker() .apply { arguments = Bundle().apply { putString(DURATION_KEY, builder.duration) putString(TITLE_KEY, builder.title) + putStringArray(HOURS_KEY, builder.hoursArray) + putStringArray(MINUTES_KEY, builder.minutesArray) } } } @@ -88,8 +100,60 @@ class DurationPicker : DialogFragment() { var duration: String = "" private set + var minutesArray = arrayOf("00", "15", "30", "45") + private set + var hoursArray = Array(24) { "%02d".format(it) } + private set + + /** + * Sets picker dialog title + */ fun title(title: String) = apply { this.title = title } + + /** + * Sets picker default duration as "hh:mm" string + */ fun duration(duration: String) = apply { this.duration = duration } + + /** + * Sets minutes range set + * @param min [Int] inclusive start + * @param max [Int] exclusive end + * @param step [Int] a value in range of `0` and `59` + */ + fun minutes( + min: Int = 0, + max: Int = 60, + @IntRange(from = 1, to = 59) step: Int = 1 + ) = apply { + minutesArray = valuesArray(min, max, step) + } + + /** + * Sets hours minutes set + * @param min [Int] inclusive start + * @param max [Int] exclusive end + * @param step [Int] a value in range of `0` and `23` + */ + fun hours( + min: Int = 0, + max: Int = 24, + @IntRange(from = 1, to = 23) step: Int = 1 + ) = apply { + hoursArray = valuesArray(min, max, step) + } + fun build() = newInstance(this) + + private fun valuesArray(min: Int, max: Int, step: Int): Array<String> { + val values = mutableListOf<String>() + for (item in min until max step step) { + values.add("%02d".format(item)) + } + if (values.size == 0) { + return arrayOf("00") + } + return values.toTypedArray() + } } } diff --git a/Corona-Warn-App/src/main/res/layout/duration_picker.xml b/Corona-Warn-App/src/main/res/layout/duration_picker.xml index 3e165988d..5115ad426 100644 --- a/Corona-Warn-App/src/main/res/layout/duration_picker.xml +++ b/Corona-Warn-App/src/main/res/layout/duration_picker.xml @@ -4,22 +4,41 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/duration_container" android:layout_width="match_parent" - tools:context="de.rki.coronawarnapp.ui.durationpicker.DurationPicker" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + tools:context="de.rki.coronawarnapp.ui.durationpicker.DurationPicker"> <TextView android:id="@+id/title" style="@style/headline6" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="24dp" - android:layout_marginTop="@dimen/spacing_small" - android:padding="@dimen/spacing_mega_tiny" + android:paddingStart="@dimen/spacing_normal" + android:paddingTop="@dimen/spacing_small" + android:paddingEnd="@dimen/spacing_normal" + android:paddingBottom="@dimen/spacing_normal" android:text="@string/duration_dialog_title" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Dauer" /> + <TextView + android:id="@+id/labelHours" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/duration_hours" + app:layout_constraintEnd_toEndOf="@id/hours" + app:layout_constraintStart_toStartOf="@id/hours" + app:layout_constraintTop_toBottomOf="@+id/title" /> + + <TextView + android:id="@+id/labelMinutes" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/duration_minutes" + app:layout_constraintEnd_toEndOf="@id/minutes" + app:layout_constraintStart_toStartOf="@id/minutes" + app:layout_constraintTop_toBottomOf="@+id/title" /> + <NumberPicker android:id="@+id/hours" android:layout_width="wrap_content" @@ -52,13 +71,12 @@ android:id="@+id/minutes" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="18dp" android:layout_marginEnd="67dp" android:theme="@style/DefaultNumberPickerTheme" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toEndOf="@+id/divider" - app:layout_constraintTop_toBottomOf="@+id/title" /> + app:layout_constraintTop_toBottomOf="@+id/labelMinutes" /> <com.google.android.material.button.MaterialButton android:id="@+id/cancel_button" diff --git a/Corona-Warn-App/src/main/res/values-de/strings.xml b/Corona-Warn-App/src/main/res/values-de/strings.xml index 1e6f63eff..964add2da 100644 --- a/Corona-Warn-App/src/main/res/values-de/strings.xml +++ b/Corona-Warn-App/src/main/res/values-de/strings.xml @@ -1921,6 +1921,10 @@ <string name="duration_dialog_ok_button">OK</string> <!-- NOTR --> <string name="duration_dialog_default_value">00:00</string> + <!-- XTXT: Duration hours text --> + <string name="duration_hours">"Stunden"</string> + <!-- XTXT: Duration minutes text --> + <string name="duration_minutes">"Minuten"</string> <!-- Scan check in QR Code--> <!-- XTXT: Scan check in QR-Code FAB text--> diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml index 4257ddb1e..ab3d6252c 100644 --- a/Corona-Warn-App/src/main/res/values/strings.xml +++ b/Corona-Warn-App/src/main/res/values/strings.xml @@ -1930,6 +1930,10 @@ <string name="duration_dialog_ok_button">"OK"</string> <!-- NOTR --> <string name="duration_dialog_default_value">"00:00"</string> + <!-- XTXT: Duration hours text --> + <string name="duration_hours">"Stunden"</string> + <!-- XTXT: Duration minutes text --> + <string name="duration_minutes">"Minuten"</string> <!-- Scan check in QR Code--> <!-- XTXT: Scan check in QR-Code FAB text--> diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtensionKtTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtensionKtTest.kt index 78d19f9fd..1e12c906f 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtensionKtTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationExtensionKtTest.kt @@ -9,7 +9,15 @@ import org.junit.jupiter.params.provider.MethodSource internal class DurationExtensionKtTest { @ParameterizedTest - @MethodSource("provideArguments") + @MethodSource("provideArgumentsForContactDiaryFormat") + fun `toContactDiaryFormat() should return correct String`(testItem: TestItem) { + with(testItem) { + duration.toContactDiaryFormat() shouldBe expectedReadableDuration + } + } + + @ParameterizedTest + @MethodSource("provideArgumentsForReadableDuration") fun `toReadableDuration() should return correct String`(testItem: TestItem) { with(testItem) { duration.toReadableDuration(prefix, suffix) shouldBe expectedReadableDuration @@ -20,21 +28,46 @@ internal class DurationExtensionKtTest { @Suppress("unused") @JvmStatic - fun provideArguments() = listOf( + fun provideArgumentsForContactDiaryFormat() = listOf( + TestItem( + duration = Duration.standardMinutes(0), + expectedReadableDuration = "00:00" + ), + TestItem( + duration = Duration.standardMinutes(1), + expectedReadableDuration = "00:01" + ), + TestItem( + duration = Duration.standardMinutes(30), + expectedReadableDuration = "00:30" + ), + TestItem( + duration = Duration.standardMinutes(45), + expectedReadableDuration = "00:45" + ), + TestItem( + duration = Duration.standardMinutes(60), + expectedReadableDuration = "01:00" + ), + TestItem( + duration = Duration.standardMinutes(75), + expectedReadableDuration = "01:15" + ), + ).map { Arguments.of(it) } + + @Suppress("unused") + @JvmStatic + fun provideArgumentsForReadableDuration() = listOf( TestItem( - prefix = null, - suffix = null, duration = Duration.standardMinutes(30), expectedReadableDuration = "00:30" ), TestItem( prefix = "Dauer", - suffix = null, duration = Duration.standardMinutes(45), expectedReadableDuration = "Dauer 00:45" ), TestItem( - prefix = null, suffix = "Std.", duration = Duration.standardMinutes(60), expectedReadableDuration = "01:00 Std." @@ -49,9 +82,9 @@ internal class DurationExtensionKtTest { } data class TestItem( - val prefix: String?, + val prefix: String? = null, val duration: Duration, - val suffix: String?, + val suffix: String? = null, val expectedReadableDuration: String ) } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationPickerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationPickerTest.kt index 61c1b12ae..3454383e3 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationPickerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/durationpicker/DurationPickerTest.kt @@ -1,43 +1,35 @@ package de.rki.coronawarnapp.ui.durationpicker import io.kotest.matchers.shouldBe -import io.mockk.MockKAnnotations import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class DurationPickerTest { + private lateinit var durationPickerBuilder: DurationPicker.Builder + @BeforeEach fun setup() { - MockKAnnotations.init(this) + durationPickerBuilder = DurationPicker.Builder() } @Test fun `check hours array`() { - DurationPicker.hoursArray.count() shouldBe 24 + durationPickerBuilder.hoursArray.count() shouldBe 24 for (i in 0..9) { - DurationPicker.hoursArray[i] shouldBe "0$i" + durationPickerBuilder.hoursArray[i] shouldBe "0$i" } for (i in 10..23) { - DurationPicker.hoursArray[i] shouldBe "$i" + durationPickerBuilder.hoursArray[i] shouldBe "$i" } } @Test fun `check minutes array`() { - DurationPicker.minutesArray.count() shouldBe 4 - DurationPicker.minutesArray[0] shouldBe "00" + durationPickerBuilder.minutesArray.count() shouldBe 4 + durationPickerBuilder.minutesArray[0] shouldBe "00" for (i in 1..3) { - DurationPicker.minutesArray[i] shouldBe "${i * 15}" + durationPickerBuilder.minutesArray[i] shouldBe "${i * 15}" } } - - @Test - fun `check duration`() { - DurationPicker.getDuration(0, 0).toContactDiaryFormat() shouldBe "00:00" - DurationPicker.getDuration(1, 0).toContactDiaryFormat() shouldBe "01:00" - DurationPicker.getDuration(23, 3).toContactDiaryFormat() shouldBe "23:45" - DurationPicker.getDuration(9, 2).toContactDiaryFormat() shouldBe "09:30" - DurationPicker.getDuration(10, 1).toContactDiaryFormat() shouldBe "10:15" - } } -- GitLab