diff --git a/OldKotlin/realm-models/src/main/kotlin/com/infomaniak/mail/data/models/FeatureFlag.kt b/OldKotlin/realm-models/src/main/kotlin/com/infomaniak/mail/data/models/FeatureFlag.kt
index 5ee49d60be0..ccedcb96bd5 100644
--- a/OldKotlin/realm-models/src/main/kotlin/com/infomaniak/mail/data/models/FeatureFlag.kt
+++ b/OldKotlin/realm-models/src/main/kotlin/com/infomaniak/mail/data/models/FeatureFlag.kt
@@ -22,6 +22,7 @@ enum class FeatureFlag(val apiName: String) {
AI("ai-mail-composer"),
BIMI("bimi"),
ENCRYPTION("mail-compose-encrypted"),
+ RESPONSE_REQUIRED("mail-response-required-flag"),
SCHEDULE_DRAFTS("schedule-send-draft"),
SNOOZE("mail-snooze"),
EMOJI_REACTION("mail-emoji-reaction"),
diff --git a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleSendBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/RescheduleDraftBottomSheetDialog.kt
similarity index 72%
rename from app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleSendBottomSheetDialog.kt
rename to app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/RescheduleDraftBottomSheetDialog.kt
index 9718e302fe2..16ce17010c3 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleSendBottomSheetDialog.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/RescheduleDraftBottomSheetDialog.kt
@@ -23,16 +23,13 @@ import com.infomaniak.core.legacy.utils.setBackNavigationResult
import com.infomaniak.mail.MatomoMail.MatomoName
import com.infomaniak.mail.MatomoMail.trackScheduleSendEvent
import com.infomaniak.mail.R
-import com.infomaniak.mail.utils.openKSuiteProBottomSheet
-import com.infomaniak.mail.utils.openMailPremiumBottomSheet
-import com.infomaniak.mail.utils.openMyKSuiteUpgradeBottomSheet
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
-class ScheduleSendBottomSheetDialog @Inject constructor() : SelectScheduleOptionBottomSheet() {
+class RescheduleDraftBottomSheetDialog @Inject constructor() : SimpleSchedulePickerBottomSheet() {
- private val navigationArgs: ScheduleSendBottomSheetDialogArgs by navArgs()
+ private val navigationArgs: RescheduleDraftBottomSheetDialogArgs by navArgs()
override val currentKSuite: KSuite? by lazy { navigationArgs.currentKSuite }
@@ -56,17 +53,12 @@ class ScheduleSendBottomSheetDialog @Inject constructor() : SelectScheduleOption
}
override fun onCustomScheduleOptionClicked() {
- val kSuite = currentKSuite
- val matomoName = MatomoName.ScheduledCustomDate.value
- when (kSuite) {
- KSuite.Perso.Free -> openMyKSuiteUpgradeBottomSheet(matomoName)
- KSuite.Pro.Free -> openKSuiteProBottomSheet(kSuite, navigationArgs.isAdmin, matomoName)
- KSuite.StarterPack -> openMailPremiumBottomSheet(matomoName)
- else -> {
- trackScheduleSendEvent(MatomoName.CustomSchedule)
- setBackNavigationResult(OPEN_SCHEDULE_DRAFT_DATE_AND_TIME_PICKER, true)
- }
- }
+ handleCustomScheduleOptionClicked(
+ matomoName = MatomoName.ScheduledCustomDate.value,
+ backNavKey = OPEN_SCHEDULE_DRAFT_DATE_AND_TIME_PICKER,
+ isAdmin = navigationArgs.isAdmin,
+ onDefaultClicked = { trackScheduleSendEvent(MatomoName.CustomSchedule) }
+ )
}
companion object {
diff --git a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleOptionUtils.kt b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleOptionUtils.kt
new file mode 100644
index 00000000000..7182897e642
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleOptionUtils.kt
@@ -0,0 +1,169 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2025-2026 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 .
+ */
+package com.infomaniak.mail.ui.bottomSheetDialogs
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.IntRange
+import androidx.annotation.StringRes
+import com.infomaniak.core.common.utils.getNextMonday
+import com.infomaniak.core.common.utils.getTimeAtHour
+import com.infomaniak.core.common.utils.isAtLeastXMinutesInTheFuture
+import com.infomaniak.core.common.utils.isWeekend
+import com.infomaniak.core.common.utils.tomorrow
+import com.infomaniak.mail.MatomoMail.MatomoName
+import com.infomaniak.mail.R
+import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOptionUtils.HIDE_INTERVAL
+import com.infomaniak.mail.ui.newMessage.MIN_SELECTABLE_DATE_MINUTES
+import java.util.Calendar
+import java.util.Date
+import kotlin.time.Duration.Companion.minutes
+
+object ScheduleOptionUtils {
+ val HIDE_INTERVAL = 5.minutes // Beware: the API refuses schedules smaller than 5 minutes
+
+ fun getLastScheduleOptionDate(
+ lastSelectedEpoch: Long?,
+ currentlyScheduledEpochMillis: Long?,
+ ): Date? {
+ val lastSelectedDate = lastSelectedEpoch?.let { Date(it) }
+
+ return if (
+ lastSelectedDate?.isAtLeastXMinutesInTheFuture(MIN_SELECTABLE_DATE_MINUTES) == true &&
+ lastSelectedDate.isNotAlreadySelected(currentlyScheduledEpochMillis)
+ ) {
+ lastSelectedDate
+ } else {
+ null
+ }
+ }
+
+ fun getAvailableScheduleOptions(currentlyScheduledEpochMillis: Long?): List {
+ val currentTime = Date()
+ return WeekPeriod.getCurrent().scheduleOptions.filter { scheduleOption ->
+ scheduleOption.canBeDisplayedAt(currentTime) &&
+ scheduleOption.date().isNotAlreadySelected(currentlyScheduledEpochMillis)
+ }
+ }
+
+ private fun Date.isNotAlreadySelected(currentlyScheduledEpochMillis: Long?): Boolean {
+ return time.truncateToMinute() != currentlyScheduledEpochMillis?.truncateToMinute()
+ }
+
+ private fun Long.truncateToMinute(): Long {
+ return Calendar.getInstance().apply {
+ timeInMillis = this@truncateToMinute
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.time.time
+ }
+}
+
+enum class ScheduleOption(
+ private val day: RelativeDay,
+ private val hour: HourOfTheDay,
+ @StringRes val titleRes: Int,
+ @DrawableRes val iconRes: Int,
+ val matomoName: MatomoName,
+) {
+ LaterThisMorning(
+ day = RelativeDay.Today,
+ hour = HourOfTheDay.Morning,
+ titleRes = R.string.laterThisMorning,
+ iconRes = R.drawable.ic_morning_sunrise_schedule,
+ matomoName = MatomoName.LaterThisMorning,
+ ),
+ ThisAfternoon(
+ day = RelativeDay.Today,
+ hour = HourOfTheDay.Afternoon,
+ titleRes = R.string.thisAfternoon,
+ iconRes = R.drawable.ic_afternoon_schedule,
+ matomoName = MatomoName.ThisAfternoon,
+ ),
+ ThisEvening(
+ day = RelativeDay.Today,
+ hour = HourOfTheDay.Evening,
+ titleRes = R.string.thisEvening,
+ iconRes = R.drawable.ic_evening_schedule,
+ matomoName = MatomoName.ThisEvening,
+ ),
+ TomorrowMorning(
+ day = RelativeDay.Tomorrow,
+ hour = HourOfTheDay.Morning,
+ titleRes = R.string.tomorrowMorning,
+ iconRes = R.drawable.ic_morning_schedule,
+ matomoName = MatomoName.TomorrowMorning,
+ ),
+ NextMondayMorning(
+ day = RelativeDay.NextMonday,
+ hour = HourOfTheDay.Morning,
+ titleRes = R.string.nextMonday,
+ iconRes = R.drawable.ic_arrow_return,
+ matomoName = MatomoName.NextMonday,
+ ),
+ MondayMorning(
+ day = RelativeDay.NextMonday,
+ hour = HourOfTheDay.Morning,
+ titleRes = R.string.mondayMorning,
+ iconRes = R.drawable.ic_morning_schedule,
+ matomoName = MatomoName.NextMondayMorning,
+ ),
+ MondayAfternoon(
+ day = RelativeDay.NextMonday,
+ hour = HourOfTheDay.Afternoon,
+ titleRes = R.string.mondayAfternoon,
+ iconRes = R.drawable.ic_afternoon_schedule,
+ matomoName = MatomoName.NextMondayAfternoon,
+ );
+
+ fun date(): Date = day.getDate().getTimeAtHour(hour.hourOfTheDay)
+ fun canBeDisplayedAt(date: Date): Boolean = date.time < minimalDisplayTime()
+ private fun minimalDisplayTime() = date().time - HIDE_INTERVAL.inWholeMilliseconds
+}
+
+private enum class RelativeDay(val getDate: () -> Date) {
+ Today({ Date() }),
+ Tomorrow({ Date().tomorrow() }),
+ NextMonday({ Date().getNextMonday() }),
+}
+
+private enum class HourOfTheDay(@IntRange(0, 23) val hourOfTheDay: Int) {
+ Morning(8),
+ Afternoon(14),
+ Evening(18),
+}
+
+/**
+ * Represents a period inside the current week. In other words, a timeframe used to group relevant schedule options based on when
+ * they should be displayed.
+ *
+ * @param scheduleOptions The available schedule options that can be displayed to the user during each period
+ */
+private enum class WeekPeriod(vararg val scheduleOptions: ScheduleOption) {
+ Weekday(
+ ScheduleOption.LaterThisMorning,
+ ScheduleOption.ThisAfternoon,
+ ScheduleOption.ThisEvening,
+ ScheduleOption.TomorrowMorning,
+ ScheduleOption.NextMondayMorning,
+ ),
+ Weekend(ScheduleOption.MondayMorning, ScheduleOption.MondayAfternoon);
+
+ companion object {
+ fun getCurrent(): WeekPeriod = if (Date().isWeekend()) Weekend else Weekday
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SelectScheduleOptionBottomSheet.kt b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SelectScheduleOptionBottomSheet.kt
deleted file mode 100644
index 9e2c0befddf..00000000000
--- a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SelectScheduleOptionBottomSheet.kt
+++ /dev/null
@@ -1,209 +0,0 @@
-/*
- * Infomaniak Mail - Android
- * Copyright (C) 2025-2026 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 .
- */
-package com.infomaniak.mail.ui.bottomSheetDialogs
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.DrawableRes
-import androidx.annotation.IntRange
-import androidx.annotation.StringRes
-import androidx.core.view.children
-import androidx.core.view.isVisible
-import com.infomaniak.core.ksuite.data.KSuite
-import com.infomaniak.core.legacy.utils.context
-import com.infomaniak.core.legacy.utils.safeBinding
-import com.infomaniak.core.common.utils.getNextMonday
-import com.infomaniak.core.common.utils.getTimeAtHour
-import com.infomaniak.core.common.utils.isAtLeastXMinutesInTheFuture
-import com.infomaniak.core.common.utils.isWeekend
-import com.infomaniak.core.common.utils.tomorrow
-import com.infomaniak.mail.MatomoMail.MatomoName
-import com.infomaniak.mail.R
-import com.infomaniak.mail.databinding.BottomSheetScheduleOptionsBinding
-import com.infomaniak.mail.ui.alertDialogs.SelectDateAndTimeDialog.Companion.MIN_SELECTABLE_DATE_MINUTES
-import com.infomaniak.mail.ui.bottomSheetDialogs.HourOfTheDay.Afternoon
-import com.infomaniak.mail.ui.bottomSheetDialogs.HourOfTheDay.Evening
-import com.infomaniak.mail.ui.bottomSheetDialogs.HourOfTheDay.Morning
-import com.infomaniak.mail.ui.bottomSheetDialogs.RelativeDay.NextMonday
-import com.infomaniak.mail.ui.bottomSheetDialogs.RelativeDay.Today
-import com.infomaniak.mail.ui.bottomSheetDialogs.RelativeDay.Tomorrow
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption.LaterThisMorning
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption.MondayAfternoon
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption.MondayMorning
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption.NextMondayMorning
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption.ThisAfternoon
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption.ThisEvening
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption.TomorrowMorning
-import com.infomaniak.mail.ui.main.thread.actions.ActionItemView
-import com.infomaniak.mail.ui.main.thread.actions.ActionItemView.TrailingContent
-import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear
-import java.util.Calendar
-import java.util.Date
-import kotlin.time.Duration.Companion.minutes
-
-abstract class SelectScheduleOptionBottomSheet : EdgeToEdgeBottomSheetDialog() {
-
- private var binding: BottomSheetScheduleOptionsBinding by safeBinding()
-
- abstract val lastSelectedEpoch: Long?
- abstract val currentlyScheduledEpochMillis: Long?
- abstract val currentKSuite: KSuite?
-
- @get:StringRes
- abstract val titleRes: Int
-
- abstract fun onLastScheduleOptionClicked()
- abstract fun onScheduleOptionClicked(dateItem: ScheduleOption)
- abstract fun onCustomScheduleOptionClicked()
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- return BottomSheetScheduleOptionsBinding.inflate(inflater, container, false).also { binding = it }.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?): Unit = with(binding) {
- super.onViewCreated(view, savedInstanceState)
-
- title.text = getString(titleRes)
-
- computeLastScheduleOption()
-
- setLastScheduleOptionClickListener()
- createCommonScheduleOptions()
- setCustomScheduleOptionClickListener()
-
- val shouldDisplayDivider = lastScheduleOption.isVisible
- (scheduleOptions.children.first() as ActionItemView).setDividerVisibility(shouldDisplayDivider)
-
- customScheduleOption.trailingContent = when (currentKSuite) {
- KSuite.Perso.Free -> TrailingContent.KSuitePersoChip
- KSuite.Pro.Free, KSuite.StarterPack -> TrailingContent.KSuiteProChip
- else -> TrailingContent.Chevron
- }
- }
-
- private fun computeLastScheduleOption() = with(binding) {
- val lastSelectedDate = lastSelectedEpoch?.let { Date(it) }
-
- if (lastSelectedDate?.isAtLeastXMinutesInTheFuture(MIN_SELECTABLE_DATE_MINUTES) == true && lastSelectedDate.isNotAlreadySelected()) {
- lastScheduleOption.isVisible = true
- lastScheduleOption.setDescription(context.dayOfWeekDateWithoutYear(date = lastSelectedDate))
- }
- }
-
- private fun setLastScheduleOptionClickListener() {
- binding.lastScheduleOption.setOnClickListener { onLastScheduleOptionClicked() }
- }
-
- private fun createCommonScheduleOptions() {
- val currentTime = Date()
- WeekPeriod.getCurrent().scheduleOptions.forEach { scheduleOption ->
- if (scheduleOption.canBeDisplayedAt(currentTime) && scheduleOption.isNotAlreadySelected()) {
- binding.scheduleOptions.addView(createScheduleOptionItem(scheduleOption))
- }
- }
- }
-
- private fun createScheduleOptionItem(scheduleOption: ScheduleOption): ActionItemView = ActionItemView(binding.context).apply {
- setTitle(scheduleOption.titleRes)
- setDescription(context.dayOfWeekDateWithoutYear(date = scheduleOption.date()))
- setIconResource(scheduleOption.iconRes)
- setOnClickListener { onScheduleOptionClicked(scheduleOption) }
- }
-
- private fun setCustomScheduleOptionClickListener() {
- binding.customScheduleOption.setOnClickListener { onCustomScheduleOptionClicked() }
- }
-
- private fun Date.isNotAlreadySelected(): Boolean {
- return time.truncateToMinute() != currentlyScheduledEpochMillis?.truncateToMinute()
- }
-
- private fun Long.truncateToMinute(): Long {
- return Calendar.getInstance().apply {
- timeInMillis = this@truncateToMinute
- set(Calendar.SECOND, 0)
- set(Calendar.MILLISECOND, 0)
- }.time.time
- }
-
- private fun ScheduleOption.isNotAlreadySelected() = date().isNotAlreadySelected()
-}
-
-private val HIDE_INTERVAL = 5.minutes // Beware: the API refuses schedules smaller than 5 minutes
-
-enum class ScheduleOption(
- private val day: RelativeDay,
- private val hour: HourOfTheDay,
- @StringRes val titleRes: Int,
- @DrawableRes val iconRes: Int,
- val matomoName: MatomoName,
-) {
- LaterThisMorning(
- Today,
- Morning,
- R.string.laterThisMorning,
- R.drawable.ic_morning_sunrise_schedule,
- MatomoName.LaterThisMorning
- ),
- ThisAfternoon(Today, Afternoon, R.string.thisAfternoon, R.drawable.ic_afternoon_schedule, MatomoName.ThisAfternoon),
- ThisEvening(Today, Evening, R.string.thisEvening, R.drawable.ic_evening_schedule, MatomoName.ThisEvening),
- TomorrowMorning(Tomorrow, Morning, R.string.tomorrowMorning, R.drawable.ic_morning_schedule, MatomoName.TomorrowMorning),
- NextMondayMorning(NextMonday, Morning, R.string.nextMonday, R.drawable.ic_arrow_return, MatomoName.NextMonday),
-
- MondayMorning(NextMonday, Morning, R.string.mondayMorning, R.drawable.ic_morning_schedule, MatomoName.NextMondayMorning),
- MondayAfternoon(
- NextMonday,
- Afternoon,
- R.string.mondayAfternoon,
- R.drawable.ic_afternoon_schedule,
- MatomoName.NextMondayAfternoon
- );
-
- fun date(): Date = day.getDate().getTimeAtHour(hour.hourOfTheDay)
- fun canBeDisplayedAt(date: Date): Boolean = date.time < minimalDisplayTime()
- private fun minimalDisplayTime() = date().time - HIDE_INTERVAL.inWholeMilliseconds
-}
-
-private enum class RelativeDay(val getDate: () -> Date) {
- Today({ Date() }),
- Tomorrow({ Date().tomorrow() }),
- NextMonday({ Date().getNextMonday() }),
-}
-
-private enum class HourOfTheDay(@IntRange(0, 23) val hourOfTheDay: Int) {
- Morning(8),
- Afternoon(14),
- Evening(18),
-}
-
-/**
- * Represents a period inside the current week. In other words, a timeframe used to group relevant schedule options based on when
- * they should be displayed.
- *
- * @param scheduleOptions The available schedule options that can be displayed to the user during each period
- */
-private enum class WeekPeriod(vararg val scheduleOptions: ScheduleOption) {
- Weekday(LaterThisMorning, ThisAfternoon, ThisEvening, TomorrowMorning, NextMondayMorning),
- Weekend(MondayMorning, MondayAfternoon);
-
- companion object {
- fun getCurrent(): WeekPeriod = if (Date().isWeekend()) Weekend else Weekday
- }
-}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SimpleSchedulePickerBottomSheet.kt b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SimpleSchedulePickerBottomSheet.kt
new file mode 100644
index 00000000000..97fd6264a58
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SimpleSchedulePickerBottomSheet.kt
@@ -0,0 +1,118 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 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 .
+ */
+package com.infomaniak.mail.ui.bottomSheetDialogs
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.StringRes
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import com.infomaniak.core.ksuite.data.KSuite
+import com.infomaniak.core.legacy.utils.safeBinding
+import com.infomaniak.core.legacy.utils.setBackNavigationResult
+import com.infomaniak.mail.databinding.BottomSheetScheduleOptionsBinding
+import com.infomaniak.mail.ui.main.thread.actions.ActionItemView
+import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear
+import com.infomaniak.mail.utils.openKSuiteProBottomSheet
+import com.infomaniak.mail.utils.openMailPremiumBottomSheet
+import com.infomaniak.mail.utils.openMyKSuiteUpgradeBottomSheet
+
+abstract class SimpleSchedulePickerBottomSheet : EdgeToEdgeBottomSheetDialog() {
+
+ private var binding: BottomSheetScheduleOptionsBinding by safeBinding()
+
+ @get:StringRes
+ abstract val titleRes: Int
+
+ abstract val lastSelectedEpoch: Long?
+ abstract val currentlyScheduledEpochMillis: Long?
+ abstract val currentKSuite: KSuite?
+
+ abstract fun onLastScheduleOptionClicked()
+ abstract fun onScheduleOptionClicked(dateItem: ScheduleOption)
+ abstract fun onCustomScheduleOptionClicked()
+
+ protected fun handleCustomScheduleOptionClicked(
+ matomoName: String,
+ backNavKey: String,
+ isAdmin: Boolean,
+ onDefaultClicked: () -> Unit = {}
+ ) {
+ when (val kSuite = currentKSuite) {
+ KSuite.Perso.Free -> openMyKSuiteUpgradeBottomSheet(matomoName)
+ KSuite.Pro.Free -> openKSuiteProBottomSheet(kSuite, isAdmin, matomoName)
+ KSuite.StarterPack -> openMailPremiumBottomSheet(matomoName)
+ else -> {
+ onDefaultClicked()
+ setBackNavigationResult(backNavKey, true)
+ }
+ }
+ }
+
+ protected open fun createScheduleOptionItem(scheduleOption: ScheduleOption): View {
+ return ActionItemView(requireContext()).apply {
+ setTitle(scheduleOption.titleRes)
+ setDescription(context.dayOfWeekDateWithoutYear(date = scheduleOption.date()))
+ setIconResource(scheduleOption.iconRes)
+ setOnClickListener { onScheduleOptionClicked(scheduleOption) }
+ }
+ }
+
+ protected open fun bindLastScheduleOptionDescription(description: String) {
+ binding.lastScheduleOption.setDescription(description)
+ }
+
+ protected open fun setupFirstScheduleOptionDivider(firstItem: View, shouldDisplayDivider: Boolean) {
+ (firstItem as? ActionItemView)?.setDividerVisibility(shouldDisplayDivider)
+ }
+
+ protected fun setupScheduleOptions() = with(binding) {
+ val lastDate = ScheduleOptionUtils.getLastScheduleOptionDate(lastSelectedEpoch, currentlyScheduledEpochMillis)
+ if (lastDate != null) {
+ lastScheduleOption.isVisible = true
+ bindLastScheduleOptionDescription(requireContext().dayOfWeekDateWithoutYear(lastDate))
+ lastScheduleOption.setOnClickListener { onLastScheduleOptionClicked() }
+ } else {
+ lastScheduleOption.isVisible = false
+ }
+
+ ScheduleOptionUtils.getAvailableScheduleOptions(currentlyScheduledEpochMillis).forEach { scheduleOption ->
+ scheduleOptions.addView(createScheduleOptionItem(scheduleOption))
+ }
+
+ customScheduleOption.setOnClickListener { onCustomScheduleOptionClicked() }
+
+ val shouldDisplayDivider = lastScheduleOption.isVisible
+ scheduleOptions.children.firstOrNull()?.let { firstItem ->
+ setupFirstScheduleOptionDivider(firstItem, shouldDisplayDivider)
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ return BottomSheetScheduleOptionsBinding.inflate(inflater, container, false).also { binding = it }.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.title.text = getString(titleRes)
+ setupScheduleOptions()
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SnoozeBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SnoozeBottomSheetDialog.kt
index 4262deb8a0b..0d52f572113 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SnoozeBottomSheetDialog.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/SnoozeBottomSheetDialog.kt
@@ -23,14 +23,11 @@ import com.infomaniak.core.legacy.utils.setBackNavigationResult
import com.infomaniak.mail.MatomoMail.MatomoName
import com.infomaniak.mail.MatomoMail.trackSnoozeEvent
import com.infomaniak.mail.R
-import com.infomaniak.mail.utils.openKSuiteProBottomSheet
-import com.infomaniak.mail.utils.openMailPremiumBottomSheet
-import com.infomaniak.mail.utils.openMyKSuiteUpgradeBottomSheet
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
-class SnoozeBottomSheetDialog @Inject constructor() : SelectScheduleOptionBottomSheet() {
+class SnoozeBottomSheetDialog @Inject constructor() : SimpleSchedulePickerBottomSheet() {
private val navigationArgs: SnoozeBottomSheetDialogArgs by navArgs()
@@ -56,17 +53,12 @@ class SnoozeBottomSheetDialog @Inject constructor() : SelectScheduleOptionBottom
}
override fun onCustomScheduleOptionClicked() {
- val kSuite = currentKSuite
- val matomoName = MatomoName.SnoozeCustomDate.value
- when (kSuite) {
- KSuite.Perso.Free -> openMyKSuiteUpgradeBottomSheet(matomoName)
- KSuite.Pro.Free -> openKSuiteProBottomSheet(kSuite, navigationArgs.isAdmin, matomoName)
- KSuite.StarterPack -> openMailPremiumBottomSheet(matomoName)
- else -> {
- trackSnoozeEvent(MatomoName.CustomSchedule)
- setBackNavigationResult(OPEN_SNOOZE_DATE_AND_TIME_PICKER, true)
- }
- }
+ handleCustomScheduleOptionClicked(
+ matomoName = MatomoName.SnoozeCustomDate.value,
+ backNavKey = OPEN_SNOOZE_DATE_AND_TIME_PICKER,
+ isAdmin = navigationArgs.isAdmin,
+ onDefaultClicked = { trackSnoozeEvent(MatomoName.CustomSchedule) }
+ )
}
companion object {
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/settings/ItemSettingView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/settings/ItemSettingView.kt
index f8586300ea4..ff8ab5264f7 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/settings/ItemSettingView.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/settings/ItemSettingView.kt
@@ -58,6 +58,10 @@ class ItemSettingView @JvmOverloads constructor(
icon.isGone = it == null
}
+ getColorStateList(R.styleable.ItemSettingView_iconColor)?.let { color ->
+ icon.imageTintList = color
+ }
+
getString(R.styleable.ItemSettingView_subtitle).let {
subtitle.apply {
text = it
@@ -101,6 +105,14 @@ class ItemSettingView @JvmOverloads constructor(
}
}
+ fun removeSubtitle() {
+ binding.subtitle.isGone = true
+ }
+
+ fun setCheckMark(displayCheckMark: Boolean) {
+ binding.checkMark.isVisible = displayCheckMark
+ }
+
fun toggleMailboxBlockedState(mustBlock: Boolean) = with(binding) {
warning.isVisible = mustBlock
chevron.isGone = mustBlock
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioButtonView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioButtonView.kt
index da94ab1163c..a617719aaf1 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioButtonView.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioButtonView.kt
@@ -57,12 +57,12 @@ class SettingRadioButtonView @JvmOverloads constructor(
setIcon(iconDrawable)
text.text = textString
checkMark.setColorFilter(checkMarkColor)
-
- root.setOnClickListener {
- (parent as? OnCheckListener)?.onChecked(this@SettingRadioButtonView.id) ?: onClickListener?.onClick(root)
- }
}
}
+
+ binding.root.setOnClickListener {
+ (parent as? OnCheckListener)?.onChecked(id) ?: onClickListener?.onClick(binding.root)
+ }
}
override fun check() = with(binding) {
@@ -80,6 +80,11 @@ class SettingRadioButtonView @JvmOverloads constructor(
binding.text.text = newText
}
+ fun setDescription(newDescription: String) {
+ binding.description.isVisible = newDescription.isNotBlank()
+ if (newDescription.isNotBlank()) binding.description.text = newDescription
+ }
+
fun setCheckMarkColor(@ColorInt color: Int) {
binding.checkMark.setColorFilter(color)
}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioGroupView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioGroupView.kt
index 4bdee44da5d..987744607e3 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioGroupView.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/settings/SettingRadioGroupView.kt
@@ -97,4 +97,12 @@ class SettingRadioGroupView @JvmOverloads constructor(
fun onItemCheckedListener(listener: ((id: Int, value: String?, enum: Enum<*>?) -> Unit)?) {
onItemCheckedListener = listener
}
+
+ fun clearCheck() {
+ if (checkedId != View.NO_ID) {
+ (findViewById(checkedId) as? RadioCheckable)?.uncheck()
+ checkedId = View.NO_ID
+ checkedValue = null
+ }
+ }
}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt
index 60ff0c67cac..86b2f50cc79 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt
@@ -87,9 +87,9 @@ import com.infomaniak.mail.ui.alertDialogs.LinkContextualMenuAlertDialog
import com.infomaniak.mail.ui.alertDialogs.PhoneContextualMenuAlertDialog
import com.infomaniak.mail.ui.alertDialogs.SelectDateAndTimeForScheduledDraftDialog
import com.infomaniak.mail.ui.alertDialogs.SelectDateAndTimeForSnoozeDialog
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialog.Companion.OPEN_SCHEDULE_DRAFT_DATE_AND_TIME_PICKER
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialog.Companion.SCHEDULE_DRAFT_RESULT
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialogArgs
+import com.infomaniak.mail.ui.bottomSheetDialogs.RescheduleDraftBottomSheetDialog.Companion.OPEN_SCHEDULE_DRAFT_DATE_AND_TIME_PICKER
+import com.infomaniak.mail.ui.bottomSheetDialogs.RescheduleDraftBottomSheetDialog.Companion.SCHEDULE_DRAFT_RESULT
+import com.infomaniak.mail.ui.bottomSheetDialogs.RescheduleDraftBottomSheetDialogArgs
import com.infomaniak.mail.ui.bottomSheetDialogs.SnoozeBottomSheetDialog.Companion.OPEN_SNOOZE_DATE_AND_TIME_PICKER
import com.infomaniak.mail.ui.bottomSheetDialogs.SnoozeBottomSheetDialog.Companion.SNOOZE_RESULT
import com.infomaniak.mail.ui.main.SnackbarManager
@@ -1248,7 +1248,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver {
val mailbox = mainViewModel.currentMailbox.value ?: return
safeNavigate(
resId = R.id.scheduleSendBottomSheetDialog,
- args = ScheduleSendBottomSheetDialogArgs(
+ args = RescheduleDraftBottomSheetDialogArgs(
lastSelectedScheduleEpochMillis = localSettings.lastSelectedScheduleEpochMillis ?: 0L,
currentlyScheduledEpochMillis = threadViewModel.reschedulingCurrentlyScheduledEpochMillis ?: 0L,
currentKSuite = mailbox.kSuite,
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt
index 5dfd4f9b3a0..3280d562409 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt
@@ -44,6 +44,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.infomaniak.core.common.observe
+import com.infomaniak.core.common.utils.FORMAT_DATE_DAY_FULL_MONTH_YEAR_WITH_TIME
+import com.infomaniak.core.common.utils.format
import com.infomaniak.core.fragmentnavigation.safelyNavigate
import com.infomaniak.core.ksuite.data.KSuite
import com.infomaniak.core.ksuite.ui.utils.MatomoKSuite
@@ -82,9 +84,8 @@ import com.infomaniak.mail.ui.MainActivity
import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog
import com.infomaniak.mail.ui.alertDialogs.InformationAlertDialog
import com.infomaniak.mail.ui.alertDialogs.SelectDateAndTimeForScheduledDraftDialog
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialog.Companion.OPEN_SCHEDULE_DRAFT_DATE_AND_TIME_PICKER
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialog.Companion.SCHEDULE_DRAFT_RESULT
-import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialogArgs
+import com.infomaniak.mail.ui.bottomSheetDialogs.RescheduleDraftBottomSheetDialog.Companion.OPEN_SCHEDULE_DRAFT_DATE_AND_TIME_PICKER
+import com.infomaniak.mail.ui.bottomSheetDialogs.RescheduleDraftBottomSheetDialog.Companion.SCHEDULE_DRAFT_RESULT
import com.infomaniak.mail.ui.main.SnackbarManager
import com.infomaniak.mail.ui.main.thread.AttachmentAdapter
import com.infomaniak.mail.ui.newMessage.NewMessageRecipientFieldsManager.FieldType
@@ -92,6 +93,7 @@ import com.infomaniak.mail.ui.newMessage.NewMessageViewModel.ImportationResult
import com.infomaniak.mail.ui.newMessage.NewMessageViewModel.UiFrom
import com.infomaniak.mail.ui.newMessage.encryption.EncryptionMessageManager
import com.infomaniak.mail.ui.newMessage.encryption.EncryptionViewModel
+import com.infomaniak.mail.ui.newMessage.sendOptions.DraftSendOptionsFragmentArgs
import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCommonMentionsCodeScript
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomEditorStyle
@@ -117,6 +119,7 @@ import com.infomaniak.mail.utils.SentryDebug
import com.infomaniak.mail.utils.SignatureUtils
import com.infomaniak.mail.utils.WebViewUtils.Companion.evaluateJs
import com.infomaniak.mail.utils.WebViewUtils.Companion.setupNewMessageWebViewSettings
+import com.infomaniak.mail.utils.date.DateFormatUtils.formatDelayText
import com.infomaniak.mail.utils.extensions.AttachmentExt
import com.infomaniak.mail.utils.extensions.AttachmentExt.openAttachment
import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets
@@ -145,6 +148,7 @@ import kotlinx.coroutines.launch
import splitties.experimental.ExperimentalSplittiesApi
import java.util.Date
import javax.inject.Inject
+import kotlin.time.Duration.Companion.minutes
@AndroidEntryPoint
class NewMessageFragment : Fragment() {
@@ -299,7 +303,9 @@ class NewMessageFragment : Fragment() {
observeCcAndBccVisibility()
}
- observeScheduledDraftsFeatureFlagUpdates()
+ observeFeatureFlagUpdates()
+ observeSchedule()
+ observeReminder()
}
private fun handleEdgeToEdge() = with(binding) {
@@ -320,7 +326,7 @@ class NewMessageFragment : Fragment() {
private fun setupBackActionHandler() {
fun scheduleDraft(timestamp: Long) {
- newMessageViewModel.setScheduleDate(Date(timestamp))
+ newMessageViewModel.setScheduleConfig(ScheduleConfig.Scheduled(timestamp))
tryToSendEmail(isScheduled = true)
}
@@ -453,6 +459,16 @@ class NewMessageFragment : Fragment() {
},
)
+ scheduleAlert.apply {
+ onAction1 { navigateToScheduleSendBottomSheet() }
+ onAction2 { newMessageViewModel.setScheduleConfig(ScheduleConfig.None) }
+ }
+
+ reminderAlert.apply {
+ onAction1 { navigateToScheduleSendBottomSheet() }
+ onAction2 { newMessageViewModel.setReminderConfig(ReminderConfig.None) }
+ }
+
recipientFieldsManager.setupAutoCompletionFields()
subjectTextField.filters = arrayOf(object : InputFilter {
@@ -478,6 +494,45 @@ class NewMessageFragment : Fragment() {
}
}
+ private fun observeSchedule() {
+ newMessageViewModel.scheduleConfig.observe(viewLifecycleOwner) { config ->
+ when (config) {
+ is ScheduleConfig.Scheduled -> {
+ val date = Date(config.epochMillis).format(FORMAT_DATE_DAY_FULL_MONTH_YEAR_WITH_TIME)
+ binding.scheduleAlert.apply {
+ setDescription(getString(R.string.scheduledEmailHeader, date))
+ isVisible = true
+ }
+ binding.divider7.isVisible = true
+ }
+ ScheduleConfig.None -> {
+ binding.scheduleAlert.isVisible = false
+ binding.divider7.isVisible = false
+ }
+ }
+ }
+ }
+
+ private fun observeReminder() {
+ newMessageViewModel.reminderConfig.observe(viewLifecycleOwner) { config ->
+ when (config) {
+ is ReminderConfig.Delayed -> {
+ val dateText = requireContext().formatDelayText(config.delayMinutes)
+
+ binding.reminderAlert.apply {
+ setDescription(getString(R.string.callIfNoResponseHeaderTitle, dateText))
+ isVisible = true
+ }
+ binding.divider6.isVisible = true
+ }
+ is ReminderConfig.None -> {
+ binding.reminderAlert.isVisible = false
+ binding.divider6.isVisible = false
+ }
+ }
+ }
+ }
+
private fun initEditorUi() = with(binding) {
editorWebView.settings.setupNewMessageWebViewSettings()
editorWebView.initEditorWebviewBridge(
@@ -831,10 +886,11 @@ class NewMessageFragment : Fragment() {
newMessageViewModel.isShimmering.collect(::setShimmerVisibility)
}
- private fun observeScheduledDraftsFeatureFlagUpdates() {
+ private fun observeFeatureFlagUpdates() {
newMessageViewModel.featureFlagsLive.observe(viewLifecycleOwner) { featureFlags ->
val isScheduledDraftsEnabled = featureFlags.contains(FeatureFlag.SCHEDULE_DRAFTS)
- binding.scheduleButton.isVisible = isScheduledDraftsEnabled
+ val isRemindersEnabled = featureFlags.contains(FeatureFlag.RESPONSE_REQUIRED)
+ binding.sendOptionsButton.isVisible = isScheduledDraftsEnabled || isRemindersEnabled
val areMentionsAvailable = featureFlags.contains(FeatureFlag.MENTIONS)
@@ -920,28 +976,46 @@ class NewMessageFragment : Fragment() {
private fun setupSendButtons(mailbox: Mailbox) = with(binding) {
newMessageViewModel.isSendingAllowed.observe(viewLifecycleOwner) {
- scheduleButton.isEnabled = it
sendButton.isEnabled = it
}
- scheduleButton.setOnClickListener {
+ sendOptionsButton.setOnClickListener {
if (checkMailboxStorage(mailbox)) {
if (newMessageViewModel.isEncryptionActivated.value == true) {
- snackbarManager.postValue(getString(R.string.encryptedMessageSnackbarScheduledUnavailable))
+ snackbarManager.postValue(getString(R.string.encryptedMessageSnackbarScheduledReminderUnavailable))
} else {
navigateToScheduleSendBottomSheet()
}
}
}
- sendButton.setOnClickListener { if (checkMailboxStorage(mailbox)) tryToSendEmail() }
+ onSendButtonClicked(mailbox)
+ }
+
+ private fun onSendButtonClicked(mailbox: Mailbox) {
+ binding.sendButton.setOnClickListener {
+ if (!checkMailboxStorage(mailbox)) return@setOnClickListener
+
+ val isSendingWithScheduled = isMessageWithSchedule()
+ if (!isSendingWithScheduled) newMessageViewModel.setScheduleConfig(ScheduleConfig.None)
+ tryToSendEmail(isSendingWithScheduled)
+ }
+ }
+
+ private fun isMessageWithSchedule(): Boolean {
+ val scheduleConfig = newMessageViewModel.scheduleConfig.value
+ return if (scheduleConfig is ScheduleConfig.Scheduled) {
+ scheduleConfig.epochMillis - MIN_SELECTABLE_DATE_MINUTES.minutes.inWholeMilliseconds >= System.currentTimeMillis()
+ } else {
+ false
+ }
}
private fun navigateToScheduleSendBottomSheet(): Job = viewLifecycleOwner.lifecycleScope.launch {
val mailbox = newMessageViewModel.currentMailbox()
safelyNavigate(
- resId = R.id.scheduleSendBottomSheetDialog,
- args = ScheduleSendBottomSheetDialogArgs(
+ resId = R.id.sendOptionsFragment,
+ args = DraftSendOptionsFragmentArgs(
lastSelectedScheduleEpochMillis = localSettings.lastSelectedScheduleEpochMillis ?: 0L,
currentKSuite = mailbox.kSuite,
isAdmin = mailbox.isAdmin,
@@ -955,7 +1029,7 @@ class NewMessageFragment : Fragment() {
val resultIntent = Intent()
resultIntent.putExtra(
MainActivity.DRAFT_ACTION_KEY,
- if (isScheduled) DraftAction.SCHEDULE.name else DraftAction.SEND.name,
+ if (isScheduled) DraftAction.SCHEDULE.name else DraftAction.SEND.name
)
requireActivity().setResult(AppCompatActivity.RESULT_OK, resultIntent)
}
@@ -1000,7 +1074,7 @@ class NewMessageFragment : Fragment() {
trackNewMessageEvent(trackConfirmEvent)
hasConfirmed = true
},
- onCancel = { if (isScheduled) newMessageViewModel.resetScheduledDate() },
+ onCancel = { if (isScheduled) newMessageViewModel.setScheduleConfig(ScheduleConfig.None) },
onDismiss = { isSendingCanceled.complete(!hasConfirmed) },
)
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt
index feffa90df38..b8468d74d09 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt
@@ -256,6 +256,12 @@ class NewMessageViewModel @Inject constructor(
private val _currentMentions = MutableStateFlow>(emptyList())
val currentMentions: StateFlow> = _currentMentions.asStateFlow()
+ private val _scheduleConfig = MutableStateFlow(ScheduleConfig.None)
+ val scheduleConfig: StateFlow = _scheduleConfig.asStateFlow()
+
+ private val _reminderConfig = MutableStateFlow(ReminderConfig.None)
+ val reminderConfig: StateFlow = _reminderConfig.asStateFlow()
+
//region Check mailbox existence
private val exitSignal: CompletableJob = Job()
@@ -1233,6 +1239,14 @@ class NewMessageViewModel @Inject constructor(
}
+ fun setScheduleConfig(config: ScheduleConfig) {
+ _scheduleConfig.value = config
+ }
+
+ fun setReminderConfig(config: ReminderConfig) {
+ _reminderConfig.value = config
+ }
+
enum class ImportationResult {
SUCCESS,
ATTACHMENTS_TOO_BIG,
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/SendOptionsConfig.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/SendOptionsConfig.kt
new file mode 100644
index 00000000000..c6c59c13c81
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/SendOptionsConfig.kt
@@ -0,0 +1,39 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 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 .
+ */
+package com.infomaniak.mail.ui.newMessage
+
+import com.infomaniak.mail.R
+import com.infomaniak.mail.utils.date.DateFormatUtils.MINUTES_IN_A_DAY
+
+sealed class ScheduleConfig {
+ data object None : ScheduleConfig()
+ data class Scheduled(val epochMillis: Long, val isCustom: Boolean = false) : ScheduleConfig()
+}
+
+sealed class ReminderConfig {
+ data object None : ReminderConfig()
+ data class Delayed(val delayMinutes: Int, val isCustom: Boolean = false) : ReminderConfig()
+}
+
+enum class ReminderPreset(val titleRes: Int, val delayMinutes: Int) {
+ HOURS_24(R.plurals.hoursBeforeSendingReminder, MINUTES_IN_A_DAY),
+ DAYS_3(R.plurals.daysBeforeSendingReminder, 3 * MINUTES_IN_A_DAY),
+ DAYS_7(R.plurals.daysBeforeSendingReminder, 7 * MINUTES_IN_A_DAY);
+}
+
+const val MIN_SELECTABLE_DATE_MINUTES = 5
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/sendOptions/DraftSendOptionsFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/sendOptions/DraftSendOptionsFragment.kt
new file mode 100644
index 00000000000..bf8da27ddd8
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/sendOptions/DraftSendOptionsFragment.kt
@@ -0,0 +1,362 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 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 .
+ */
+
+package com.infomaniak.mail.ui.newMessage.sendOptions
+
+import android.os.Bundle
+import android.transition.TransitionManager
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.navArgs
+import com.infomaniak.core.common.observe
+import com.infomaniak.core.ksuite.data.KSuite
+import com.infomaniak.core.legacy.utils.safeBinding
+import com.infomaniak.mail.MatomoMail.MatomoName
+import com.infomaniak.mail.MatomoMail.trackScheduleSendEvent
+import com.infomaniak.mail.R
+import com.infomaniak.mail.data.LocalSettings
+import com.infomaniak.mail.data.models.FeatureFlag
+import com.infomaniak.mail.databinding.FragmentSendOptionsBinding
+import com.infomaniak.mail.ui.alertDialogs.SelectDateAndTimeForScheduledDraftDialog
+import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOption
+import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleOptionUtils
+import com.infomaniak.mail.ui.main.settings.SettingRadioButtonView
+import com.infomaniak.mail.ui.newMessage.NewMessageViewModel
+import com.infomaniak.mail.ui.newMessage.ReminderConfig
+import com.infomaniak.mail.ui.newMessage.ReminderPreset
+import com.infomaniak.mail.ui.newMessage.ScheduleConfig
+import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear
+import com.infomaniak.mail.utils.date.DateFormatUtils.formatDelayText
+import com.infomaniak.mail.utils.extensions.applyContentPaddingStart
+import com.infomaniak.mail.utils.openKSuiteProBottomSheet
+import com.infomaniak.mail.utils.openMailPremiumBottomSheet
+import com.infomaniak.mail.utils.openMyKSuiteUpgradeBottomSheet
+import dagger.hilt.android.AndroidEntryPoint
+import java.util.Date
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class DraftSendOptionsFragment : Fragment() {
+
+ private var binding: FragmentSendOptionsBinding by safeBinding()
+ private val newMessageViewModel: NewMessageViewModel by activityViewModels()
+ private val navigationArgs: DraftSendOptionsFragmentArgs by navArgs()
+
+ @Inject
+ lateinit var dateAndTimeScheduleDialog: SelectDateAndTimeForScheduledDraftDialog
+
+ @Inject
+ lateinit var localSettings: LocalSettings
+
+ private val currentKSuite: KSuite? by lazy { navigationArgs.currentKSuite }
+ private val lastSelectedEpoch: Long? by lazy { navigationArgs.lastSelectedScheduleEpochMillis.takeIf { it != 0L } }
+ private val currentlyScheduledEpochMillis: Long? by lazy {
+ navigationArgs.currentlyScheduledEpochMillis.takeIf { it != 0L }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ return FragmentSendOptionsBinding.inflate(inflater, container, false).also { binding = it }.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(binding) {
+ dateAndTimeScheduleDialog.bindAlertToLifecycle(viewLifecycleOwner)
+
+ setupScheduleOptions()
+ lastScheduleOption.associatedValue = lastSelectedEpoch?.toString()
+
+ setReminderOptionsVisible(isVisible = false)
+ setScheduleOptionsVisible(isVisible = false)
+
+ setupToggles()
+ setupScheduleSelection()
+ setupReminderOptions()
+
+ observeFeatureFlagUpdates()
+ observeScheduleConfig()
+ observeReminderConfig()
+ }
+
+ private fun observeFeatureFlagUpdates() = with(binding) {
+ newMessageViewModel.featureFlagsLive.observe(viewLifecycleOwner) { featureFlags ->
+ val isRemindersEnabled = featureFlags?.contains(FeatureFlag.RESPONSE_REQUIRED) ?: false
+ reminderIfNoAnswer.isVisible = isRemindersEnabled
+ val isScheduledDraftsEnabled = featureFlags?.contains(FeatureFlag.SCHEDULE_DRAFTS) ?: false
+ scheduleSending.isVisible = isScheduledDraftsEnabled
+ dividerBottomReminderOptions.isVisible = isRemindersEnabled && isScheduledDraftsEnabled
+ }
+ }
+
+ private fun createScheduleOptionItem(scheduleOption: ScheduleOption): View {
+ return SettingRadioButtonView(requireContext()).apply {
+ id = View.generateViewId()
+ associatedValue = scheduleOption.date().time.toString()
+ setText(getString(scheduleOption.titleRes))
+ setDescription(context.dayOfWeekDateWithoutYear(date = scheduleOption.date()))
+ }
+ }
+
+ private fun bindLastScheduleOptionDescription(description: String) = binding.lastScheduleOption.setDescription(description)
+
+ private fun onLastScheduleOptionClicked() {
+ newMessageViewModel.setScheduleConfig(lastSelectedEpoch?.let(ScheduleConfig::Scheduled) ?: ScheduleConfig.None)
+ }
+
+ private fun onCustomScheduleOptionClicked() = executeIfAuthorized { showCustomScheduleDatePicker() }
+
+ private fun setupScheduleOptions() = with(binding) {
+ val lastDate = ScheduleOptionUtils.getLastScheduleOptionDate(lastSelectedEpoch, currentlyScheduledEpochMillis)
+ lastScheduleOption.apply {
+ if (lastDate != null) {
+ isVisible = true
+ setDescription(requireContext().dayOfWeekDateWithoutYear(lastDate))
+ setOnClickListener { onLastScheduleOptionClicked() }
+ } else {
+ isVisible = false
+ }
+ }
+
+ ScheduleOptionUtils.getAvailableScheduleOptions(currentlyScheduledEpochMillis).forEach { scheduleOption ->
+ scheduleOptions.addView(createScheduleOptionItem(scheduleOption))
+ }
+
+ customScheduleOption.setOnClickListener { onCustomScheduleOptionClicked() }
+ }
+
+ private fun setupToggles() = with(binding) {
+ reminderIfNoAnswer.setOnClickListener {
+ if (!reminderIfNoAnswer.isChecked) {
+ removeReminderOptionsSelection()
+ } else {
+ defaultReminderSelection()
+ }
+ }
+ scheduleSending.setOnClickListener {
+ if (!scheduleSending.isChecked) {
+ removeScheduleOptionsSelection()
+ } else {
+ defaultScheduleSelection()
+ }
+ }
+ }
+
+ private fun setupScheduleSelection() = with(binding) {
+ scheduleOptions.onItemCheckedListener { _, value, _ ->
+ val epoch = value?.toLongOrNull()
+ newMessageViewModel.setScheduleConfig(if (epoch != null) ScheduleConfig.Scheduled(epoch) else ScheduleConfig.None)
+ }
+
+ val paddingStartValue = resources.getDimensionPixelSize(R.dimen.startPaddingWithoutIcon)
+ (scheduleOptions.children + reminderVisibility + customScheduleOption).forEach { view ->
+ view.applyContentPaddingStart(paddingStartValue)
+ }
+ }
+
+ private fun setupReminderOptions() = with(binding) {
+ hours24.setText(resources.getQuantityString(R.plurals.hoursBeforeSendingReminder, 24, 24))
+ days3.setText(resources.getQuantityString(R.plurals.daysBeforeSendingReminder, 3, 3))
+ days7.setText(resources.getQuantityString(R.plurals.daysBeforeSendingReminder, 7, 7))
+
+ val paddingStartValue = resources.getDimensionPixelSize(R.dimen.startPaddingWithoutIcon)
+ (optionsDelays.children + customDelayReminder).forEach { view -> view.applyContentPaddingStart(paddingStartValue) }
+
+ optionsDelays.onItemCheckedListener { _, value, _ ->
+ val minutes = value?.toIntOrNull()
+ val isValidPreset = ReminderPreset.entries.any { preset -> preset.delayMinutes == minutes }
+ newMessageViewModel.setReminderConfig(
+ config = if (minutes != null && isValidPreset) {
+ ReminderConfig.Delayed(minutes, isCustom = false)
+ } else {
+ ReminderConfig.None
+ }
+ )
+ }
+ }
+
+ private fun setReminderOptionsVisible(isVisible: Boolean) {
+ TransitionManager.beginDelayedTransition(binding.reminderOptionsWrapper.parent as ViewGroup)
+ binding.reminderOptionsWrapper.isVisible = isVisible
+ }
+
+ private fun removeReminderOptionsSelection() {
+ binding.optionsDelays.clearCheck()
+ newMessageViewModel.setReminderConfig(ReminderConfig.None)
+ }
+
+ private fun defaultReminderSelection() = with(binding) {
+ optionsDelays.check(R.id.hours24)
+ newMessageViewModel.setReminderConfig(ReminderConfig.Delayed(ReminderPreset.HOURS_24.delayMinutes, isCustom = false))
+ }
+
+ private fun defaultScheduleSelection() = with(binding) {
+ val firstVisibleOption = scheduleOptions.children
+ .filterIsInstance()
+ .firstOrNull { it.id != R.id.lastScheduleOption }
+
+ firstVisibleOption?.let { option ->
+ val epoch = option.associatedValue?.toLongOrNull()
+ if (epoch != null) {
+ scheduleOptions.check(option.id)
+ newMessageViewModel.setScheduleConfig(ScheduleConfig.Scheduled(epoch))
+ }
+ }
+ }
+
+ private fun removeScheduleOptionsSelection() = with(binding) {
+ scheduleOptions.clearCheck()
+ newMessageViewModel.setScheduleConfig(ScheduleConfig.None)
+ }
+
+ private fun setScheduleOptionsVisible(isVisible: Boolean) = with(binding) {
+ TransitionManager.beginDelayedTransition(scheduleOptionsWrapper.parent as ViewGroup)
+ scheduleOptionsWrapper.isVisible = isVisible
+ }
+
+ private fun observeScheduleConfig() {
+ newMessageViewModel.scheduleConfig.observe(viewLifecycleOwner) { scheduleConfig ->
+ renderScheduleConfig(scheduleConfig)
+ }
+ }
+
+ private fun observeReminderConfig() {
+ newMessageViewModel.reminderConfig.observe(viewLifecycleOwner) { reminderConfig ->
+ renderReminderConfig(reminderConfig)
+ }
+ }
+
+ private fun renderScheduleConfig(scheduleConfig: ScheduleConfig) = with(binding) {
+ when (scheduleConfig) {
+ is ScheduleConfig.Scheduled -> {
+ scheduleSending.isChecked = true
+ setScheduleOptionsVisible(isVisible = true)
+ handleScheduledConfig(scheduleConfig)
+ }
+ ScheduleConfig.None -> {
+ scheduleSending.isChecked = false
+ setScheduleOptionsVisible(isVisible = false)
+ scheduleOptions.clearCheck()
+ resetCustomScheduleOption()
+ }
+ }
+ }
+
+ private fun resetCustomScheduleOption() = with(binding) {
+ customScheduleOption.setCheckMark(displayCheckMark = false)
+ customScheduleOption.removeSubtitle()
+ }
+
+ private fun checkStandardScheduleOption(optionId: Int) {
+ resetCustomScheduleOption()
+ binding.scheduleOptions.check(optionId)
+ }
+
+ private fun applyCustomSchedule(epoch: Long) = with(binding) {
+ scheduleOptions.clearCheck()
+ customScheduleOption.setSubtitle(requireContext().dayOfWeekDateWithoutYear(Date(epoch)))
+ customScheduleOption.setCheckMark(displayCheckMark = true)
+ }
+
+ private fun handleScheduledConfig(config: ScheduleConfig.Scheduled) = with(binding) {
+ val epoch = config.epochMillis
+ val scheduleStr = epoch.toString()
+ val matchedOption = scheduleOptions.children
+ .filterIsInstance()
+ .firstOrNull { it.associatedValue == scheduleStr }
+
+ when {
+ config.isCustom -> applyCustomSchedule(epoch)
+ matchedOption != null -> checkStandardScheduleOption(matchedOption.id)
+ lastSelectedEpoch != null && lastScheduleOption.associatedValue == scheduleStr -> {
+ checkStandardScheduleOption(lastScheduleOption.id)
+ }
+ else -> applyCustomSchedule(epoch)
+ }
+ }
+
+ private fun renderReminderConfig(reminderConfig: ReminderConfig) = with(binding) {
+ when (reminderConfig) {
+ is ReminderConfig.Delayed -> {
+ reminderIfNoAnswer.isChecked = true
+ setReminderOptionsVisible(isVisible = true)
+ handleDelayedConfig(reminderConfig)
+ }
+ ReminderConfig.None -> {
+ reminderIfNoAnswer.isChecked = false
+ setReminderOptionsVisible(isVisible = false)
+ optionsDelays.clearCheck()
+ resetCustomDelayReminder()
+ }
+ }
+ }
+
+ private fun resetCustomDelayReminder() = with(binding) {
+ customDelayReminder.setCheckMark(displayCheckMark = false)
+ customDelayReminder.removeSubtitle()
+ }
+
+ private fun applyCustomReminder(delayMinutes: Int) = with(binding) {
+ optionsDelays.clearCheck()
+ customDelayReminder.setSubtitle(requireContext().formatDelayText(delayMinutes))
+ customDelayReminder.setCheckMark(displayCheckMark = true)
+ }
+
+ private fun checkStandardReminderOption(optionId: Int?) {
+ resetCustomDelayReminder()
+ if (optionId != null) binding.optionsDelays.check(optionId) else binding.optionsDelays.clearCheck()
+ }
+
+ private fun handleDelayedConfig(config: ReminderConfig.Delayed) {
+ if (config.isCustom) {
+ applyCustomReminder(config.delayMinutes)
+ } else {
+ val targetId = when (config.delayMinutes) {
+ ReminderPreset.HOURS_24.delayMinutes -> R.id.hours24
+ ReminderPreset.DAYS_3.delayMinutes -> R.id.days3
+ ReminderPreset.DAYS_7.delayMinutes -> R.id.days7
+ else -> null
+ }
+ checkStandardReminderOption(targetId)
+ }
+ }
+
+ private fun executeIfAuthorized(onAuthorized: () -> Unit) {
+ val kSuite = currentKSuite
+ val matomoName = MatomoName.ScheduledCustomDate.value
+
+ when (kSuite) {
+ KSuite.Perso.Free -> openMyKSuiteUpgradeBottomSheet(matomoName)
+ KSuite.Pro.Free -> openKSuiteProBottomSheet(kSuite, navigationArgs.isAdmin, matomoName)
+ KSuite.StarterPack -> openMailPremiumBottomSheet(matomoName)
+ else -> onAuthorized()
+ }
+ }
+
+ private fun showCustomScheduleDatePicker() {
+ dateAndTimeScheduleDialog.show(
+ onDateSelected = { timestamp ->
+ trackScheduleSendEvent(MatomoName.CustomSchedule)
+ newMessageViewModel.setScheduleConfig(ScheduleConfig.Scheduled(timestamp, isCustom = true))
+ localSettings.lastSelectedScheduleEpochMillis = timestamp
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt
index 43b9380eb4d..58e7e53d41c 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt
@@ -50,4 +50,18 @@ object DateFormatUtils {
)
private fun Context.localHourFormat() = if (DateFormat.is24HourFormat(this)) FORMAT_DATE_24_HOUR else FORMAT_DATE_12_HOUR
+
+ fun Context.formatDelayText(delayMinutes: Int): String {
+ val hours = delayMinutes / MINUTES_IN_AN_HOUR
+ val days = delayMinutes / MINUTES_IN_A_DAY
+
+ val (pluralId, quantity) = when {
+ delayMinutes % MINUTES_IN_A_DAY == 0 -> R.plurals.daysBeforeSendingReminder to days
+ else -> R.plurals.hoursBeforeSendingReminder to hours
+ }
+ return resources.getQuantityString(pluralId, quantity, quantity)
+ }
+
+ const val MINUTES_IN_AN_HOUR = 60
+ const val MINUTES_IN_A_DAY = 24 * MINUTES_IN_AN_HOUR
}
diff --git a/app/src/main/java/com/infomaniak/mail/utils/extensions/ViewExt.kt b/app/src/main/java/com/infomaniak/mail/utils/extensions/ViewExt.kt
index 841c35ee56a..aff8bfffa07 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/extensions/ViewExt.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/extensions/ViewExt.kt
@@ -19,7 +19,9 @@ package com.infomaniak.mail.utils.extensions
import android.content.res.ColorStateList
import android.view.View
+import android.view.ViewGroup
import androidx.core.content.ContextCompat
+import androidx.core.view.updatePaddingRelative
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.infomaniak.core.common.observe
@@ -56,6 +58,10 @@ private fun View.applyColor(buttonState: SendingButtonState) {
if (this is ExtendedFloatingActionButton) this.backgroundTintList = ColorStateList.valueOf(color)
}
+fun View.applyContentPaddingStart(value: Int) {
+ (this as? ViewGroup)?.getChildAt(0)?.updatePaddingRelative(start = value)
+}
+
enum class SendingButtonState {
Send,
SendingBlocked
diff --git a/app/src/main/res/layout/fragment_new_message.xml b/app/src/main/res/layout/fragment_new_message.xml
index 23dc6ffa98e..a8c373ee8bc 100644
--- a/app/src/main/res/layout/fragment_new_message.xml
+++ b/app/src/main/res/layout/fragment_new_message.xml
@@ -276,6 +276,55 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/subjectBarrier" />
+
+
+
+
+
+
+
+
@@ -643,13 +692,14 @@
tools:visibility="visible" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_item_setting.xml b/app/src/main/res/layout/view_item_setting.xml
index 0c2e596aaa6..135a41e7549 100644
--- a/app/src/main/res/layout/view_item_setting.xml
+++ b/app/src/main/res/layout/view_item_setting.xml
@@ -16,6 +16,7 @@
~ along with this program. If not, see .
-->
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_setting_radio_button.xml b/app/src/main/res/layout/view_setting_radio_button.xml
index fb90bb8859d..98a37cdf4eb 100644
--- a/app/src/main/res/layout/view_setting_radio_button.xml
+++ b/app/src/main/res/layout/view_setting_radio_button.xml
@@ -33,12 +33,29 @@
android:importantForAccessibility="no"
android:visibility="gone" />
-
+ android:orientation="vertical">
+
+
+
+
+
+
-
+