Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Make time picker dialog reusable #2174

Merged
merged 4 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
}
}

[email protected] = onDateSelected
[email protected] = onAbort

positiveButton.setOnClickListener {
[email protected]?.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))
}
}

[email protected] = onSchedule
[email protected] = onAbort

positiveButton.setOnClickListener {
[email protected]?.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
Loading