Skip to content

Commit

Permalink
feat: Make time picker dialog reusable (#2174)
Browse files Browse the repository at this point in the history
  • Loading branch information
LunarX authored Feb 20, 2025
2 parents 6ea305b + e023f35 commit d02b5e2
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 143 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2025 Infomaniak Network SA
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.mail.ui.alertDialogs

import android.content.Context
import android.text.format.DateFormat
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import com.infomaniak.lib.core.utils.*
import com.infomaniak.mail.R
import com.infomaniak.mail.databinding.DialogSelectDateAndTimeBinding
import com.infomaniak.mail.utils.date.DateFormatUtils.formatTime
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
import com.infomaniak.lib.core.R as RCore

abstract class SelectDateAndTimeDialog(private val activityContext: Context) : BaseAlertDialog(activityContext) {

@get:StringRes
abstract val positiveButtonText: Int

abstract fun defineCalendarConstraint(): CalendarConstraints.Builder

abstract fun getDelayTooShortErrorMessage(): String

val binding: DialogSelectDateAndTimeBinding by lazy { DialogSelectDateAndTimeBinding.inflate(activity.layoutInflater) }

override val alertDialog = initDialog()

private var onDateSelected: ((Long) -> Unit)? = null
private var onAbort: (() -> Unit)? = null

private lateinit var selectedDate: Date

private fun initDialog() = with(binding) {
MaterialAlertDialogBuilder(context)
.setView(root)
.setPositiveButton(R.string.buttonConfirm, null)
.setNegativeButton(RCore.string.buttonCancel, null)
.create()
}

final override fun resetCallbacks() {
onDateSelected = null
onAbort = null
}

fun show(onDateSelected: (Long) -> Unit, onAbort: (() -> Unit)? = null) {
showDialogWithBasicInfo()
setupListeners(onDateSelected, onAbort)
}

private fun showDialogWithBasicInfo() {
alertDialog.show()

selectDate(Date().roundUpToNextTenMinutes())
positiveButton.setText(positiveButtonText)
}

private fun setupListeners(onDateSelected: (Long) -> Unit, onAbort: (() -> Unit)?) = with(alertDialog) {

binding.dateField.setOnClickListener {
showDatePicker(selectedDate) { time ->
val date = Date(time).let { newDate ->
Calendar.getInstance().apply {
set(newDate.year(), newDate.month(), newDate.day(), selectedDate.hours(), selectedDate.minutes(), 0)
}.time
}

selectDate(date)
}
}

binding.timeField.setOnClickListener {
showTimePicker(selectedDate) { hour, minute ->
selectDate(selectedDate.setHour(hour).setMinute(minute))
}
}

this@SelectDateAndTimeDialog.onDateSelected = onDateSelected
this@SelectDateAndTimeDialog.onAbort = onAbort

positiveButton.setOnClickListener {
this@SelectDateAndTimeDialog.onDateSelected?.invoke(selectedDate.time)
dismiss()
}

negativeButton.setOnClickListener { cancel() }

setOnCancelListener { onAbort?.invoke() }
}

private fun selectDate(date: Date) {
updateErrorMessage(date)
selectedDate = date
with(binding) {
dateField.setText(date.format(FORMAT_DATE_DAY_MONTH_YEAR))
timeField.setText(context.formatTime(date))
}
}

private fun updateErrorMessage(date: Date) {
val isValid = date.isAtLeastXMinutesInTheFuture(MIN_SELECTABLE_DATE_MINUTES)

if (isValid.not()) binding.errorMessage.text = getErrorText(date)
binding.errorMessage.isVisible = isValid.not()
positiveButton.isEnabled = isValid
}

private fun getErrorText(date: Date): String = if (date.isInTheFuture()) {
getDelayTooShortErrorMessage()
} else {
activityContext.resources.getString(R.string.errorChooseUpcomingDate)
}

private fun showTimePicker(dateToDisplay: Date, onDateSelected: (Int, Int) -> Unit) {
val timePicker = MaterialTimePicker.Builder()
.setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK)
.setTimeFormat(if (DateFormat.is24HourFormat(activityContext)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H)
.setHour(dateToDisplay.hours())
.setMinute(dateToDisplay.minutes())
.setTitleText(binding.context.getString(R.string.selectTimeDialogTitle))
.build()

timePicker.addOnPositiveButtonClickListener { onDateSelected(timePicker.hour, timePicker.minute) }

timePicker.show(super.activity.supportFragmentManager, null)
}

private fun showDatePicker(dateToDisplay: Date, onDateSelected: (Long) -> Unit) {
// MaterialDatePicker expects the `setSelection()` time to be defined as UTC time and not local time
val utcTime = dateToDisplay.time + TimeZone.getDefault().getOffset(dateToDisplay.time)

val datePicker = MaterialDatePicker.Builder.datePicker()
.setTitleText(binding.context.getString(R.string.selectDateDialogTitle))
.setSelection(utcTime)
.setCalendarConstraints(defineCalendarConstraint().build())
.build()

datePicker.addOnPositiveButtonClickListener(onDateSelected)

datePicker.show(super.activity.supportFragmentManager, null)
}

companion object {
const val MIN_SELECTABLE_DATE_MINUTES = 5
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,162 +18,39 @@
package com.infomaniak.mail.ui.alertDialogs

import android.content.Context
import android.text.format.DateFormat
import androidx.core.view.isVisible
import com.google.android.material.datepicker.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import com.infomaniak.lib.core.utils.*
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.CompositeDateValidator
import com.google.android.material.datepicker.DateValidatorPointBackward
import com.google.android.material.datepicker.DateValidatorPointForward
import com.infomaniak.lib.core.utils.addYears
import com.infomaniak.mail.R
import com.infomaniak.mail.databinding.DialogSelectDateAndTimeForScheduledDraftBinding
import com.infomaniak.mail.utils.date.DateFormatUtils.formatTime
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
import javax.inject.Inject
import com.infomaniak.lib.core.R as RCore

@ActivityScoped
open class SelectDateAndTimeForScheduledDraftDialog @Inject constructor(
@ActivityContext private val activityContext: Context,
) : BaseAlertDialog(activityContext) {
) : SelectDateAndTimeDialog(activityContext) {

val binding: DialogSelectDateAndTimeForScheduledDraftBinding by lazy {
DialogSelectDateAndTimeForScheduledDraftBinding.inflate(activity.layoutInflater)
}

override val alertDialog = initDialog()

private var onSchedule: ((Long) -> Unit)? = null
private var onAbort: (() -> Unit)? = null

private lateinit var selectedDate: Date

private fun initDialog() = with(binding) {
MaterialAlertDialogBuilder(context)
.setView(root)
.setPositiveButton(R.string.buttonConfirm, null)
.setNegativeButton(RCore.string.buttonCancel, null)
.create()
}

final override fun resetCallbacks() {
onSchedule = null
onAbort = null
}

fun show(onSchedule: (Long) -> Unit, onAbort: (() -> Unit)? = null) {
showDialogWithBasicInfo()
setupListeners(onSchedule, onAbort)
}

private fun showDialogWithBasicInfo() {
alertDialog.show()

selectDate(Date().roundUpToNextTenMinutes())
positiveButton.setText(R.string.buttonScheduleTitle)
}

private fun setupListeners(onSchedule: (Long) -> Unit, onAbort: (() -> Unit)?) = with(alertDialog) {

binding.dateField.setOnClickListener {
showDatePicker(selectedDate) { time ->
val date = Date(time).let { newDate ->
Calendar.getInstance().apply {
set(newDate.year(), newDate.month(), newDate.day(), selectedDate.hours(), selectedDate.minutes(), 0)
}.time
}

selectDate(date)
}
}

binding.timeField.setOnClickListener {
showTimePicker(selectedDate) { hour, minute ->
selectDate(selectedDate.setHour(hour).setMinute(minute))
}
}

this@SelectDateAndTimeForScheduledDraftDialog.onSchedule = onSchedule
this@SelectDateAndTimeForScheduledDraftDialog.onAbort = onAbort

positiveButton.setOnClickListener {
this@SelectDateAndTimeForScheduledDraftDialog.onSchedule?.invoke(selectedDate.time)
dismiss()
}

negativeButton.setOnClickListener { cancel() }
override val positiveButtonText: Int = R.string.buttonScheduleTitle

setOnCancelListener { onAbort?.invoke() }
}

private fun selectDate(date: Date) {
updateErrorMessage(date)
selectedDate = date
with(binding) {
dateField.setText(date.format(FORMAT_DATE_DAY_MONTH_YEAR))
timeField.setText(context.formatTime(date))
}
}

private fun updateErrorMessage(date: Date) {
val isValid = date.isAtLeastXMinutesInTheFuture(MIN_SCHEDULE_DELAY_MINUTES)

if (isValid.not()) binding.scheduleDateError.text = getScheduleDateErrorText(date)
binding.scheduleDateError.isVisible = isValid.not()
positiveButton.isEnabled = isValid
}

private fun getScheduleDateErrorText(date: Date): String = if (date.isInTheFuture()) {
activityContext.resources.getQuantityString(
R.plurals.errorScheduleDelayTooShort,
MIN_SCHEDULE_DELAY_MINUTES,
MIN_SCHEDULE_DELAY_MINUTES,
)
} else {
activityContext.resources.getString(R.string.errorChooseUpcomingDate)
}

private fun showTimePicker(dateToDisplay: Date, onDateSelected: (Int, Int) -> Unit) {
val timePicker = MaterialTimePicker.Builder()
.setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK)
.setTimeFormat(if (DateFormat.is24HourFormat(activityContext)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H)
.setHour(dateToDisplay.hours())
.setMinute(dateToDisplay.minutes())
.setTitleText(binding.context.getString(R.string.selectTimeDialogTitle))
.build()

timePicker.addOnPositiveButtonClickListener { onDateSelected(timePicker.hour, timePicker.minute) }

timePicker.show(super.activity.supportFragmentManager, null)
}

private fun showDatePicker(dateToDisplay: Date, onDateSelected: (Long) -> Unit) {
override fun defineCalendarConstraint(): CalendarConstraints.Builder {
val dateValidators = listOf(
DateValidatorPointForward.now(),
DateValidatorPointBackward.before(Date().addYears(MAX_SCHEDULE_DELAY_YEARS).time),
)
val constraintsBuilder = CalendarConstraints.Builder().setValidator(CompositeDateValidator.allOf(dateValidators))

// MaterialDatePicker expects the `setSelection()` time to be defined as UTC time and not local time
val utcTime = dateToDisplay.time + TimeZone.getDefault().getOffset(dateToDisplay.time)

val datePicker = MaterialDatePicker.Builder.datePicker()
.setTitleText(binding.context.getString(R.string.selectDateDialogTitle))
.setSelection(utcTime)
.setCalendarConstraints(constraintsBuilder.build())
.build()

datePicker.addOnPositiveButtonClickListener(onDateSelected)

datePicker.show(super.activity.supportFragmentManager, null)
return CalendarConstraints.Builder().setValidator(CompositeDateValidator.allOf(dateValidators))
}

override fun getDelayTooShortErrorMessage(): String = activityContext.resources.getQuantityString(
R.plurals.errorScheduleDelayTooShort,
MIN_SELECTABLE_DATE_MINUTES,
MIN_SELECTABLE_DATE_MINUTES,
)

companion object {
const val MIN_SCHEDULE_DELAY_MINUTES = 5
const val MAX_SCHEDULE_DELAY_YEARS = 10
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import com.infomaniak.lib.core.utils.*
import com.infomaniak.mail.MatomoMail.trackScheduleSendEvent
import com.infomaniak.mail.R
import com.infomaniak.mail.databinding.BottomSheetScheduleSendBinding
import com.infomaniak.mail.ui.alertDialogs.SelectDateAndTimeForScheduledDraftDialog.Companion.MIN_SCHEDULE_DELAY_MINUTES
import com.infomaniak.mail.ui.alertDialogs.SelectDateAndTimeDialog.Companion.MIN_SELECTABLE_DATE_MINUTES
import com.infomaniak.mail.ui.main.thread.actions.ActionItemView
import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear
import dagger.hilt.android.AndroidEntryPoint
Expand Down Expand Up @@ -72,7 +72,7 @@ class ScheduleSendBottomSheetDialog @Inject constructor() : BottomSheetDialogFra
}

private fun computeLastScheduleItem() = with(binding) {
if (Date(lastSelectedScheduleEpoch).isAtLeastXMinutesInTheFuture(MIN_SCHEDULE_DELAY_MINUTES)) {
if (Date(lastSelectedScheduleEpoch).isAtLeastXMinutesInTheFuture(MIN_SELECTABLE_DATE_MINUTES)) {
lastScheduleItem.isVisible = true
lastScheduleItem.setDescription(context.dayOfWeekDateWithoutYear(date = Date(lastSelectedScheduleEpoch)))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ class ThreadFragment : Fragment() {

getBackNavigationResult(OPEN_DATE_AND_TIME_SCHEDULE_DIALOG) { _: Boolean ->
dateAndTimeScheduleDialog.show(
onSchedule = { timestamp ->
onDateSelected = { timestamp ->
localSettings.lastSelectedScheduleEpoch = timestamp
mainViewModel.rescheduleDraft(Date(timestamp))
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class NewMessageFragment : Fragment() {

getBackNavigationResult(OPEN_DATE_AND_TIME_SCHEDULE_DIALOG) { _: Boolean ->
dateAndTimeScheduleDialog.show(
onSchedule = { timestamp ->
onDateSelected = { timestamp ->
localSettings.lastSelectedScheduleEpoch = timestamp
scheduleDraft(timestamp)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
</com.infomaniak.lib.core.views.EndIconTextInputLayout>

<TextView
android:id="@+id/scheduleDateError"
android:id="@+id/errorMessage"
style="@style/Body.Error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
Expand Down

0 comments on commit d02b5e2

Please sign in to comment.