diff --git a/.idea/navEditor.xml b/.idea/navEditor.xml index 533687d6a1..a584185378 100644 --- a/.idea/navEditor.xml +++ b/.idea/navEditor.xml @@ -112,7 +112,7 @@ @@ -124,8 +124,8 @@ @@ -136,7 +136,7 @@ @@ -148,7 +148,7 @@ @@ -160,7 +160,7 @@ @@ -172,7 +172,7 @@ @@ -193,7 +193,7 @@ @@ -205,7 +205,7 @@ @@ -217,7 +217,7 @@ @@ -229,7 +229,7 @@ @@ -241,7 +241,7 @@ @@ -253,7 +253,7 @@ @@ -265,7 +265,7 @@ @@ -291,7 +291,7 @@ @@ -303,7 +303,7 @@ @@ -315,7 +315,7 @@ @@ -327,7 +327,7 @@ @@ -339,7 +339,7 @@ @@ -363,7 +363,7 @@ @@ -375,7 +375,7 @@ @@ -387,7 +387,7 @@ @@ -399,7 +399,7 @@ @@ -430,7 +430,7 @@ @@ -454,7 +454,7 @@ @@ -466,7 +466,7 @@ @@ -499,7 +499,7 @@ @@ -511,7 +511,7 @@ @@ -523,7 +523,7 @@ @@ -547,7 +547,19 @@ + + + + + + + @@ -559,7 +571,7 @@ @@ -580,7 +592,7 @@ @@ -606,7 +618,7 @@ @@ -682,7 +694,7 @@ @@ -694,7 +706,7 @@ @@ -706,7 +718,7 @@ @@ -718,7 +730,7 @@ @@ -739,7 +751,7 @@ @@ -751,7 +763,7 @@ @@ -763,7 +775,7 @@ @@ -775,7 +787,7 @@ @@ -787,7 +799,7 @@ @@ -843,7 +855,7 @@ @@ -855,13 +867,25 @@ + + + + + + + @@ -889,7 +913,7 @@ @@ -901,7 +925,7 @@ @@ -913,7 +937,7 @@ @@ -937,7 +961,7 @@ @@ -963,6 +987,30 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt index 54f5af12fc..92d2c9d1ff 100644 --- a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt +++ b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -269,6 +269,10 @@ object MatomoMail : MatomoCore { trackEvent("settingsAutoAdvance", name) } + fun Fragment.trackScheduleSendEvent(name: String) { + trackEvent("scheduleSend", name) + } + // We need to invert this logical value to keep a coherent value for analytics because actions // conditions are inverted (ex: if the condition is `message.isSpam`, then we want to unspam) private fun Boolean.toMailActionValue() = (!this).toFloat() diff --git a/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt b/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt index 6e96b7184a..c57d4f0816 100644 --- a/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt +++ b/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -69,6 +69,7 @@ class LocalSettings private constructor(context: Context) : SharedValues { var autoAdvanceNaturalThread by sharedValue("autoAdvanceNaturalThreadKey", AutoAdvanceMode.FOLLOWING_THREAD) var showWebViewOutdated by sharedValue("showWebViewOutdatedKey", true) var accessTokenApiCallRecord by sharedValue("accessTokenApiCallRecordKey", null) + var lastSelectedScheduleEpoch by sharedValue("lastSelectedScheduleEpochKey", null) fun removeSettings() = sharedPreferences.transaction { clear() } diff --git a/app/src/main/java/com/infomaniak/mail/data/api/ApiRepository.kt b/app/src/main/java/com/infomaniak/mail/data/api/ApiRepository.kt index a43f35a862..ac7ae5cebe 100644 --- a/app/src/main/java/com/infomaniak/mail/data/api/ApiRepository.kt +++ b/app/src/main/java/com/infomaniak/mail/data/api/ApiRepository.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -30,7 +30,6 @@ import com.infomaniak.lib.core.utils.FORMAT_FULL_DATE_WITH_HOUR import com.infomaniak.lib.core.utils.format import com.infomaniak.mail.data.LocalSettings.AiEngine import com.infomaniak.mail.data.models.* -import com.infomaniak.mail.data.models.AttachmentDisposition import com.infomaniak.mail.data.models.addressBook.AddressBooksResult import com.infomaniak.mail.data.models.ai.AiMessage import com.infomaniak.mail.data.models.ai.AiResult @@ -43,6 +42,7 @@ import com.infomaniak.mail.data.models.correspondent.Contact import com.infomaniak.mail.data.models.correspondent.Recipient import com.infomaniak.mail.data.models.draft.Draft import com.infomaniak.mail.data.models.draft.SaveDraftResult +import com.infomaniak.mail.data.models.draft.ScheduleDraftResult import com.infomaniak.mail.data.models.draft.SendDraftResult import com.infomaniak.mail.data.models.getMessages.ActivitiesResult import com.infomaniak.mail.data.models.getMessages.GetMessagesByUidsResult @@ -58,7 +58,6 @@ import com.infomaniak.mail.data.models.thread.ThreadResult import com.infomaniak.mail.ui.newMessage.AiViewModel.Shortcut import com.infomaniak.mail.utils.Utils import io.realm.kotlin.ext.copyFromRealm -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -170,28 +169,35 @@ object ApiRepository : ApiRepositoryCore() { } fun saveDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse { - - val body = getDraftBody(draft) - - fun postDraft(): ApiResponse = callApi(ApiRoutes.draft(mailboxUuid), POST, body, okHttpClient) - - fun putDraft(uuid: String): ApiResponse = - callApi(ApiRoutes.draft(mailboxUuid, uuid), PUT, body, okHttpClient) - - return draft.remoteUuid?.let(::putDraft) ?: run(::postDraft) + return uploadDraft(mailboxUuid, draft, okHttpClient) } fun sendDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse { + return uploadDraft(mailboxUuid, draft, okHttpClient) + } - val body = getDraftBody(draft) + fun scheduleDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse { + return uploadDraft(mailboxUuid, draft, okHttpClient) + } - fun postDraft(): ApiResponse = callApi(ApiRoutes.draft(mailboxUuid), POST, body, okHttpClient) + private inline fun uploadDraft(mailboxUuid: String, draft: Draft, okHttpClient: OkHttpClient): ApiResponse { + val body = getDraftBody(draft) + return draft.remoteUuid?.let { putDraft(mailboxUuid, body, okHttpClient, it) } + ?: run { postDraft(mailboxUuid, body, okHttpClient) } + } - fun putDraft(uuid: String): ApiResponse = - callApi(ApiRoutes.draft(mailboxUuid, uuid), PUT, body, okHttpClient) + private inline fun putDraft( + mailboxUuid: String, + body: String, + okHttpClient: OkHttpClient, + uuid: String, + ): ApiResponse = callApi(ApiRoutes.draft(mailboxUuid, uuid), PUT, body, okHttpClient) - return draft.remoteUuid?.let(::putDraft) ?: run(::postDraft) - } + private inline fun postDraft( + mailboxUuid: String, + body: String, + okHttpClient: OkHttpClient, + ): ApiResponse = callApi(ApiRoutes.draft(mailboxUuid), POST, body, okHttpClient) private fun getDraftBody(draft: Draft): String { val updatedDraft = if (draft.identityId == Draft.NO_IDENTITY.toString()) { @@ -240,6 +246,14 @@ object ApiRepository : ApiRepositoryCore() { return callApi(ApiRoutes.draft(mailboxUuid, remoteDraftUuid), DELETE) } + fun unscheduleDraft(unscheduleDraftUrl: String): ApiResponse { + return callApi(ApiRoutes.resource(unscheduleDraftUrl), DELETE) + } + + fun rescheduleDraft(draftResource: String, scheduleDate: Date): ApiResponse { + return callApi(ApiRoutes.rescheduleDraft(draftResource, scheduleDate), PUT) + } + fun getDraft(messageDraftResource: String): ApiResponse = callApi(ApiRoutes.resource(messageDraftResource), GET) fun addToFavorites(mailboxUuid: String, messagesUids: List): List> { diff --git a/app/src/main/java/com/infomaniak/mail/data/api/ApiRoutes.kt b/app/src/main/java/com/infomaniak/mail/data/api/ApiRoutes.kt index ec24bd4e08..4e7ef9464f 100644 --- a/app/src/main/java/com/infomaniak/mail/data/api/ApiRoutes.kt +++ b/app/src/main/java/com/infomaniak/mail/data/api/ApiRoutes.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -18,8 +18,12 @@ package com.infomaniak.mail.data.api import com.infomaniak.lib.core.BuildConfig.INFOMANIAK_API_V1 +import com.infomaniak.lib.core.utils.FORMAT_SCHEDULE_MAIL +import com.infomaniak.lib.core.utils.format import com.infomaniak.mail.BuildConfig.MAIL_API import com.infomaniak.mail.utils.Utils +import java.net.URLEncoder +import java.util.Date object ApiRoutes { @@ -127,11 +131,11 @@ object ApiRoutes { } fun folders(mailboxUuid: String): String { - return "${mailMailbox(mailboxUuid)}/folder" + return "${mailMailbox(mailboxUuid)}/folder?with=ik-static" } private fun folder(mailboxUuid: String, folderId: String): String { - return "${folders(mailboxUuid)}/$folderId" + return "${mailMailbox(mailboxUuid)}/folder/$folderId" } fun flushFolder(mailboxUuid: String, folderId: String): String { @@ -223,6 +227,11 @@ object ApiRoutes { return "${draft(mailboxUuid)}/$remoteDraftUuid" } + fun rescheduleDraft(draftResource: String, scheduleDate: Date): String { + val formatedDate = scheduleDate.format(FORMAT_SCHEDULE_MAIL) + return "${MAIL_API}${draftResource}/schedule?schedule_date=${URLEncoder.encode(formatedDate, "UTF-8")}" + } + fun createAttachment(mailboxUuid: String): String { return "${draft(mailboxUuid)}/attachment" } diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/RealmDatabase.kt b/app/src/main/java/com/infomaniak/mail/data/cache/RealmDatabase.kt index 8ace97abbe..613a9bdab7 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/RealmDatabase.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/RealmDatabase.kt @@ -160,8 +160,8 @@ object RealmDatabase { //region Configurations versions const val USER_INFO_SCHEMA_VERSION = 2L - const val MAILBOX_INFO_SCHEMA_VERSION = 7L - const val MAILBOX_CONTENT_SCHEMA_VERSION = 19L + const val MAILBOX_INFO_SCHEMA_VERSION = 8L + const val MAILBOX_CONTENT_SCHEMA_VERSION = 20L //endregion //region Configurations names diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/RealmMigrations.kt b/app/src/main/java/com/infomaniak/mail/data/cache/RealmMigrations.kt index cb6dd7c90f..4a1a4bab3b 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/RealmMigrations.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/RealmMigrations.kt @@ -38,6 +38,7 @@ val MAILBOX_INFO_MIGRATION = AutomaticSchemaMigration { migrationContext -> val MAILBOX_CONTENT_MIGRATION = AutomaticSchemaMigration { migrationContext -> SentryDebug.addMigrationBreadcrumb(migrationContext) migrationContext.deleteRealmFromFirstMigration() + migrationContext.keepDefaultValuesAfterNineteenthMigration() } // Migrate to version #1 @@ -45,6 +46,7 @@ private fun MigrationContext.deleteRealmFromFirstMigration() { if (oldRealm.schemaVersion() < 1L) newRealm.deleteAll() } +//region Use default property values when adding a new column in a migration /** * Migrate from version #6 * @@ -73,3 +75,26 @@ private fun MigrationContext.keepDefaultValuesAfterSixthMigration() { } } } + +// Migrate from version #19 +private fun MigrationContext.keepDefaultValuesAfterNineteenthMigration() { + + if (oldRealm.schemaVersion() <= 19L) { + + enumerate(className = "Folder") { _: DynamicRealmObject, newObject: DynamicMutableRealmObject? -> + newObject?.apply { + // Add property with default value + set(propertyName = "isDisplayed", value = true) + } + } + + enumerate(className = "Message") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? -> + newObject?.apply { + // Rename property without losing its previous value + set(propertyName = "isScheduledMessage", value = oldObject.getValue(fieldName = "isScheduled")) + } + } + + } +} +//endregion diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/FolderController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/FolderController.kt index e5ae656aa1..27eb00ac2e 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/FolderController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/FolderController.kt @@ -46,11 +46,19 @@ class FolderController @Inject constructor( //region Get data fun getMenuDrawerDefaultFoldersAsync(): Flow> { - return getFoldersQuery(mailboxContentRealm(), withoutType = FoldersType.CUSTOM, withoutChildren = true).asFlow() + return getFoldersQuery( + realm = mailboxContentRealm(), + withoutTypes = listOf(FoldersType.CUSTOM), + withoutChildren = true, + ).asFlow() } fun getMenuDrawerCustomFoldersAsync(): Flow> { - return getFoldersQuery(mailboxContentRealm(), withoutType = FoldersType.DEFAULT, withoutChildren = true).asFlow() + return getFoldersQuery( + realm = mailboxContentRealm(), + withoutTypes = listOf(FoldersType.DEFAULT), + withoutChildren = true, + ).asFlow() } fun getSearchFoldersAsync(): Flow> { @@ -58,7 +66,11 @@ class FolderController @Inject constructor( } fun getMoveFolders(): RealmResults { - return getFoldersQuery(mailboxContentRealm(), withoutType = FoldersType.DRAFT, withoutChildren = true).find() + return getFoldersQuery( + realm = mailboxContentRealm(), + withoutTypes = listOf(FoldersType.SCHEDULED_DRAFTS, FoldersType.DRAFT), + withoutChildren = true, + ).find() } fun getFolder(id: String): Folder? { @@ -108,20 +120,25 @@ class FolderController @Inject constructor( remoteFolders.forEach { remoteFolder -> - getFolder(remoteFolder.id, realm = this)?.let { localFolder -> + val localFolder = getFolder(remoteFolder.id, realm = this) + + if (remoteFolder.role == FolderRole.SCHEDULED_DRAFTS && localFolder == null) remoteFolder.isDisplayed = false + + localFolder?.let { val collapseStateNeedsReset = remoteFolder.isRoot && remoteFolder.children.isEmpty() - val isCollapsed = if (collapseStateNeedsReset) false else localFolder.isCollapsed + val isCollapsed = if (collapseStateNeedsReset) false else it.isCollapsed remoteFolder.initLocalValues( - localFolder.lastUpdatedAt, - localFolder.cursor, - localFolder.unreadCountLocal, - localFolder.threads, - localFolder.oldMessagesUidsToFetch, - localFolder.newMessagesUidsToFetch, - localFolder.remainingOldMessagesToFetch, - localFolder.isHidden, + it.lastUpdatedAt, + it.cursor, + it.unreadCountLocal, + it.threads, + it.oldMessagesUidsToFetch, + it.newMessagesUidsToFetch, + it.remainingOldMessagesToFetch, + it.isDisplayed, + it.isHidden, isCollapsed, ) } @@ -134,6 +151,7 @@ class FolderController @Inject constructor( enum class FoldersType { DEFAULT, CUSTOM, + SCHEDULED_DRAFTS, DRAFT, } @@ -145,17 +163,21 @@ class FolderController @Inject constructor( //region Queries private fun getFoldersQuery( realm: TypedRealm, - withoutType: FoldersType? = null, + withoutTypes: List = emptyList(), withoutChildren: Boolean = false, + visibleFoldersOnly: Boolean = true, ): RealmQuery { val rootsQuery = if (withoutChildren) " AND $isRootFolder" else "" - val typeQuery = when (withoutType) { - FoldersType.DEFAULT -> " AND ${Folder.rolePropertyName} == nil" - FoldersType.CUSTOM -> " AND ${Folder.rolePropertyName} != nil" - FoldersType.DRAFT -> " AND ${Folder.rolePropertyName} != '${FolderRole.DRAFT.name}'" - null -> "" + val typeQuery = withoutTypes.joinToString(separator = "") { + when (it) { + FoldersType.DEFAULT -> " AND ${Folder.rolePropertyName} == nil" + FoldersType.CUSTOM -> " AND ${Folder.rolePropertyName} != nil" + FoldersType.SCHEDULED_DRAFTS -> " AND ${Folder.rolePropertyName} != '${FolderRole.SCHEDULED_DRAFTS.name}'" + FoldersType.DRAFT -> " AND ${Folder.rolePropertyName} != '${FolderRole.DRAFT.name}'" + } } - return realm.query("$isNotSearch${rootsQuery}${typeQuery}").sortFolders() + val visibilityQuery = if (visibleFoldersOnly) " AND ${Folder::isDisplayed.name} == true" else "" + return realm.query("${isNotSearch}${rootsQuery}${typeQuery}${visibilityQuery}").sortFolders() } private fun getFoldersQuery(exceptionsFoldersIds: List, realm: TypedRealm): RealmQuery { @@ -170,7 +192,7 @@ class FolderController @Inject constructor( //region Get data private fun getFolders(exceptionsFoldersIds: List = emptyList(), realm: TypedRealm): RealmResults { val realmQuery = if (exceptionsFoldersIds.isEmpty()) { - getFoldersQuery(realm) + getFoldersQuery(realm, visibleFoldersOnly = false) } else { getFoldersQuery(exceptionsFoldersIds, realm) } diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt index f3a99b1abd..cbdfec54da 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt @@ -58,11 +58,13 @@ class MessageController @Inject constructor(private val mailboxContentRealm: Rea fun getLastMessageToExecuteAction(thread: Thread): Message = with(thread) { + val isNotScheduledDraft = "${Message::isScheduledDraft.name} == false" val isNotFromMe = "SUBQUERY(${Message::from.name}, \$recipient, " + "\$recipient.${Recipient::email.name} != '${AccountUtils.currentMailboxEmail}').@count > 0" - return messages.query("$isNotDraft AND $isNotFromMe").find().lastOrNull() - ?: messages.query(isNotDraft).find().lastOrNull() + return messages.query("$isNotDraft AND $isNotScheduledDraft AND $isNotFromMe").find().lastOrNull() + ?: messages.query("$isNotDraft AND $isNotScheduledDraft").find().lastOrNull() + ?: messages.query(isNotScheduledDraft).find().lastOrNull() ?: messages.last() } @@ -83,11 +85,11 @@ class MessageController @Inject constructor(private val mailboxContentRealm: Rea fun getMovableMessages(thread: Thread): List { val byFolderId = "${Message::folderId.name} == '${thread.folderId}'" - return getMessagesAndDuplicates(thread, "$byFolderId AND $isNotScheduled") + return getMessagesAndDuplicates(thread, "$byFolderId AND $isNotScheduledMessage") } fun getUnscheduledMessages(thread: Thread): List { - return getMessagesAndDuplicates(thread, isNotScheduled) + return getMessagesAndDuplicates(thread, isNotScheduledMessage) } private fun getMessagesAndDuplicates(thread: Thread, query: String): List { @@ -150,7 +152,7 @@ class MessageController @Inject constructor(private val mailboxContentRealm: Rea companion object { private val isNotDraft = "${Message::isDraft.name} == false" - private val isNotScheduled = "${Message::isScheduled.name} == false" + private val isNotScheduledMessage = "${Message::isScheduledMessage.name} == false" //region Queries private fun getMessagesQuery(messageUid: String, realm: TypedRealm): RealmQuery { diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/RefreshController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/RefreshController.kt index 4f0cc34821..23d33e4c4e 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/RefreshController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/RefreshController.kt @@ -195,9 +195,10 @@ class RefreshController @Inject constructor( clearCallbacks() when (initialFolder.role) { - FolderRole.INBOX -> listOf(FolderRole.SENT, FolderRole.DRAFT) - FolderRole.SENT -> listOf(FolderRole.INBOX, FolderRole.DRAFT) - FolderRole.DRAFT -> listOf(FolderRole.INBOX, FolderRole.SENT) + FolderRole.INBOX -> listOf(FolderRole.SENT, FolderRole.DRAFT, FolderRole.SCHEDULED_DRAFTS) + FolderRole.SENT -> listOf(FolderRole.INBOX, FolderRole.DRAFT, FolderRole.SCHEDULED_DRAFTS) + FolderRole.DRAFT -> listOf(FolderRole.INBOX, FolderRole.SENT, FolderRole.SCHEDULED_DRAFTS) + FolderRole.SCHEDULED_DRAFTS -> listOf(FolderRole.INBOX, FolderRole.SENT, FolderRole.DRAFT) else -> emptyList() }.forEach { role -> scope.ensureActive() @@ -285,6 +286,7 @@ class RefreshController @Inject constructor( it.newMessagesUidsToFetch.addAll(activities.addedShortUids) it.unreadCountRemote = activities.unreadCountRemote it.lastUpdatedAt = Date().toRealmInstant() + if (it.role == FolderRole.SCHEDULED_DRAFTS) it.isDisplayed = it.threads.isNotEmpty() it.cursor = activities.cursor SentryDebug.addCursorBreadcrumb("fetchActivities", it, activities.cursor) } @@ -345,6 +347,8 @@ class RefreshController @Inject constructor( max(it.remainingOldMessagesToFetch - uidsToFetch.count(), 0) } } + + if (it.role == FolderRole.SCHEDULED_DRAFTS) it.isDisplayed = it.threads.isNotEmpty() } sendOrphanMessages(folder) @@ -601,6 +605,7 @@ class RefreshController @Inject constructor( val existingMessage = folderMessages[remoteMessage.uid]?.let { if (it.isManaged()) it else MessageController.getMessage(it.uid, realm = this) } + // Add Sentry log and leave if the Message already exists if (existingMessage != null && !existingMessage.isOrphan()) { SentryLog.i( diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ThreadController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ThreadController.kt index e25d97dcb2..9c7033cda2 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ThreadController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ThreadController.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -58,8 +58,8 @@ class ThreadController @Inject constructor( ) { //region Get data - fun getThreadsAsync(folder: Folder, filter: ThreadFilter = ThreadFilter.ALL): Flow> { - return getThreadsQuery(folder, filter).asFlow() + fun getThreadsAsync(folder: Folder, filter: ThreadFilter = ThreadFilter.ALL, sortOrder: Sort): Flow> { + return getThreadsQuery(folder, filter, sortOrder).asFlow() } fun getSearchThreadsAsync(): Flow> { @@ -217,11 +217,15 @@ class ThreadController @Inject constructor( return folder.threads.query(unseen).count() } - private fun getThreadsQuery(folder: Folder, filter: ThreadFilter = ThreadFilter.ALL): RealmQuery { + private fun getThreadsQuery( + folder: Folder, + filter: ThreadFilter = ThreadFilter.ALL, + sortOrder: Sort, + ): RealmQuery { val notFromSearch = "${Thread::isFromSearch.name} == false" val notLocallyMovedOut = " AND ${Thread::isLocallyMovedOut.name} == false" - val realmQuery = folder.threads.query(notFromSearch + notLocallyMovedOut).sort(Thread::date.name, Sort.DESCENDING) + val realmQuery = folder.threads.query(notFromSearch + notLocallyMovedOut).sort(Thread::date.name, sortOrder) return if (filter == ThreadFilter.ALL) { realmQuery diff --git a/app/src/main/java/com/infomaniak/mail/data/models/FeatureFlag.kt b/app/src/main/java/com/infomaniak/mail/data/models/FeatureFlag.kt index 48cb42bebd..96474a41ec 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/FeatureFlag.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/FeatureFlag.kt @@ -21,4 +21,5 @@ package com.infomaniak.mail.data.models enum class FeatureFlag(val apiName: String) { AI("ai-mail-composer"), BIMI("bimi"), + SCHEDULE_DRAFTS("schedule-send-draft"), } diff --git a/app/src/main/java/com/infomaniak/mail/data/models/Folder.kt b/app/src/main/java/com/infomaniak/mail/data/models/Folder.kt index 76514c37d7..7613be4413 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/Folder.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/Folder.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -92,6 +92,8 @@ class Folder : RealmObject, Cloneable { var roleOrder: Int = role?.order ?: CUSTOM_FOLDER_ROLE_ORDER @Transient var sortedName: String = name + @Transient + var isDisplayed: Boolean = true // Used to hide folders on specific conditions (i.e. ScheduledDrafts folder when its empty) //endregion private val _parents by backlinks(Folder::children) @@ -123,6 +125,7 @@ class Folder : RealmObject, Cloneable { oldMessagesUidsToFetch: RealmList, newMessagesUidsToFetch: RealmList, remainingOldMessagesToFetch: Int, + isDisplayed: Boolean, isHidden: Boolean, isCollapsed: Boolean, ) { @@ -134,6 +137,7 @@ class Folder : RealmObject, Cloneable { this.oldMessagesUidsToFetch.addAll(oldMessagesUidsToFetch) this.newMessagesUidsToFetch.addAll(newMessagesUidsToFetch) this.remainingOldMessagesToFetch = remainingOldMessagesToFetch + this.isDisplayed = isDisplayed this.isHidden = isHidden this.isCollapsed = isCollapsed @@ -161,10 +165,11 @@ class Folder : RealmObject, Cloneable { val order: Int, val matomoValue: String, ) { - INBOX(R.string.inboxFolder, R.drawable.ic_drawer_inbox, 8, "inboxFolder"), - COMMERCIAL(R.string.commercialFolder, R.drawable.ic_promotions, 7, "commercialFolder"), - SOCIALNETWORKS(R.string.socialNetworksFolder, R.drawable.ic_social_media, 6, "socialNetworksFolder"), - SENT(R.string.sentFolder, R.drawable.ic_sent_messages, 5, "sentFolder"), + INBOX(R.string.inboxFolder, R.drawable.ic_drawer_inbox, 9, "inboxFolder"), + COMMERCIAL(R.string.commercialFolder, R.drawable.ic_promotions, 8, "commercialFolder"), + SOCIALNETWORKS(R.string.socialNetworksFolder, R.drawable.ic_social_media, 7, "socialNetworksFolder"), + SENT(R.string.sentFolder, R.drawable.ic_send, 6, "sentFolder"), + SCHEDULED_DRAFTS(R.string.scheduledMessagesFolder, R.drawable.ic_schedule_send, 5, "scheduledDraftsFolder"), DRAFT(R.string.draftFolder, R.drawable.ic_draft, 4, "draftFolder"), SPAM(R.string.spamFolder, R.drawable.ic_spam, 3, "spamFolder"), TRASH(R.string.trashFolder, R.drawable.ic_bin, 2, "trashFolder"), diff --git a/app/src/main/java/com/infomaniak/mail/data/models/draft/Draft.kt b/app/src/main/java/com/infomaniak/mail/data/models/draft/Draft.kt index 445754a260..2c0b4e14a7 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/draft/Draft.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/draft/Draft.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -27,13 +27,16 @@ import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.serializers.RealmListKSerializer import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.annotations.PrimaryKey -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import kotlinx.serialization.UseSerializers +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.util.UUID +@OptIn(ExperimentalSerializationApi::class) @Serializable class Draft : RealmObject { @@ -69,9 +72,33 @@ class Draft : RealmObject { var swissTransferUuid: String? = null /** - * This `delay` should NOT be removed. If we remove it, we won't receive any `etop` from the API when sending an Email. + * We can't have both `delay` & `scheduleDate`. They are mutually exclusive. + * + * `delay` should be set to 0 when `scheduleDate` is not set. + * If we don't set it to 0, we won't receive any `etop` from the API when sending an Email. */ - var delay: Int = 0 + @EncodeDefault(EncodeDefault.Mode.NEVER) + @Serializable(with = ConditionalIntSerializer::class) + var delay: Int? = 0 + set(value) { + scheduleDate = null + field = value + } + + /** + * We can't have both `delay` & `scheduleDate`. They are mutually exclusive. + * + * The API requires that this field does not exist, except when scheduling a message. + * We use a custom serializer for this very reason. + */ + @EncodeDefault(EncodeDefault.Mode.NEVER) + @Serializable(with = ConditionalStringSerializer::class) + @SerialName("schedule_date") + var scheduleDate: String? = null + set(value) { + delay = null + field = value + } //endregion //region Local data (Transient) @@ -102,6 +129,7 @@ class Draft : RealmObject { enum class DraftAction(val apiCallValue: String, val matomoValue: String) { SAVE("save", "saveDraft"), SEND("send", "sendMail"), + SCHEDULE("schedule", "scheduleDraft"), } enum class DraftMode { @@ -118,3 +146,28 @@ class Draft : RealmObject { private val draftJson = Json(ApiController.json) { encodeDefaults = true } } } + +object ConditionalIntSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ConditionalIntField", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: Int?) { + if (shouldSerialize(value)) encoder.encodeInt(value ?: 0) + } + + override fun deserialize(decoder: Decoder): Int = decoder.decodeInt() + + private fun shouldSerialize(value: Int?): Boolean = value != null +} + + +object ConditionalStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ConditionalStringField", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: String?) { + if (shouldSerialize(value)) encoder.encodeString(value ?: "") + } + + override fun deserialize(decoder: Decoder): String = decoder.decodeString() + + private fun shouldSerialize(value: String?): Boolean = value != null +} diff --git a/app/src/main/java/com/infomaniak/mail/data/models/draft/ScheduleDraftResult.kt b/app/src/main/java/com/infomaniak/mail/data/models/draft/ScheduleDraftResult.kt new file mode 100644 index 0000000000..f9747e2c5a --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/data/models/draft/ScheduleDraftResult.kt @@ -0,0 +1,28 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2024 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.data.models.draft + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ScheduleDraftResult( + @SerialName("schedule_action") + val unscheduleDraftUrl: String, +) diff --git a/app/src/main/java/com/infomaniak/mail/data/models/draft/SendDraftResult.kt b/app/src/main/java/com/infomaniak/mail/data/models/draft/SendDraftResult.kt index d788c53ada..4497c7146c 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/draft/SendDraftResult.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/draft/SendDraftResult.kt @@ -23,5 +23,5 @@ import kotlinx.serialization.Serializable @Serializable data class SendDraftResult( @SerialName("etop") - val scheduledDate: String, + val scheduledMessageEtop: String, ) diff --git a/app/src/main/java/com/infomaniak/mail/data/models/getMessages/ActivitiesResult.kt b/app/src/main/java/com/infomaniak/mail/data/models/getMessages/ActivitiesResult.kt index b4189d1d1b..e21825360d 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/getMessages/ActivitiesResult.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/getMessages/ActivitiesResult.kt @@ -44,7 +44,7 @@ data class ActivitiesResult( @SerialName("forwarded") val isForwarded: Boolean, @SerialName("scheduled") - val isScheduled: Boolean, + val isScheduledMessage: Boolean, @SerialName("seen") val isSeen: Boolean, ) diff --git a/app/src/main/java/com/infomaniak/mail/data/models/mailbox/Mailbox.kt b/app/src/main/java/com/infomaniak/mail/data/models/mailbox/Mailbox.kt index 3bdd03329d..2ef138894d 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/mailbox/Mailbox.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/mailbox/Mailbox.kt @@ -65,6 +65,8 @@ class Mailbox : RealmObject { @SerialName("unseen_messages") var unreadCountRemote: Int = 0 var aliases = realmListOf() + @SerialName("is_free") + var isFree: Boolean = false //endregion //region Local data (Transient) @@ -110,6 +112,8 @@ class Mailbox : RealmObject { shouldDisplayPastille = unreadCountLocal == 0 && unreadCountRemote > 0, ) + val isFreeMailbox: Boolean get() = isFree && isLimited + private fun createObjectId(userId: Int): String = "${userId}_${this.mailboxId}" fun initLocalValues( diff --git a/app/src/main/java/com/infomaniak/mail/data/models/message/Message.kt b/app/src/main/java/com/infomaniak/mail/data/models/message/Message.kt index c82bb2f5f6..965b1a1cf8 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/message/Message.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/message/Message.kt @@ -80,6 +80,9 @@ class Message : RealmObject { var isDraft: Boolean = false @SerialName("draft_resource") var draftResource: String? = null + // Boolean used to know if this Message is scheduled to be sent sometime in the future, or not + @SerialName("is_scheduled_draft") + var isScheduledDraft: Boolean = false var body: Body? = null @SerialName("has_attachments") var hasAttachments: Boolean = false @@ -92,13 +95,17 @@ class Message : RealmObject { var isAnswered: Boolean = false @SerialName("flagged") var isFavorite: Boolean = false + // Boolean used to know if this Message is currently being sent, but can + // still be cancelled during 10~30 sec, depending on user configuration @SerialName("scheduled") - var isScheduled: Boolean = false + var isScheduledMessage: Boolean = false var preview: String = "" var size: Int = 0 @SerialName("has_unsubscribe_link") var hasUnsubscribeLink: Boolean? = null var bimi: Bimi? = null + @SerialName("schedule_action") + var unscheduleDraftUrl: String? = null // TODO: Those are unused for now, but if we ever want to use them, we need to save them in `Message.keepHeavyData()`. // If we don't do it now, we'll probably forget to do it in the future. @@ -113,7 +120,7 @@ class Message : RealmObject { // ------------- !IMPORTANT! ------------- // Every field that is added in this Transient region should be declared in 'initLocalValue()' too // to avoid loosing data when updating from API. - // If the Field is a "heavy data" (i.e an embedded object), it should also be added in 'keepHeavyData()' + // If the Field is a "heavy data" (i.e. an embedded object), it should also be added in 'keepHeavyData()'. @Transient @PersistedName("isFullyDownloaded") @@ -318,7 +325,7 @@ class Message : RealmObject { isFavorite = flags.isFavorite isAnswered = flags.isAnswered isForwarded = flags.isForwarded - isScheduled = flags.isScheduled + isScheduledMessage = flags.isScheduledMessage } fun shouldBeExpanded(index: Int, lastIndex: Int) = !isDraft && (!isSeen || index == lastIndex) diff --git a/app/src/main/java/com/infomaniak/mail/data/models/thread/Thread.kt b/app/src/main/java/com/infomaniak/mail/data/models/thread/Thread.kt index 6c1be59b4a..56706b84d3 100644 --- a/app/src/main/java/com/infomaniak/mail/data/models/thread/Thread.kt +++ b/app/src/main/java/com/infomaniak/mail/data/models/thread/Thread.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -95,6 +95,8 @@ class Thread : RealmObject { // It's only used to filter locally the Threads' list. @Transient var isLocallyMovedOut: Boolean = false + @Transient + var numberOfScheduledDrafts: Int = 0 //endregion private val _folders by backlinks(Folder::threads) @@ -135,6 +137,7 @@ class Thread : RealmObject { val shouldAddMessage = when (FolderController.getFolder(folderId, realm)?.role) { FolderRole.DRAFT -> newMessage.isDraft // In Draft folder: only add draft Messages. + FolderRole.SCHEDULED_DRAFTS -> newMessage.isScheduledDraft // In ScheduledDrafts folder: only add scheduled Messages. FolderRole.TRASH -> newMessage.isTrashed // In Trash folder: only add deleted Messages. else -> !newMessage.isTrashed // In other folders: only add non-deleted Messages. } @@ -188,6 +191,7 @@ class Thread : RealmObject { isAnswered = false isForwarded = false hasAttachable = false + numberOfScheduledDrafts = 0 } private fun updateThread() { @@ -210,6 +214,7 @@ class Thread : RealmObject { isAnswered = false } if (message.hasAttachable) hasAttachable = true + if (message.isScheduledDraft) numberOfScheduledDrafts++ } date = messages.last { it.folderId == folderId }.date @@ -237,12 +242,14 @@ class Thread : RealmObject { fun computeAvatarRecipient(): Pair = runCatching { - val message = messages - .lastOrNull { it.folder.role != FolderRole.SENT && it.folder.role != FolderRole.DRAFT } - ?: messages.last() + val message = messages.lastOrNull { + it.folder.role != FolderRole.SENT && + it.folder.role != FolderRole.DRAFT && + it.folder.role != FolderRole.SCHEDULED_DRAFTS + } ?: messages.last() val recipients = when (message.folder.role) { - FolderRole.SENT, FolderRole.DRAFT -> message.to + FolderRole.SENT, FolderRole.DRAFT, FolderRole.SCHEDULED_DRAFTS -> message.to else -> message.from } @@ -264,7 +271,7 @@ class Thread : RealmObject { } fun computeDisplayedRecipients(): RealmList = when (folder.role) { - FolderRole.SENT, FolderRole.DRAFT -> to + FolderRole.SENT, FolderRole.DRAFT, FolderRole.SCHEDULED_DRAFTS -> to else -> from } diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt index 0ec1dde3cd..9272dc35d1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt @@ -38,11 +38,9 @@ import androidx.navigation.fragment.NavHostFragment import androidx.work.Data import com.airbnb.lottie.LottieAnimationView import com.infomaniak.lib.core.MatomoCore.TrackerAction -import com.infomaniak.lib.core.utils.SentryLog +import com.infomaniak.lib.core.utils.* import com.infomaniak.lib.core.utils.Utils import com.infomaniak.lib.core.utils.Utils.toEnumOrThrow -import com.infomaniak.lib.core.utils.hasPermissions -import com.infomaniak.lib.core.utils.year import com.infomaniak.lib.stores.StoreUtils import com.infomaniak.lib.stores.StoreUtils.checkUpdateIsRequired import com.infomaniak.lib.stores.reviewmanagers.InAppReviewManager @@ -69,6 +67,7 @@ import com.infomaniak.mail.ui.main.search.SearchFragmentArgs import com.infomaniak.mail.ui.newMessage.NewMessageActivity import com.infomaniak.mail.ui.sync.SyncAutoConfigActivity import com.infomaniak.mail.utils.* +import com.infomaniak.mail.utils.MailDateFormatUtils.mostDetailedDate import com.infomaniak.mail.utils.UiUtils.progressivelyColorSystemBars import com.infomaniak.mail.utils.Utils.Shortcuts import com.infomaniak.mail.utils.Utils.openShortcutHelp @@ -79,8 +78,10 @@ import io.sentry.Sentry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date +import java.util.Locale import java.util.UUID import javax.inject.Inject import com.infomaniak.lib.core.R as RCore @@ -103,16 +104,20 @@ class MainActivity : BaseActivity() { (supportFragmentManager.findFragmentById(R.id.mainHostFragment) as NavHostFragment).navController } + private var draftAction: DraftAction? = null + private val showSendingSnackbarTimer: CountDownTimer by lazy { - Utils.createRefreshTimer(milliseconds = 1_000L) { snackbarManager.setValue(getString(R.string.snackbarEmailSending)) } + Utils.createRefreshTimer(milliseconds = 1_000L) { + val resId = if (draftAction == DraftAction.SCHEDULE) R.string.snackbarScheduling else R.string.snackbarEmailSending + snackbarManager.setValue(getString(resId)) + } } private val newMessageActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> - val draftAction = result.data?.getStringExtra(DRAFT_ACTION_KEY)?.let(DraftAction::valueOf) - if (draftAction == DraftAction.SEND) { - showEasterXMas() - showSendingSnackbarTimer.start() - } + draftAction = result.data?.getStringExtra(DRAFT_ACTION_KEY)?.let(DraftAction::valueOf) + + if (draftAction == DraftAction.SEND) showEasterXMas() + if (draftAction == DraftAction.SEND || draftAction == DraftAction.SCHEDULE) showSendingSnackbarTimer.start() } private val syncAutoConfigActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> @@ -281,7 +286,19 @@ class MainActivity : BaseActivity() { showSavedDraftSnackbar(associatedMailboxUuid, remoteDraftUuid) } } - DraftAction.SEND -> showSentDraftSnackbar() + DraftAction.SEND -> { + showSentDraftSnackbar() + } + DraftAction.SCHEDULE -> { + val scheduleDate = getString(DraftsActionsWorker.SCHEDULED_DRAFT_DATE_KEY) + val unscheduleDraftUrl = getString(DraftsActionsWorker.UNSCHEDULE_DRAFT_URL_KEY) + if (scheduleDate != null && unscheduleDraftUrl != null) { + showScheduledDraftSnackbar( + scheduleDate = SimpleDateFormat(FORMAT_SCHEDULE_MAIL, Locale.getDefault()).parse(scheduleDate)!!, + unscheduleDraftUrl = unscheduleDraftUrl, + ) + } + } } } } @@ -290,8 +307,8 @@ class MainActivity : BaseActivity() { val userId = getInt(DraftsActionsWorker.RESULT_USER_ID_KEY, 0) if (userId != AccountUtils.currentUserId) return - getLong(DraftsActionsWorker.BIGGEST_SCHEDULED_DATE_KEY, 0).takeIf { it > 0 }?.let { scheduledDate -> - mainViewModel.refreshDraftFolderWhenDraftArrives(scheduledDate) + getLong(DraftsActionsWorker.BIGGEST_SCHEDULED_MESSAGES_ETOP_KEY, 0).takeIf { it > 0 }?.let { scheduledMessageEtop -> + mainViewModel.refreshDraftFolderWhenDraftArrives(scheduledMessageEtop) } } @@ -312,6 +329,23 @@ class MainActivity : BaseActivity() { snackbarManager.setValue(getString(R.string.snackbarEmailSent)) } + // Still display the Snackbar even if it took three times 10 seconds of timeout to succeed + private fun showScheduledDraftSnackbar(scheduleDate: Date, unscheduleDraftUrl: String) { + showSendingSnackbarTimer.cancel() + + val dateString = mostDetailedDate( + context = this, + date = scheduleDate, + format = FORMAT_DATE_DAY_MONTH, + ) + + snackbarManager.setValue( + title = String.format(getString(R.string.snackbarScheduleSaved), dateString), + buttonTitle = RCore.string.buttonCancel, + customBehavior = { mainViewModel.unscheduleDraft(unscheduleDraftUrl) }, + ) + } + private fun loadCurrentMailbox() { mainViewModel.loadCurrentMailboxFromLocal().observe(this) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index d1a8f82c78..71ba7f62a8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -70,6 +70,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.realm.kotlin.Realm import io.realm.kotlin.ext.toRealmList import io.realm.kotlin.notifications.ResultsChange +import io.realm.kotlin.query.Sort import io.sentry.Attachment import io.sentry.Sentry import io.sentry.SentryLevel @@ -202,7 +203,10 @@ class MainViewModel @Inject constructor( currentThreadsLiveJob = viewModelScope.launch(ioCoroutineContext) { observeFolderAndFilter() .flatMapLatest { (folder, filter) -> - folder?.let { threadController.getThreadsAsync(it, filter) } ?: emptyFlow() + folder?.let { + val sortOrder = if (folder.role == FolderRole.SCHEDULED_DRAFTS) Sort.ASCENDING else Sort.DESCENDING + threadController.getThreadsAsync(it, filter, sortOrder) + } ?: emptyFlow() } .collect(currentThreadsLive::postValue) } @@ -233,6 +237,10 @@ class MainViewModel @Inject constructor( val mergedContactsLive: LiveData = avatarMergedContactData.mergedContactLiveData //endregion + //region Scheduled Draft + var draftResource: String? = null + //endregion + //region Share Thread URL private val _shareThreadUrlResult = MutableSharedFlow() val shareThreadUrlResult = _shareThreadUrlResult.shareIn(viewModelScope, SharingStarted.Lazily) @@ -479,6 +487,7 @@ class MainViewModel @Inject constructor( deleteThreadsOrMessage(threadsUids = threadsUids) } + // TODO: When the back is done refactoring how scheduled drafts are deleted, work on this function shall resume. private fun deleteThreadsOrMessage( threadsUids: List, message: Message? = null, @@ -583,15 +592,75 @@ class MainViewModel @Inject constructor( refreshFoldersAsync(mailbox, listOf(draftFolderId)) } - showDraftDeletedSnackbar(apiResponse) + showDeletedDraftSnackbar(apiResponse) } - private fun showDraftDeletedSnackbar(apiResponse: ApiResponse) { + private fun showDeletedDraftSnackbar(apiResponse: ApiResponse) { val titleRes = if (apiResponse.isSuccess()) R.string.snackbarDraftDeleted else apiResponse.translateError() snackbarManager.postValue(appContext.getString(titleRes)) } //endregion + //region Scheduled Drafts + fun rescheduleDraft(scheduleDate: Date) = viewModelScope.launch(ioCoroutineContext) { + draftResource?.takeIf { it.isNotBlank() }?.let { resource -> + with(ApiRepository.rescheduleDraft(resource, scheduleDate)) { + if (isSuccess()) { + val scheduledDraftsFolderId = folderController.getFolder(FolderRole.SCHEDULED_DRAFTS)!!.id + refreshFoldersAsync(currentMailbox.value!!, listOf(scheduledDraftsFolderId)) + } else { + snackbarManager.postValue(title = appContext.getString(translatedError)) + } + } + } ?: run { + snackbarManager.postValue(title = appContext.getString(RCore.string.anErrorHasOccurred)) + } + } + + fun modifyScheduledDraft( + unscheduleDraftUrl: String, + onSuccess: () -> Unit, + ) = viewModelScope.launch(ioCoroutineContext) { + val mailbox = currentMailbox.value!! + val apiResponse = ApiRepository.unscheduleDraft(unscheduleDraftUrl) + + if (apiResponse.isSuccess()) { + val scheduledDraftsFolderId = folderController.getFolder(FolderRole.SCHEDULED_DRAFTS)!!.id + refreshFoldersAsync(mailbox, listOf(scheduledDraftsFolderId)) + onSuccess() + } else { + snackbarManager.postValue(title = appContext.getString(apiResponse.translatedError)) + } + } + + fun unscheduleDraft(unscheduleDraftUrl: String) = viewModelScope.launch(ioCoroutineContext) { + val mailbox = currentMailbox.value!! + val apiResponse = ApiRepository.unscheduleDraft(unscheduleDraftUrl) + + if (apiResponse.isSuccess()) { + val scheduledDraftsFolderId = folderController.getFolder(FolderRole.SCHEDULED_DRAFTS)!!.id + refreshFoldersAsync(mailbox, listOf(scheduledDraftsFolderId)) + } + + showUnscheduledDraftSnackbar(apiResponse) + } + + private fun showUnscheduledDraftSnackbar(apiResponse: ApiResponse) { + + fun openDraftFolder() = folderController.getFolder(FolderRole.DRAFT)?.id?.let(::openFolder) + + if (apiResponse.isSuccess()) { + snackbarManager.postValue( + title = appContext.getString(R.string.snackbarSaveInDraft), + buttonTitle = R.string.draftFolder, + customBehavior = ::openDraftFolder, + ) + } else { + snackbarManager.postValue(appContext.getString(apiResponse.translateError())) + } + } + //endregion + //region Move fun moveThreadsOrMessageTo( destinationFolderId: String, @@ -1136,13 +1205,13 @@ class MainViewModel @Inject constructor( selectedThreadsLiveData.value = selectedThreads } - fun refreshDraftFolderWhenDraftArrives(scheduledDate: Long) = viewModelScope.launch(ioCoroutineContext) { + fun refreshDraftFolderWhenDraftArrives(scheduledMessageEtop: Long) = viewModelScope.launch(ioCoroutineContext) { val folder = folderController.getFolder(FolderRole.DRAFT) if (folder?.cursor != null) { val timeNow = Date().time - val delay = REFRESH_DELAY + max(scheduledDate - timeNow, 0L) + val delay = REFRESH_DELAY + max(scheduledMessageEtop - timeNow, 0L) delay(min(delay, MAX_REFRESH_DELAY)) refreshController.refreshThreads( diff --git a/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/ConfirmScheduledDraftModificationDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/ConfirmScheduledDraftModificationDialog.kt new file mode 100644 index 0000000000..046b49133e --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/ConfirmScheduledDraftModificationDialog.kt @@ -0,0 +1,111 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2024-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 . + */ +package com.infomaniak.mail.ui.alertDialogs + +import android.content.Context +import androidx.annotation.StringRes +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.infomaniak.lib.core.utils.context +import com.infomaniak.mail.R +import com.infomaniak.mail.databinding.DialogConfirmScheduledDraftModificationBinding +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject +import com.infomaniak.lib.core.R as RCore + +@ActivityScoped +open class ConfirmScheduledDraftModificationDialog @Inject constructor( + @ActivityContext private val activityContext: Context, +) : BaseAlertDialog(activityContext) { + + val binding: DialogConfirmScheduledDraftModificationBinding by lazy { + DialogConfirmScheduledDraftModificationBinding.inflate(activity.layoutInflater) + } + + override val alertDialog = initDialog() + + private var onPositiveButtonClicked: (() -> Unit)? = null + private var onNegativeButtonClicked: (() -> Unit)? = null + private var onDismissed: (() -> Unit)? = null + + private fun initDialog() = with(binding) { + MaterialAlertDialogBuilder(context) + .setView(root) + .setPositiveButton(R.string.buttonConfirm, null) + .setNegativeButton(RCore.string.buttonCancel, null) + .create() + } + + final override fun resetCallbacks() { + onPositiveButtonClicked = null + onNegativeButtonClicked = null + onDismissed = null + } + + fun show( + title: String, + description: String, + onPositiveButtonClicked: () -> Unit, + onNegativeButtonClicked: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null, + ) { + showDialogWithBasicInfo(title, description, R.string.buttonModify) + setupListeners(onPositiveButtonClicked, onNegativeButtonClicked, onDismiss) + } + + private fun setupListeners( + onPositiveButtonClicked: () -> Unit, + onNegativeButtonClicked: (() -> Unit)?, + onDismiss: (() -> Unit)?, + ) = with(alertDialog) { + + this@ConfirmScheduledDraftModificationDialog.onPositiveButtonClicked = onPositiveButtonClicked + this@ConfirmScheduledDraftModificationDialog.onNegativeButtonClicked = onNegativeButtonClicked + + positiveButton.setOnClickListener { + this@ConfirmScheduledDraftModificationDialog.onPositiveButtonClicked?.invoke() + dismiss() + } + + negativeButton.setOnClickListener { + this@ConfirmScheduledDraftModificationDialog.onNegativeButtonClicked?.invoke() + dismiss() + } + + onDismiss.let { + onDismissed = it + setOnDismissListener { onDismissed?.invoke() } + } + } + + private fun showDialogWithBasicInfo( + title: String? = null, + description: String? = null, + @StringRes positiveButtonText: Int? = null, + @StringRes negativeButtonText: Int? = null, + ) = with(binding) { + + alertDialog.show() + + title?.let(dialogDescriptionLayout.dialogTitle::setText) + description?.let(dialogDescriptionLayout.dialogDescription::setText) + + positiveButtonText?.let(positiveButton::setText) + negativeButtonText?.let(negativeButton::setText) + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/DescriptionAlertDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/DescriptionAlertDialog.kt index b5eec1eb72..f23fb325c1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/DescriptionAlertDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/DescriptionAlertDialog.kt @@ -41,7 +41,7 @@ open class DescriptionAlertDialog @Inject constructor( private var onPositiveButtonClicked: (() -> Unit)? = null private var onNegativeButtonClicked: (() -> Unit)? = null - private var onDismissed: (() -> Unit)? = null + private var onCancelled: (() -> Unit)? = null protected fun initDialog(customThemeRes: Int? = null) = with(binding) { val builder = customThemeRes?.let { MaterialAlertDialogBuilder(context, it) } ?: MaterialAlertDialogBuilder(context) @@ -56,7 +56,7 @@ open class DescriptionAlertDialog @Inject constructor( final override fun resetCallbacks() { onPositiveButtonClicked = null onNegativeButtonClicked = null - onDismissed = null + onCancelled = null } fun show( @@ -68,35 +68,35 @@ open class DescriptionAlertDialog @Inject constructor( @StringRes negativeButtonText: Int? = null, onPositiveButtonClicked: () -> Unit, onNegativeButtonClicked: (() -> Unit)? = null, - onDismiss: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, ) { showDialogWithBasicInfo(title, description, displayCancelButton, positiveButtonText, negativeButtonText) if (displayLoader) initProgress() - setupListeners(displayLoader, onPositiveButtonClicked, onNegativeButtonClicked, onDismiss) + setupListeners(displayLoader, onPositiveButtonClicked, onNegativeButtonClicked, onCancel) } private fun setupListeners( displayLoader: Boolean, onPositiveButtonClicked: () -> Unit, onNegativeButtonClicked: (() -> Unit)?, - onDismiss: (() -> Unit)?, + onCancel: (() -> Unit)?, ) = with(alertDialog) { this@DescriptionAlertDialog.onPositiveButtonClicked = onPositiveButtonClicked - this@DescriptionAlertDialog.onNegativeButtonClicked = onNegativeButtonClicked - positiveButton.setOnClickListener { this@DescriptionAlertDialog.onPositiveButtonClicked?.invoke() if (displayLoader) startLoading() else dismiss() } + + this@DescriptionAlertDialog.onNegativeButtonClicked = onNegativeButtonClicked negativeButton.setOnClickListener { this@DescriptionAlertDialog.onNegativeButtonClicked?.invoke() - dismiss() + cancel() } - onDismiss?.let { - onDismissed = it - setOnDismissListener { onDismissed?.invoke() } + onCancel?.let { + onCancelled = it + setOnCancelListener { onCancelled?.invoke() } } } @@ -104,7 +104,7 @@ open class DescriptionAlertDialog @Inject constructor( deletedCount: Int, displayLoader: Boolean, onPositiveButtonClicked: () -> Unit, - onDismissed: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, ) = show( title = activityContext.resources.getQuantityString( R.plurals.threadListDeletionConfirmationAlertTitle, @@ -117,7 +117,7 @@ open class DescriptionAlertDialog @Inject constructor( ), displayLoader = displayLoader, onPositiveButtonClicked = onPositiveButtonClicked, - onDismiss = onDismissed, + onCancel = onCancel, ) protected fun showDialogWithBasicInfo( diff --git a/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/SelectDateAndTimeForScheduledDraftDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/SelectDateAndTimeForScheduledDraftDialog.kt new file mode 100644 index 0000000000..97498e1cc8 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/alertDialogs/SelectDateAndTimeForScheduledDraftDialog.kt @@ -0,0 +1,190 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2024-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 . + */ +package com.infomaniak.mail.ui.alertDialogs + +import android.content.Context +import androidx.annotation.StringRes +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.infomaniak.mail.R +import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.databinding.DialogSelectDateAndTimeForScheduledDraftBinding +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import java.util.Calendar +import java.util.Date +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) { + + 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 var datePicker: MaterialDatePicker? = null + private var timePicker: MaterialTimePicker? = null + + @Inject + lateinit var localSettings: LocalSettings + + private fun initDialog() = with(binding) { + + selectedDate = Date().roundUpToNextTenMinutes() + + setTimePicker() + setDatePicker() + setDate() + + 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(title: String, onSchedule: (Long) -> Unit, onAbort: (() -> Unit)? = null) { + showDialogWithBasicInfo(title, R.string.buttonScheduleTitle) + setupListeners(onSchedule, onAbort) + } + + private fun getScheduleDateErrorText(): String = if (selectedDate.isInTheFuture().not()) { + activityContext.resources.getString(R.string.errorChooseUpcomingDate) + } else { + activityContext.resources.getQuantityString( + R.plurals.errorScheduleDelayTooShort, + MIN_SCHEDULE_DELAY_MINUTES, + MIN_SCHEDULE_DELAY_MINUTES, + ) + } + + private fun setupListeners(onSchedule: (Long) -> Unit, onAbort: (() -> Unit)?) = with(alertDialog) { + + binding.dateField.setOnClickListener { datePicker?.show(super.activity.supportFragmentManager, "tag") } + + datePicker?.addOnPositiveButtonClickListener { time -> + val date = Date().also { it.time = time } + + selectedDate = Calendar.getInstance().apply { + set(date.year(), date.month(), date.day(), selectedDate.hours(), selectedDate.minutes(), 0) + }.time + + setDate() + } + + binding.timeField.setOnClickListener { timePicker?.show(super.activity.supportFragmentManager, "tag") } + + timePicker?.addOnPositiveButtonClickListener { + val hour: Int = timePicker!!.hour + val minute: Int = timePicker!!.minute + + selectedDate = selectedDate.setHour(hour).setMinute(minute) + + binding.timeField.setText(selectedDate.format(FORMAT_DATE_HOUR_MINUTE)) + + binding.scheduleDateError.text = getScheduleDateErrorText() + binding.scheduleDateError.isVisible = selectedDate.isAtLeastXMinutesInTheFuture(MIN_SCHEDULE_DELAY_MINUTES).not() + positiveButton.isEnabled = selectedDate.isAtLeastXMinutesInTheFuture(MIN_SCHEDULE_DELAY_MINUTES) + } + + this@SelectDateAndTimeForScheduledDraftDialog.onSchedule = onSchedule + this@SelectDateAndTimeForScheduledDraftDialog.onAbort = onAbort + + positiveButton.setOnClickListener { + this@SelectDateAndTimeForScheduledDraftDialog.onSchedule?.invoke(selectedDate.time) + dismiss() + } + + negativeButton.setOnClickListener { cancel() } + + setOnCancelListener { onAbort?.invoke() } + } + + private fun setTimePicker() = with(binding) { + timePicker = MaterialTimePicker.Builder() + .setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK) + .setTimeFormat(TimeFormat.CLOCK_24H) + .setHour(selectedDate.hours()) + .setMinute(selectedDate.minutes()) + .setTitleText(context.getString(R.string.selectTimeDialogTitle)) + .build() + } + + private fun setDatePicker() = with(binding) { + val dateValidators = listOf( + DateValidatorPointForward.now(), + DateValidatorPointBackward.before(Date().addYears(MAX_SCHEDULE_DELAY_YEARS).time), + ) + val constraintsBuilder = CalendarConstraints.Builder().setValidator(CompositeDateValidator.allOf(dateValidators)) + + datePicker = MaterialDatePicker.Builder.datePicker() + .setTitleText(context.getString(R.string.selectDateDialogTitle)) + .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) + .setCalendarConstraints(constraintsBuilder.build()) + .build() + } + + private fun setDate() { + binding.dateField.setText(selectedDate.format(FORMAT_DATE_DAY_MONTH_YEAR)) + } + + private fun showDialogWithBasicInfo( + title: String? = null, + @StringRes positiveButtonText: Int? = null, + @StringRes negativeButtonText: Int? = null, + ) = with(binding) { + + alertDialog.show() + + selectedDate = Date().roundUpToNextTenMinutes() + + binding.timeField.setText(selectedDate.format(FORMAT_DATE_HOUR_MINUTE)) + + setTimePicker() + setDatePicker() + + title?.let(dialogTitle::setText) + + positiveButtonText?.let(positiveButton::setText) + negativeButtonText?.let(negativeButton::setText) + } + + companion object { + const val MIN_SCHEDULE_DELAY_MINUTES = 5 + const val MAX_SCHEDULE_DELAY_YEARS = 10 + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleSendBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleSendBottomSheetDialog.kt new file mode 100644 index 0000000000..e2d754d653 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/ScheduleSendBottomSheetDialog.kt @@ -0,0 +1,206 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2024-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 . + */ +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.StringRes +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.navigation.fragment.navArgs +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +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.main.thread.actions.ActionItemView +import com.infomaniak.mail.utils.MailDateFormatUtils.mostDetailedDate +import dagger.hilt.android.AndroidEntryPoint +import java.util.Date +import javax.inject.Inject + + +@AndroidEntryPoint +class ScheduleSendBottomSheetDialog @Inject constructor() : BottomSheetDialogFragment() { + + private val navigationArgs: ScheduleSendBottomSheetDialogArgs by navArgs() + private var binding: BottomSheetScheduleSendBinding by safeBinding() + + // Navigation args does not support nullable primitive types, so we use 0L + // as a replacement (corresponding to Thursday 1 January 1970 00:00:00 UT). + private var lastSelectedScheduleEpoch: Long = 0L + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return BottomSheetScheduleSendBinding.inflate(inflater, container, false).also { binding = it }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?): Unit = with(binding) { + super.onViewCreated(view, savedInstanceState) + + lastSelectedScheduleEpoch = navigationArgs.lastSelectedScheduleEpoch + + computeLastScheduleItem() + setLastScheduleClickListener() + setCustomScheduleClickListener() + + val timeToDisplay = TimeToDisplay.getTimeToDisplayFromDate() + Schedule.entries.filter { schedule -> timeToDisplay in schedule.timeToDisplay }.forEach { schedule -> + scheduleItems.addView(createScheduleItem(schedule)) + } + + val shouldDisplayDivider = lastScheduleItem.isVisible + (scheduleItems.children.first() as ActionItemView).setDividerVisibility(shouldDisplayDivider) + } + + private fun computeLastScheduleItem() = with(binding) { + if (Date(lastSelectedScheduleEpoch).isAtLeastXMinutesInTheFuture(MIN_SCHEDULE_DELAY_MINUTES)) { + lastScheduleItem.isVisible = true + lastScheduleItem.setDescription( + mostDetailedDate( + context, + date = Date(lastSelectedScheduleEpoch), + format = FORMAT_DATE_DAY_MONTH, + ), + ) + } + } + + private fun setLastScheduleClickListener() { + binding.lastScheduleItem.setOnClickListener { + if (lastSelectedScheduleEpoch != 0L) { + trackScheduleSendEvent("lastSelectedSchedule") + setBackNavigationResult(SCHEDULE_DRAFT_RESULT, lastSelectedScheduleEpoch) + } + } + } + + private fun setCustomScheduleClickListener() { + binding.customScheduleItem.setOnClickListener { + if (navigationArgs.isCurrentMailboxFree) { + safeNavigate( + resId = R.id.upgradeProductBottomSheetDialog, + currentClassName = ScheduleSendBottomSheetDialog::class.java.name, + ) + } else { + setBackNavigationResult(OPEN_DATE_AND_TIME_SCHEDULE_DIALOG, true) + } + } + } + + private fun createScheduleItem(schedule: Schedule): ActionItemView = ActionItemView(requireContext()).apply { + setTitle(schedule.scheduleTitleRes) + setDescription(mostDetailedDate(context, date = schedule.date(), format = FORMAT_DATE_DAY_MONTH)) + setIconResource(schedule.scheduleIconRes) + setOnClickListener { + trackScheduleSendEvent(schedule.matomoValue) + setBackNavigationResult(SCHEDULE_DRAFT_RESULT, schedule.date().time) + } + } + + companion object { + const val SCHEDULE_DRAFT_RESULT = "schedule_draft_result" + const val OPEN_DATE_AND_TIME_SCHEDULE_DIALOG = "open_date_and_time_schedule_dialog" + } +} + +enum class TimeToDisplay { + NIGHT, + MORNING, + AFTERNOON, + EVENING, + WEEKEND; + + companion object { + fun getTimeToDisplayFromDate(): TimeToDisplay { + val now = Date() + val timeSlot = Date(now.time) + return if (now.isWeekend()) { + WEEKEND + } else { + when (now) { + in timeSlot.setHour(7).setMinute(55)..timeSlot.setHour(13).setMinute(54) -> MORNING + in timeSlot.setHour(13).setMinute(55)..timeSlot.setHour(17).setMinute(54) -> AFTERNOON + in timeSlot.setHour(17).setMinute(55)..timeSlot.setHour(23).setMinute(54) -> EVENING + else -> NIGHT // Between 23:55 and 7:54, inclusive + } + } + } + } +} + +enum class Schedule( + @StringRes val scheduleTitleRes: Int, + @DrawableRes val scheduleIconRes: Int, + val date: () -> Date, + val timeToDisplay: List, + val matomoValue: String, +) { + LATER_THIS_MORNING( + R.string.laterThisMorning, + R.drawable.ic_morning_sunrise_schedule, + { Date().getMorning() }, + listOf(TimeToDisplay.NIGHT), + "laterThisMorning", + ), + MONDAY_MORNING( + R.string.mondayMorning, + R.drawable.ic_morning_schedule, + { Date().getNextMonday().getMorning() }, + listOf(TimeToDisplay.WEEKEND), + "nextMondayMorning", + ), + MONDAY_AFTERNOON( + R.string.mondayAfternoon, + R.drawable.ic_afternoon_schedule, + { Date().getNextMonday().getAfternoon() }, + listOf(TimeToDisplay.WEEKEND), + "nextMondayAfternoon", + ), + THIS_AFTERNOON( + R.string.thisAfternoon, + R.drawable.ic_afternoon_schedule, + { Date().getAfternoon() }, + listOf(TimeToDisplay.MORNING), + "thisAfternoon", + ), + THIS_EVENING( + R.string.thisEvening, + R.drawable.ic_evening_schedule, + { Date().getEvening() }, + listOf(TimeToDisplay.AFTERNOON), + "thisEvening", + ), + TOMORROW_MORNING( + R.string.tomorrowMorning, + R.drawable.ic_morning_schedule, + { Date().tomorrow().getMorning() }, + listOf(TimeToDisplay.NIGHT, TimeToDisplay.MORNING, TimeToDisplay.AFTERNOON, TimeToDisplay.EVENING), + "tomorrowMorning", + ), + NEXT_MONDAY( + R.string.nextMonday, + R.drawable.ic_arrow_return, + { Date().getNextMonday().getMorning() }, + listOf(TimeToDisplay.NIGHT, TimeToDisplay.MORNING, TimeToDisplay.AFTERNOON, TimeToDisplay.EVENING), + "nextMonday", + ) +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/UpgradeProductBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/UpgradeProductBottomSheetDialog.kt new file mode 100644 index 0000000000..00c8191bd5 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/bottomSheetDialogs/UpgradeProductBottomSheetDialog.kt @@ -0,0 +1,41 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2024 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.View +import androidx.core.view.isGone +import com.infomaniak.mail.R + +class UpgradeProductBottomSheetDialog : InformationBottomSheetDialog() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?): Unit = with(binding) { + super.onViewCreated(view, savedInstanceState) + + title.setText(R.string.disabledFeatureFlagTitle) + description.setText(R.string.disabledFeatureFlagDescription) + infoIllustration.setBackgroundResource(R.drawable.ic_update_logo) + + actionButton.apply { + actionButton.setText(R.string.buttonClose) + setOnClickListener { dismiss() } + } + + secondaryActionButton.isGone = true + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt index 1639309de9..21fec49a9a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -106,6 +106,8 @@ class ThreadListAdapter @Inject constructor( private set //endregion + private val isMultiselectDisabledInThisFolder: Boolean get() = folderRole == FolderRole.SCHEDULED_DRAFTS + init { setHasStableIds(true) } @@ -231,6 +233,7 @@ class ThreadListAdapter @Inject constructor( mailBodyPreview.text = computePreview().ifBlank { context.getString(R.string.noBodyTitle) } mailDate.text = formatDate(context) + scheduleSendIcon.isVisible = numberOfScheduledDrafts > 0 && folderRole == FolderRole.SCHEDULED_DRAFTS draftPrefix.isVisible = hasDrafts val (isIconReplyVisible, isIconForwardVisible) = computeReplyAndForwardIcon(thread.isAnswered, thread.isForwarded) @@ -242,7 +245,7 @@ class ThreadListAdapter @Inject constructor( iconFavorite.isVisible = isFavorite val messagesCount = messages.count() - threadCountText.text = messagesCount.toString() + threadCountText.text = "$messagesCount" threadCountCard.isVisible = messagesCount > 1 if (unseenMessagesCount == 0) setThreadUiRead() else setThreadUiUnread() @@ -252,13 +255,15 @@ class ThreadListAdapter @Inject constructor( multiSelection?.let { listener -> selectionCardView.setOnLongClickListener { - onThreadClickWithAbilityToOpenMultiSelection(thread, listener, TrackerAction.LONG_PRESS) + onThreadClickWithAbilityToOpenMultiSelection(thread, position, listener, TrackerAction.LONG_PRESS) true } expeditorAvatar.apply { - setOnClickListener { onThreadClickWithAbilityToOpenMultiSelection(thread, listener, TrackerAction.CLICK) } + setOnClickListener { + onThreadClickWithAbilityToOpenMultiSelection(thread, position, listener, TrackerAction.CLICK) + } setOnLongClickListener { - onThreadClickWithAbilityToOpenMultiSelection(thread, listener, TrackerAction.LONG_PRESS) + onThreadClickWithAbilityToOpenMultiSelection(thread, position, listener, TrackerAction.LONG_PRESS) true } } @@ -310,9 +315,15 @@ class ThreadListAdapter @Inject constructor( private fun CardviewThreadItemBinding.onThreadClickWithAbilityToOpenMultiSelection( thread: Thread, + position: Int, listener: MultiSelectionListener, action: TrackerAction, ) { + if (isMultiselectDisabledInThisFolder) { + onThreadClicked(thread, position) + return + } + val hasOpened = openMultiSelectionIfClosed(listener, action) toggleMultiSelectedThread(thread, shouldUpdateSelectedUi = !hasOpened) } @@ -664,29 +675,37 @@ class ThreadListAdapter @Inject constructor( } if (threadDensity == ThreadDensity.COMPACT) { - if (multiSelection?.selectedItems?.let(threads::containsAll) == false) { - multiSelection?.selectedItems?.removeAll { - scope.ensureActive() - !threads.contains(it) - } - } + cleanMultiSelectionItems(threads, scope) addAll(threads) } else { var previousSectionTitle = "" threads.forEach { thread -> scope.ensureActive() - val sectionTitle = thread.getSectionTitle(context) - when { - sectionTitle != previousSectionTitle -> { - add(sectionTitle) - previousSectionTitle = sectionTitle + + if (folderRole != FolderRole.SCHEDULED_DRAFTS) { + val sectionTitle = thread.getSectionTitle(context) + when { + sectionTitle != previousSectionTitle -> { + add(sectionTitle) + previousSectionTitle = sectionTitle + } } } + add(thread) } } } + private fun cleanMultiSelectionItems(threads: List, scope: CoroutineScope) { + if (multiSelection?.selectedItems?.let(threads::containsAll) == false) { + multiSelection?.selectedItems?.removeAll { + scope.ensureActive() + !threads.contains(it) + } + } + } + private fun Thread.getSectionTitle(context: Context): String = with(date.toDate()) { return when { isInTheFuture() -> context.getString(R.string.comingSoon) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt index 9843368e10..ff4b92ac89 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt @@ -451,7 +451,7 @@ class ThreadListFragment : TwoPaneFragment() { folderRole = folderRole, count = 1, displayLoader = false, - onDismiss = { + onCancel = { // Notify only if the user cancelled the popup (e.g. the thread is not deleted), // otherwise it will notify the next item in the list and make it slightly blink if (threadListAdapter.dataSet.indexOf(thread) == position) threadListAdapter.notifyItemChanged(position) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneViewModel.kt index c840b87b46..c7d1256dae 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneViewModel.kt @@ -86,15 +86,17 @@ class TwoPaneViewModel @Inject constructor( messageUid: String? = null, mailToUri: Uri? = null, ) { - newMessageArgs.value = NewMessageActivityArgs( - draftMode = draftMode, - previousMessageUid = previousMessageUid, - shouldLoadDistantResources = shouldLoadDistantResources, - arrivedFromExistingDraft = arrivedFromExistingDraft, - draftLocalUuid = draftLocalUuid, - draftResource = draftResource, - messageUid = messageUid, - mailToUri = mailToUri, + newMessageArgs.postValue( + NewMessageActivityArgs( + draftMode = draftMode, + previousMessageUid = previousMessageUid, + shouldLoadDistantResources = shouldLoadDistantResources, + arrivedFromExistingDraft = arrivedFromExistingDraft, + draftLocalUuid = draftLocalUuid, + draftResource = draftResource, + messageUid = messageUid, + mailToUri = mailToUri, + ), ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/menuDrawer/items/FolderViewHolder.kt b/app/src/main/java/com/infomaniak/mail/ui/main/menuDrawer/items/FolderViewHolder.kt index 4dcaf3b69a..6d0431438e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/menuDrawer/items/FolderViewHolder.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/menuDrawer/items/FolderViewHolder.kt @@ -66,7 +66,7 @@ class FolderViewHolder( } val unread = when (folder.role) { - FolderRole.DRAFT -> UnreadDisplay(count = folder.threads.count()) + FolderRole.DRAFT, FolderRole.SCHEDULED_DRAFTS -> UnreadDisplay(count = folder.threads.count()) FolderRole.SENT, FolderRole.TRASH -> UnreadDisplay(count = 0) else -> folder.unreadCountDisplay } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/settings/appearance/threadMode/ThreadModeSettingFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/settings/appearance/threadMode/ThreadModeSettingFragment.kt index 6efb045c54..646af328b9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/settings/appearance/threadMode/ThreadModeSettingFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/settings/appearance/threadMode/ThreadModeSettingFragment.kt @@ -75,7 +75,7 @@ class ThreadModeSettingFragment : Fragment() { localSettings.threadMode = threadMode threadModeSettingViewModel.dropAllMailboxesContentThenReloadApp() }, - onDismiss = { check(localSettings.threadMode) }, + onCancel = { check(localSettings.threadMode) }, ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageAlertView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageAlertView.kt index b1e6c01546..77c9c20809 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageAlertView.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageAlertView.kt @@ -51,6 +51,10 @@ class MessageAlertView @JvmOverloads constructor( } } + fun setDescription(text: String) { + binding.description.text = text + } + fun onAction1(listener: OnClickListener) { binding.action1.setOnClickListener(listener) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt index d6fd979184..ec40a78fd5 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt @@ -39,8 +39,10 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.viewbinding.ViewBinding +import com.infomaniak.lib.core.utils.FORMAT_DATE_DAY_FULL_MONTH_WITH_TIME import com.infomaniak.lib.core.utils.FormatterFileSize.formatShortFileSize import com.infomaniak.lib.core.utils.context +import com.infomaniak.lib.core.utils.format import com.infomaniak.lib.core.utils.isNightModeEnabled import com.infomaniak.mail.MatomoMail.trackMessageEvent import com.infomaniak.mail.R @@ -201,7 +203,7 @@ class ThreadAdapter( initMapForNewMessage(message, position) bindHeader(message) - bindAlerts(message.uid) + bindAlerts(message) bindCalendarEvent(message) bindAttachments(message) bindContent(message) @@ -350,6 +352,22 @@ class ThreadAdapter( private fun MessageViewHolder.bindHeader(message: Message) = with(binding) { val messageDate = message.date.toDate() + if (message.isScheduledDraft) { + scheduleAlert.setDescription( + context.getString( + R.string.scheduledEmailHeader, + message.date.toDate().format(FORMAT_DATE_DAY_FULL_MONTH_WITH_TIME), + ), + ) + scheduleSendIcon.isVisible = true + alertsGroup.isVisible = true + scheduleAlert.isVisible = true + } else { + scheduleSendIcon.isGone = true + alertsGroup.isGone = true + scheduleAlert.isGone = true + } + if (message.isDraft) { userAvatar.loadAvatar(AccountUtils.currentUser!!) expeditorName.apply { @@ -437,12 +455,18 @@ class ThreadAdapter( detailedMessageDate.text = mostDetailedDate(context, messageDate) } - private fun MessageViewHolder.bindAlerts(messageUid: String) = with(binding) { + private fun MessageViewHolder.bindAlerts(message: Message) = with(binding) { + message.draftResource?.let { draftResource -> + scheduleAlert.onAction1 { threadAdapterCallbacks?.onRescheduleClicked?.invoke(draftResource) } + } + + scheduleAlert.onAction2 { threadAdapterCallbacks?.onModifyScheduledClicked?.invoke(message) } + distantImagesAlert.onAction1 { bodyWebViewClient.unblockDistantResources() fullMessageWebViewClient.unblockDistantResources() - manuallyAllowedMessagesUids.add(messageUid) + manuallyAllowedMessagesUids.add(message.uid) reloadVisibleWebView() @@ -620,11 +644,11 @@ class ThreadAdapter( setOnClickListener { threadAdapterCallbacks?.onDeleteDraftClicked?.invoke(message) } } replyButton.apply { - isVisible = isExpanded + isVisible = isExpanded && message.isScheduledDraft.not() setOnClickListener { threadAdapterCallbacks?.onReplyClicked?.invoke(message) } } menuButton.apply { - isVisible = isExpanded + isVisible = isExpanded && message.isScheduledDraft.not() setOnClickListener { threadAdapterCallbacks?.onMenuClicked?.invoke(message) } } @@ -745,6 +769,8 @@ class ThreadAdapter( var navigateToDownloadProgressDialog: ((Attachment, AttachmentIntentType) -> Unit)? = null, var replyToCalendarEvent: ((AttendanceState, Message) -> Unit)? = null, var promptLink: ((String, ContextMenuType) -> Unit)? = null, + var onRescheduleClicked: ((String) -> Unit)? = null, + var onModifyScheduledClicked: ((Message) -> Unit)? = null, ) private enum class DisplayType(val layout: Int) { 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 0004b68e17..d981feee76 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 @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -36,6 +36,8 @@ import androidx.lifecycle.distinctUntilChanged import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import com.infomaniak.lib.core.utils.SentryLog import com.infomaniak.lib.core.utils.context +import com.infomaniak.lib.core.utils.getBackNavigationResult +import com.infomaniak.lib.core.utils.safeNavigate import com.infomaniak.lib.core.views.DividerItemDecorator import com.infomaniak.mail.MatomoMail.ACTION_ARCHIVE_NAME import com.infomaniak.mail.MatomoMail.ACTION_DELETE_NAME @@ -59,12 +61,16 @@ import com.infomaniak.mail.data.models.Attachable import com.infomaniak.mail.data.models.Attachment import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.SwissTransferFile +import com.infomaniak.mail.data.models.calendar.Attendee import com.infomaniak.mail.data.models.draft.Draft.DraftMode import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.databinding.FragmentThreadBinding import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.alertDialogs.* +import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialog.Companion.OPEN_DATE_AND_TIME_SCHEDULE_DIALOG +import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialog.Companion.SCHEDULE_DRAFT_RESULT +import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialogArgs import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folder.TwoPaneViewModel @@ -82,6 +88,7 @@ import com.infomaniak.mail.utils.extensions.AttachmentExtensions.openAttachment import dagger.hilt.android.AndroidEntryPoint import io.sentry.Sentry import io.sentry.SentryLevel +import java.util.Date import javax.inject.Inject import kotlin.math.absoluteValue import kotlin.math.min @@ -121,6 +128,12 @@ class ThreadFragment : Fragment() { @Inject lateinit var snackbarManager: SnackbarManager + @Inject + lateinit var dateAndTimeScheduleDialog: SelectDateAndTimeForScheduledDraftDialog + + @Inject + lateinit var confirmScheduledDraftModificationDialog: ConfirmScheduledDraftModificationDialog + private var _binding: FragmentThreadBinding? = null private val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView @@ -156,6 +169,8 @@ class ThreadFragment : Fragment() { observeThreadOpening() observeAutoAdvance() + setupBackActionHandler() + observeReportDisplayProblemResult() observeMessageOfUserToBlock() @@ -256,30 +271,11 @@ class ThreadFragment : Fragment() { args = DetailedContactBottomSheetDialogArgs(recipient, bimi).toBundle(), ) }, - onDraftClicked = { message -> - trackNewMessageEvent(OPEN_FROM_DRAFT_NAME) - twoPaneViewModel.navigateToNewMessage( - arrivedFromExistingDraft = true, - draftLocalUuid = message.draftLocalUuid, - draftResource = message.draftResource, - messageUid = message.uid, - ) - }, - onDeleteDraftClicked = { message -> - trackMessageActionsEvent("deleteDraft") - mainViewModel.currentMailbox.value?.let { mailbox -> deleteDraft(message, mailbox) } - }, - onAttachmentClicked = ::onAttachmentClicked, - onAttachmentOptionsClicked = { - safeNavigate( - resId = R.id.attachmentActionsBottomSheetDialog, - args = AttachmentActionsBottomSheetDialogArgs(it.localUuid, it is SwissTransferFile).toBundle(), - ) - }, - onDownloadAllClicked = { message -> - trackAttachmentActionsEvent("downloadAll") - downloadAllAttachments(message) - }, + onDraftClicked = ::openDraft, + onDeleteDraftClicked = ::deleteDraft, + onAttachmentClicked = ::onAttachableClicked, + onAttachmentOptionsClicked = ::navigateToAttachmentActions, + onDownloadAllClicked = ::downloadAllAttachments, onReplyClicked = { message -> trackMessageActionsEvent(ACTION_REPLY_NAME) replyTo(message) @@ -287,12 +283,7 @@ class ThreadFragment : Fragment() { onMenuClicked = { message -> message.navigateToActionsBottomSheet() }, onAllExpandedMessagesLoaded = ::scrollToFirstUnseenMessage, onSuperCollapsedBlockClicked = ::expandSuperCollapsedBlock, - navigateToAttendeeBottomSheet = { attendees -> - safeNavigate( - resId = R.id.attendeesBottomSheetDialog, - args = AttendeesBottomSheetDialogArgs(attendees.toTypedArray()).toBundle(), - ) - }, + navigateToAttendeeBottomSheet = ::navigateToAttendees, navigateToNewMessageActivity = { twoPaneViewModel.navigateToNewMessage(mailToUri = it) }, navigateToDownloadProgressDialog = { attachment, attachmentIntentType -> navigateToDownloadProgressDialog(attachment, attachmentIntentType, ThreadFragment::class.java.name) @@ -328,6 +319,8 @@ class ThreadFragment : Fragment() { ContextMenuType.PHONE -> phoneContextualMenuAlertDialog.show(data) } }, + onRescheduleClicked = ::rescheduleDraft, + onModifyScheduledClicked = ::modifyScheduledDraft, ), ) @@ -342,7 +335,22 @@ class ThreadFragment : Fragment() { threadAdapter.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } - private fun onAttachmentClicked(attachable: Attachable) { + private fun openDraft(message: Message) { + trackNewMessageEvent(OPEN_FROM_DRAFT_NAME) + twoPaneViewModel.navigateToNewMessage( + arrivedFromExistingDraft = true, + draftLocalUuid = message.draftLocalUuid, + draftResource = message.draftResource, + messageUid = message.uid, + ) + } + + private fun deleteDraft(message: Message) { + trackMessageActionsEvent("deleteDraft") + mainViewModel.currentMailbox.value?.let { mailbox -> threadViewModel.deleteDraft(message, mailbox) } + } + + private fun onAttachableClicked(attachable: Attachable) { when (attachable) { is Attachment -> { trackAttachmentActionsEvent(ACTION_OPEN_NAME) @@ -350,9 +358,9 @@ class ThreadFragment : Fragment() { context = requireContext(), navigateToDownloadProgressDialog = { attachment, attachmentIntentType -> navigateToDownloadProgressDialog( - attachment, - attachmentIntentType, - ThreadFragment::class.java.name, + attachment = attachment, + attachmentIntentType = attachmentIntentType, + currentClassName = ThreadFragment::class.java.name, ) }, snackbarManager = snackbarManager, @@ -365,6 +373,13 @@ class ThreadFragment : Fragment() { } } + private fun navigateToAttachmentActions(attachable: Attachable) { + safeNavigate( + resId = R.id.attachmentActionsBottomSheetDialog, + args = AttachmentActionsBottomSheetDialogArgs(attachable.localUuid, attachable is SwissTransferFile).toBundle(), + ) + } + private fun setupDialogs() { bindAlertToViewLifecycle(descriptionDialog) linkContextualMenuAlertDialog.initValues(snackbarManager) @@ -397,7 +412,7 @@ class ThreadFragment : Fragment() { mainViewModel.toggleLightThemeForMessage.observe(viewLifecycleOwner, threadAdapter::toggleLightMode) } - private fun observeThreadLive() { + private fun observeThreadLive() = with(binding) { threadViewModel.threadLive.observe(viewLifecycleOwner) { thread -> @@ -406,7 +421,7 @@ class ThreadFragment : Fragment() { return@observe } - binding.iconFavorite.apply { + iconFavorite.apply { setIconResource(if (thread.isFavorite) R.drawable.ic_star_filled else R.drawable.ic_star) val color = if (thread.isFavorite) { context.getColor(R.color.favoriteYellow) @@ -415,6 +430,9 @@ class ThreadFragment : Fragment() { } iconTint = ColorStateList.valueOf(color) } + + val shouldDisplayScheduledDraftActions = thread.numberOfScheduledDrafts == thread.messages.size + quickActionBar.init(if (shouldDisplayScheduledDraftActions) R.menu.scheduled_draft_menu else R.menu.message_menu) } } @@ -517,6 +535,24 @@ class ThreadFragment : Fragment() { mainViewModel.autoAdvanceThreadsUids.observe(viewLifecycleOwner, ::tryToAutoAdvance) } + private fun setupBackActionHandler() { + + getBackNavigationResult(OPEN_DATE_AND_TIME_SCHEDULE_DIALOG) { _: Boolean -> + dateAndTimeScheduleDialog.show( + title = getString(R.string.datePickerTitle), + onSchedule = { timestamp -> + localSettings.lastSelectedScheduleEpoch = timestamp + mainViewModel.rescheduleDraft(Date(timestamp)) + }, + onAbort = ::navigateToScheduleSendBottomSheet, + ) + } + + getBackNavigationResult(SCHEDULE_DRAFT_RESULT) { selectedScheduleEpoch: Long -> + mainViewModel.rescheduleDraft(Date(selectedScheduleEpoch)) + } + } + private fun displayThreadView() = with(binding) { emptyView.isGone = true threadView.isVisible = true @@ -581,6 +617,8 @@ class ThreadFragment : Fragment() { } private fun downloadAllAttachments(message: Message) { + trackAttachmentActionsEvent("downloadAll") + val truncatedSubject = message.subject?.let { it.substring(0..min(MAXIMUM_SUBJECT_LENGTH, it.lastIndex)) } if (message.hasAttachments) downloadAttachments(message, allAttachmentsFileName(truncatedSubject ?: "")) @@ -679,6 +717,54 @@ class ThreadFragment : Fragment() { reassignMessagesLive(twoPaneViewModel.currentThreadUid.value!!) } + private fun navigateToAttendees(attendees: List) { + safeNavigate( + resId = R.id.attendeesBottomSheetDialog, + args = AttendeesBottomSheetDialogArgs(attendees.toTypedArray()).toBundle(), + ) + } + + private fun rescheduleDraft(draftResource: String) { + mainViewModel.draftResource = draftResource + navigateToScheduleSendBottomSheet() + } + + private fun navigateToScheduleSendBottomSheet() { + safeNavigate( + resId = R.id.scheduleSendBottomSheetDialog, + args = ScheduleSendBottomSheetDialogArgs( + lastSelectedScheduleEpoch = localSettings.lastSelectedScheduleEpoch ?: 0L, + isCurrentMailboxFree = mainViewModel.currentMailbox.value?.isFreeMailbox ?: true, + ).toBundle(), + currentClassName = ThreadFragment::class.java.name, + ) + } + + private fun modifyScheduledDraft(message: Message) { + confirmScheduledDraftModificationDialog.show( + title = getString(R.string.editSendTitle), + description = getString(R.string.editSendDescription), + onPositiveButtonClicked = { + val unscheduleDraftUrl = message.unscheduleDraftUrl + val draftResource = message.draftResource + + if (unscheduleDraftUrl != null && draftResource != null) { + mainViewModel.modifyScheduledDraft( + unscheduleDraftUrl = unscheduleDraftUrl, + onSuccess = { + trackNewMessageEvent(OPEN_FROM_DRAFT_NAME) + twoPaneViewModel.navigateToNewMessage( + arrivedFromExistingDraft = true, + draftResource = draftResource, + messageUid = message.uid, + ) + }, + ) + } + }, + ) + } + private fun shouldLoadDistantResources(messageUid: String): Boolean { val isMessageSpecificallyAllowed = threadAdapter.isMessageUidManuallyAllowed(messageUid) return (isMessageSpecificallyAllowed && isNotInSpam) || shouldLoadDistantResources() diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt index 9989950a76..e240a2555f 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -23,7 +23,7 @@ import android.content.res.TypedArray import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout -import androidx.annotation.ColorInt +import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.annotation.StyleableRes @@ -31,7 +31,7 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.core.view.isGone import androidx.core.view.isVisible import com.infomaniak.lib.core.utils.getAttributes -import com.infomaniak.lib.core.utils.setPaddingRelative +import com.infomaniak.lib.core.utils.setMarginsRelative import com.infomaniak.mail.R import com.infomaniak.mail.databinding.ItemBottomSheetActionBinding import com.infomaniak.mail.utils.AccountUtils @@ -47,19 +47,18 @@ class ActionItemView @JvmOverloads constructor( init { attrs?.getAttributes(context, R.styleable.ActionItemView) { with(binding) { - button.apply { - icon = getDrawable(R.styleable.ActionItemView_icon) - getColorStateList(R.styleable.ActionItemView_iconColor)?.let(::setIconTint) - text = getString(R.styleable.ActionItemView_text) - getColorStateList(R.styleable.ActionItemView_textColor)?.let(::setTextColor) - - val iconHorizontalPadding = getDimenOrNull(R.styleable.ActionItemView_iconPaddingHorizontal) - val iconPaddingStart = iconHorizontalPadding ?: getDimenOrNull(R.styleable.ActionItemView_iconPaddingStart) - val iconPaddingEnd = iconHorizontalPadding ?: getDimenOrNull(R.styleable.ActionItemView_iconPaddingEnd) - - iconPaddingEnd?.let { iconPadding = it } - setPaddingRelative(start = iconPaddingStart) - } + icon.setImageDrawable(getDrawable(R.styleable.ActionItemView_icon)) + getColorStateList(R.styleable.ActionItemView_iconColor)?.let(::setIconTint) + + title.text = getString(R.styleable.ActionItemView_title) + getColorStateList(R.styleable.ActionItemView_titleColor)?.let(::setTitleColor) + + val iconHorizontalPadding = getDimenOrNull(R.styleable.ActionItemView_iconPaddingHorizontal) + val iconPaddingStart = iconHorizontalPadding ?: getDimenOrNull(R.styleable.ActionItemView_iconPaddingStart) + val iconPaddingEnd = iconHorizontalPadding ?: getDimenOrNull(R.styleable.ActionItemView_iconPaddingEnd) + + icon.setMarginsRelative(start = iconPaddingStart) + description.setMarginsRelative(end = iconPaddingEnd) divider.apply { isVisible = getBoolean(R.styleable.ActionItemView_visibleDivider, true) @@ -68,31 +67,40 @@ class ActionItemView @JvmOverloads constructor( if (getBoolean(R.styleable.ActionItemView_staffOnly, false)) { if (isInEditMode || AccountUtils.currentUser?.isStaff == true) { - button.apply { - setIconTintResource(R.color.staffOnlyColor) - setTextColor(AppCompatResources.getColorStateList(context, R.color.staffOnlyColor)) - } + icon.imageTintList = AppCompatResources.getColorStateList(context, R.color.staffOnlyColor) + title.setTextColor(AppCompatResources.getColorStateList(context, R.color.staffOnlyColor)) } else { isGone = true } } - if (getBoolean(R.styleable.ActionItemView_keepIconTint, false)) button.iconTint = null + if (getBoolean(R.styleable.ActionItemView_keepIconTint, false)) icon.imageTintList = null + + description.isVisible = true + + if (getBoolean(R.styleable.ActionItemView_showActionIcon, false)) actionIcon.isVisible = true } } } override fun setOnClickListener(onClickListener: OnClickListener?) { - binding.button.setOnClickListener(onClickListener) + findViewById(R.id.itemBottomSheetAction).setOnClickListener(onClickListener) } - fun setIconResource(@DrawableRes iconResourceId: Int) = binding.button.setIconResource(iconResourceId) + fun setIconResource(@DrawableRes iconResourceId: Int) = binding.icon.setImageResource(iconResourceId) - fun setIconTint(@ColorInt color: Int) { - binding.button.iconTint = ColorStateList.valueOf(color) + private fun setIconTint(color: ColorStateList) { + binding.icon.imageTintList = color } - fun setText(@StringRes textResourceId: Int) = binding.button.setText(textResourceId) + fun setTitle(@StringRes textResourceId: Int) = binding.title.setText(textResourceId) + + private fun setTitleColor(color: ColorStateList) = binding.title.setTextColor(color) + + fun setDescription(text: String) = with(binding.description) { + this.text = text + isVisible = true + } fun setDividerVisibility(isVisible: Boolean) { binding.divider.isVisible = isVisible diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt index de26b453eb..b7570be3ff 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt @@ -112,19 +112,19 @@ abstract class MailActionsBottomSheetDialog : ActionsBottomSheetDialog() { fun setMarkAsReadUi(isSeen: Boolean) = with(binding.markAsReadUnread) { val (readIconRes, readTextRes) = computeUnreadStyle(isSeen) setIconResource(readIconRes) - setText(readTextRes) + setTitle(readTextRes) } fun setFavoriteUi(isFavorite: Boolean) = with(binding.favorite) { val (favoriteIconRes, favoriteText) = computeFavoriteStyle(isFavorite) setIconResource(favoriteIconRes) - setText(favoriteText) + setTitle(favoriteText) } fun setArchiveUi(isFromArchive: Boolean) = with(binding.archive) { if (isFromArchive) { setIconResource(R.drawable.ic_drawer_inbox) - setText(R.string.actionMoveToInbox) + setTitle(R.string.actionMoveToInbox) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index 2d416ef7a6..0cccbc3418 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -74,7 +74,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { if (requireContext().isNightModeEnabled()) { binding.lightTheme.apply { isVisible = true - setText(if (isThemeTheSame) R.string.actionViewInLight else R.string.actionViewInDark) + setTitle(if (isThemeTheSame) R.string.actionViewInLight else R.string.actionViewInDark) setClosingOnClickListener { mainViewModel.toggleLightThemeForMessage.value = message } } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 3ccdd2f2dc..516d89cc30 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -131,14 +131,14 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { val (spamIcon, spamText) = getSpamIconAndText(isFromSpam) binding.spam.apply { setIconResource(spamIcon) - setText(spamText) + setTitle(spamText) } val favoriteIcon = if (shouldFavorite) R.drawable.ic_star else R.drawable.ic_unstar val favoriteText = if (shouldFavorite) R.string.actionStar else R.string.actionUnstar binding.favorite.apply { setIconResource(favoriteIcon) - setText(favoriteText) + setTitle(favoriteText) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 30c2560abd..bf2b0b6005 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -96,7 +96,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { R.string.actionReportJunk to R.drawable.ic_report_junk } - setText(text) + setTitle(text) setIconResource(icon) isVisible = true } diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageActivity.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageActivity.kt index 5293f93816..e6f756f21f 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageActivity.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageActivity.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -125,7 +125,7 @@ class NewMessageActivity : BaseActivity() { private fun saveDraft() { val draftSaveConfiguration = DraftSaveConfiguration( - action = if (newMessageViewModel.shouldSendInsteadOfSave) DraftAction.SEND else DraftAction.SAVE, + action = newMessageViewModel.draftAction, isFinishing = isFinishing, isTaskRoot = isTaskRoot, startWorkerCallback = ::startWorker, diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageEditorManager.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageEditorManager.kt index 1f64e879ac..cc084815d1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageEditorManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageEditorManager.kt @@ -126,8 +126,8 @@ class NewMessageEditorManager @Inject constructor(private val insertLinkDialog: } editorActions.isGone = isExpanded - sendButton.isGone = isExpanded - formatOptionsScrollView.isVisible = isExpanded + sendLayout.isGone = isExpanded + formatOptionsLayout.isVisible = isExpanded } fun observeEditorStatus(): Unit = with(binding) { 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 9c27a642ee..09aa4d11a3 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 @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -43,11 +43,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import com.infomaniak.lib.core.utils.FilePicker +import com.infomaniak.lib.core.utils.* import com.infomaniak.lib.core.utils.SnackbarUtils.showSnackbar -import com.infomaniak.lib.core.utils.getBackNavigationResult -import com.infomaniak.lib.core.utils.isNightModeEnabled -import com.infomaniak.lib.core.utils.showToast import com.infomaniak.lib.richhtmleditor.StatusCommand.* import com.infomaniak.mail.MatomoMail.OPEN_FROM_DRAFT_NAME import com.infomaniak.mail.MatomoMail.trackAttachmentActionsEvent @@ -57,6 +54,7 @@ import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.LocalSettings.ExternalContent import com.infomaniak.mail.data.models.Attachment import com.infomaniak.mail.data.models.AttachmentDisposition +import com.infomaniak.mail.data.models.FeatureFlag import com.infomaniak.mail.data.models.draft.Draft import com.infomaniak.mail.data.models.draft.Draft.DraftAction import com.infomaniak.mail.data.models.draft.Draft.DraftMode @@ -65,6 +63,10 @@ import com.infomaniak.mail.databinding.FragmentNewMessageBinding 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_DATE_AND_TIME_SCHEDULE_DIALOG +import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialog.Companion.SCHEDULE_DRAFT_RESULT +import com.infomaniak.mail.ui.bottomSheetDialogs.ScheduleSendBottomSheetDialogArgs import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.thread.AttachmentAdapter import com.infomaniak.mail.ui.newMessage.NewMessageRecipientFieldsManager.FieldType @@ -89,6 +91,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import java.util.Date import javax.inject.Inject import com.google.android.material.R as RMaterial @@ -150,6 +153,9 @@ class NewMessageFragment : Fragment() { @Inject lateinit var snackbarManager: SnackbarManager + @Inject + lateinit var dateAndTimeScheduleDialog: SelectDateAndTimeForScheduledDraftDialog + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return FragmentNewMessageBinding.inflate(inflater, container, false).also { _binding = it }.root } @@ -180,12 +186,13 @@ class NewMessageFragment : Fragment() { observeRecipients() observeAttachments() observeImportAttachmentsResult() - observeOpenAttachment() observeBodyLoader() observeUiSignature() observeUiQuote() observeShimmering() + setupBackActionHandler() + with(editorManager) { observeEditorFormatActions() observeEditorStatus() @@ -204,12 +211,31 @@ class NewMessageFragment : Fragment() { observeContacts() observeCcAndBccVisibility() } + + observeScheduledDraftsFeatureFlagUpdates() } - private fun observeShimmering() { - lifecycleScope.launch { - newMessageViewModel.isShimmering.collect(::setShimmerVisibility) + private fun setupBackActionHandler() { + + fun scheduleDraft(timestamp: Long) { + newMessageViewModel.setScheduleDate(Date(timestamp)) + tryToSendEmail(scheduled = true) } + + getBackNavigationResult(OPEN_DATE_AND_TIME_SCHEDULE_DIALOG) { _: Boolean -> + dateAndTimeScheduleDialog.show( + title = getString(R.string.datePickerTitle), + onSchedule = { timestamp -> + localSettings.lastSelectedScheduleEpoch = timestamp + scheduleDraft(timestamp) + }, + onAbort = ::navigateToScheduleSendBottomSheet, + ) + } + + getBackNavigationResult(SCHEDULE_DRAFT_RESULT, ::scheduleDraft) + + getBackNavigationResult(AttachmentExtensions.DOWNLOAD_ATTACHMENT_RESULT, ::startActivity) } private fun setShimmerVisibility(isShimmering: Boolean) = with(binding) { @@ -339,7 +365,7 @@ class NewMessageFragment : Fragment() { initEditorUi() - setupSendButton() + setupSendButtons() externalsManager.setupExternalBanner() scrim.setOnClickListener { @@ -622,10 +648,6 @@ class NewMessageFragment : Fragment() { } } - private fun observeOpenAttachment() { - getBackNavigationResult(AttachmentExtensions.DOWNLOAD_ATTACHMENT_RESULT, ::startActivity) - } - private fun observeImportAttachmentsResult() = with(newMessageViewModel) { importAttachmentsResult.observe(viewLifecycleOwner) { result -> if (result == ImportationResult.ATTACHMENTS_TOO_BIG) showSnackbar(R.string.attachmentFileLimitReached) @@ -658,6 +680,17 @@ class NewMessageFragment : Fragment() { } } + private fun observeShimmering() = lifecycleScope.launch { + newMessageViewModel.isShimmering.collect(::setShimmerVisibility) + } + + private fun observeScheduledDraftsFeatureFlagUpdates() { + newMessageViewModel.currentMailboxLive.observeNotNull(viewLifecycleOwner) { mailbox -> + val isScheduledDraftsEnabled = mailbox.featureFlags.contains(FeatureFlag.SCHEDULE_DRAFTS) + binding.scheduleButton.isVisible = isScheduledDraftsEnabled + } + } + override fun onStart() { super.onStart() newMessageViewModel.discardOldBodyAndSubjectChannelMessages() @@ -680,24 +713,40 @@ class NewMessageFragment : Fragment() { newMessageViewModel.deleteAttachment(position) } - private fun setupSendButton() = with(binding) { + private fun setupSendButtons() = with(binding) { newMessageViewModel.isSendingAllowed.observe(viewLifecycleOwner) { + scheduleButton.isEnabled = it sendButton.isEnabled = it } + scheduleButton.setOnClickListener { navigateToScheduleSendBottomSheet() } + sendButton.setOnClickListener { tryToSendEmail() } } - private fun tryToSendEmail() { + private fun navigateToScheduleSendBottomSheet() { + safeNavigate( + resId = R.id.scheduleSendBottomSheetDialog, + args = ScheduleSendBottomSheetDialogArgs( + lastSelectedScheduleEpoch = localSettings.lastSelectedScheduleEpoch ?: 0L, + isCurrentMailboxFree = newMessageViewModel.currentMailbox.isFreeMailbox, + ).toBundle(), + ) + } + + private fun tryToSendEmail(scheduled: Boolean = false) { fun setSnackbarActivityResult() { val resultIntent = Intent() - resultIntent.putExtra(MainActivity.DRAFT_ACTION_KEY, DraftAction.SEND.name) + resultIntent.putExtra( + MainActivity.DRAFT_ACTION_KEY, + if (scheduled) DraftAction.SCHEDULE.name else DraftAction.SEND.name, + ) requireActivity().setResult(AppCompatActivity.RESULT_OK, resultIntent) } fun sendEmail() { - newMessageViewModel.shouldSendInsteadOfSave = true + newMessageViewModel.draftAction = if (scheduled) DraftAction.SCHEDULE else DraftAction.SEND setSnackbarActivityResult() requireActivity().finishAppAndRemoveTaskIfNeeded() } @@ -713,6 +762,7 @@ class NewMessageFragment : Fragment() { trackNewMessageEvent("sendWithoutSubjectConfirm") sendEmail() }, + onCancel = { if (scheduled) newMessageViewModel.resetScheduledDate() }, ) } else { sendEmail() 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 7b77b0a769..7f4c39edbb 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 @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -81,6 +81,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import org.jsoup.nodes.Document +import java.util.Date import javax.inject.Inject @HiltViewModel @@ -134,7 +135,7 @@ class NewMessageViewModel @Inject constructor( var isAutoCompletionOpened = false var isEditorExpanded = false var isExternalBannerManuallyClosed = false - var shouldSendInsteadOfSave = false + var draftAction = DraftAction.SAVE var signaturesCount = 0 private var isNewMessage = false @@ -855,6 +856,17 @@ class NewMessageViewModel @Inject constructor( }.onFailure(Sentry::captureException) } + fun setScheduleDate(date: Date?) = viewModelScope.launch(ioDispatcher) { + val localUuid = draftLocalUuid ?: return@launch + mailboxContentRealm().write { + DraftController.getDraft(localUuid, realm = this)?.also { draft -> + draft.scheduleDate = date?.format(FORMAT_SCHEDULE_MAIL) + } + } + } + + fun resetScheduledDate() = setScheduleDate(date = null) + fun storeBodyAndSubject(subject: String, html: String) { globalCoroutineScope.launch(ioDispatcher) { _subjectAndBodyChannel.send(SubjectAndBodyData(subject, html, channelExpirationIdTarget)) @@ -1026,19 +1038,28 @@ class NewMessageViewModel @Inject constructor( isTaskRoot: Boolean, ) = withContext(mainDispatcher) { when (action) { - DraftAction.SAVE -> { - if (isFinishing) { - if (isTaskRoot) appContext.showToast(R.string.snackbarDraftSaving) - } else { - appContext.showToast(R.string.snackbarDraftSaving) - } - } - DraftAction.SEND -> { - if (isTaskRoot) appContext.showToast(R.string.snackbarEmailSending) - } + DraftAction.SAVE -> showSaveToast(isFinishing, isTaskRoot) + DraftAction.SEND -> showSendToast(isTaskRoot) + DraftAction.SCHEDULE -> showScheduleToast(isTaskRoot) + } + } + + private fun showSaveToast(isFinishing: Boolean, isTaskRoot: Boolean) { + if (isFinishing) { + if (isTaskRoot) appContext.showToast(R.string.snackbarDraftSaving) + } else { + appContext.showToast(R.string.snackbarDraftSaving) } } + private fun showSendToast(isTaskRoot: Boolean) { + if (isTaskRoot) appContext.showToast(R.string.snackbarEmailSending) + } + + private fun showScheduleToast(isTaskRoot: Boolean) { + if (isTaskRoot) appContext.showToast(R.string.snackbarScheduling) + } + private fun MutableLiveData.addRecipientThenSetValue(recipient: Recipient) { updateRecipientsThenSetValue { it.add(recipient) } } diff --git a/app/src/main/java/com/infomaniak/mail/utils/MailDateFormatUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/MailDateFormatUtils.kt index 8687a89a05..c55fa8f2ba 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/MailDateFormatUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/MailDateFormatUtils.kt @@ -47,10 +47,10 @@ object MailDateFormatUtils { } } - fun mostDetailedDate(context: Context, date: Date): String = with(date) { + fun mostDetailedDate(context: Context, date: Date, format: String = FORMAT_EMAIL_DATE_LONG_DATE): String = with(date) { return@with context.getString( R.string.messageDetailsDateAt, - format(FORMAT_EMAIL_DATE_LONG_DATE), + format(format), format(FORMAT_EMAIL_DATE_HOUR), ) } diff --git a/app/src/main/java/com/infomaniak/mail/utils/Utils.kt b/app/src/main/java/com/infomaniak/mail/utils/Utils.kt index 92089ed6b6..59f01f3753 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/Utils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/Utils.kt @@ -54,8 +54,12 @@ object Utils { fun colorToHexRepresentation(color: Int) = "#" + color.toHexString().substring(2 until 8) - fun isPermanentDeleteFolder(role: FolderRole?): Boolean { - return role == FolderRole.DRAFT || role == FolderRole.SPAM || role == FolderRole.TRASH + fun isPermanentDeleteFolder(role: FolderRole?): Boolean = when (role) { + FolderRole.SCHEDULED_DRAFTS, + FolderRole.DRAFT, + FolderRole.SPAM, + FolderRole.TRASH -> true + else -> false } fun kSyncAccountUri(accountName: String): Uri = "content://com.infomaniak.sync.accounts/account/$accountName".toUri() diff --git a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt index bfd5dc9d54..1862e6df2a 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2022-2024 Infomaniak Network SA + * Copyright (C) 2022-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 @@ -53,6 +53,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature +import androidx.work.Data import com.airbnb.lottie.LottieAnimationView import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.SimpleColorFilter @@ -114,13 +115,14 @@ import io.realm.kotlin.query.RealmQuery import io.realm.kotlin.query.Sort import io.realm.kotlin.types.RealmInstant import io.realm.kotlin.types.RealmObject -import kotlinx.serialization.encodeToString import org.jsoup.nodes.Document import java.util.Calendar import java.util.Date import java.util.Scanner import kotlin.math.roundToInt +const val IK_FOLDER = ".ik" + //region Type alias // Explanation of this Map: Map> typealias MergedContactDictionary = Map> @@ -331,32 +333,42 @@ fun List.flattenFolderChildrenAndRemoveMessages(dismissHiddenChildren: B if (isEmpty()) return this - tailrec fun formatFolderWithAllChildren( - inputList: MutableList, - outputList: MutableList = mutableListOf(), - ): List { + return formatFolderWithAllChildren(dismissHiddenChildren, toMutableList()) +} - val folder = inputList.removeAt(0) +private tailrec fun formatFolderWithAllChildren( + dismissHiddenChildren: Boolean, + inputList: MutableList, + outputList: MutableList = mutableListOf(), +): List { - val children = if (folder.isManaged()) { - outputList.add(folder.copyFromRealm(depth = 1u)) - with(folder.children) { - (if (dismissHiddenChildren) query("${Folder::isHidden.name} == false") else query()) - .sortFolders() - .find() - } - } else { - outputList.add(folder) - (if (dismissHiddenChildren) folder.children.filter { !it.isHidden } else folder.children) - .sortFolders() - } + val folder = inputList.removeAt(0) + + /* + * There are two types of folders: + * - user's folders (with or without a role) + * - hidden IK folders (ScheduledDrafts, Snoozed, etc…) + * + * We want to display the user's folders, and also the IK folders for which we handle the role. + * IK folders where we don't handle the role are dismissed. + */ + fun shouldThisFolderBeAdded(): Boolean = folder.path.startsWith(IK_FOLDER).not() || folder.role != null - inputList.addAll(index = 0, children) + val children = if (folder.isManaged()) { + if (shouldThisFolderBeAdded()) outputList.add(folder.copyFromRealm(depth = 1u)) - return if (inputList.isEmpty()) outputList else formatFolderWithAllChildren(inputList, outputList) + with(folder.children) { + (if (dismissHiddenChildren) query("${Folder::isHidden.name} == false") else query()).sortFolders().find() + } + } else { + if (shouldThisFolderBeAdded()) outputList.add(folder) + + (if (dismissHiddenChildren) folder.children.filter { !it.isHidden } else folder.children).sortFolders() } - return formatFolderWithAllChildren(toMutableList()) + inputList.addAll(index = 0, children) + + return if (inputList.isEmpty()) outputList else formatFolderWithAllChildren(dismissHiddenChildren, inputList, outputList) } /** @@ -398,10 +410,10 @@ fun DescriptionAlertDialog.deleteWithConfirmationPopup( folderRole: FolderRole?, count: Int, displayLoader: Boolean = true, - onDismiss: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, callback: () -> Unit, ) = if (isPermanentDeleteFolder(folderRole) && folderRole != FolderRole.DRAFT) { // We don't want to display the popup for Drafts - showDeletePermanentlyDialog(count, displayLoader, callback, onDismiss) + showDeletePermanentlyDialog(count, displayLoader, callback, onCancel) } else { callback() } @@ -576,7 +588,7 @@ fun Context.postfixWithTag( tag, getTagsPaint(this), ellipsizeConfiguration.maxWidth, - ellipsizeConfiguration.truncateAt + ellipsizeConfiguration.truncateAt, ).toString() } ?: tag } @@ -682,3 +694,5 @@ fun WebView.enableAlgorithmicDarkening(isEnabled: Boolean) { WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, isEnabled) } } + +fun Data.getLongOrNull(key: String) = getLong(key, 0L).run { if (this == 0L) null else this } diff --git a/app/src/main/java/com/infomaniak/mail/views/BottomQuickActionBarView.kt b/app/src/main/java/com/infomaniak/mail/views/BottomQuickActionBarView.kt index 3b7ef5e881..cce1fed226 100644 --- a/app/src/main/java/com/infomaniak/mail/views/BottomQuickActionBarView.kt +++ b/app/src/main/java/com/infomaniak/mail/views/BottomQuickActionBarView.kt @@ -25,6 +25,7 @@ import android.view.MenuInflater import android.widget.ActionMenuView import android.widget.FrameLayout import androidx.annotation.DrawableRes +import androidx.annotation.MenuRes import androidx.annotation.StringRes import androidx.core.content.res.getResourceIdOrThrow import androidx.core.view.get @@ -37,7 +38,7 @@ import com.infomaniak.mail.databinding.ViewBottomQuickActionBarBinding class BottomQuickActionBarView @JvmOverloads constructor( context: Context, - attrs: AttributeSet? = null, + val attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : FrameLayout(context, attrs, defStyleAttr) { @@ -47,8 +48,16 @@ class BottomQuickActionBarView @JvmOverloads constructor( private val menu: Menu by lazy { ActionMenuView(context).menu } init { + init() + } + + fun init(@MenuRes menuRes: Int? = null) { attrs?.getAttributes(context, R.styleable.BottomQuickActionBarView) { - MenuInflater(context).inflate(getResourceIdOrThrow(R.styleable.BottomQuickActionBarView_menu), menu) + + val menuResId = menuRes + ?: runCatching { getResourceIdOrThrow(R.styleable.BottomQuickActionBarView_menu) }.getOrNull() + ?: return@getAttributes + MenuInflater(context).inflate(menuResId, menu) buttons.forEachIndexed { index, button -> if (index >= menu.size) { diff --git a/app/src/main/java/com/infomaniak/mail/workers/DraftsActionsWorker.kt b/app/src/main/java/com/infomaniak/mail/workers/DraftsActionsWorker.kt index 9a216f8590..4164b0ddab 100644 --- a/app/src/main/java/com/infomaniak/mail/workers/DraftsActionsWorker.kt +++ b/app/src/main/java/com/infomaniak/mail/workers/DraftsActionsWorker.kt @@ -32,6 +32,8 @@ import com.infomaniak.mail.R import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.cache.RealmDatabase import com.infomaniak.mail.data.cache.mailboxContent.DraftController +import com.infomaniak.mail.data.cache.mailboxContent.FolderController +import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController import com.infomaniak.mail.data.models.AppSettings import com.infomaniak.mail.data.models.AttachmentUploadStatus @@ -70,6 +72,8 @@ class DraftsActionsWorker @AssistedInject constructor( private val mainApplication: MainApplication, private val notificationManagerCompat: NotificationManagerCompat, private val notificationUtils: NotificationUtils, + private val folderController: FolderController, + private val refreshController: RefreshController, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : BaseCoroutineWorker(appContext, params) { @@ -124,10 +128,12 @@ class DraftsActionsWorker @AssistedInject constructor( // List containing the callback function to update/delete drafts in Realm // We keep these Realm changes in a List to execute them all in a unique Realm transaction at the end of the worker val realmActionsOnDraft = mutableListOf<(MutableRealm) -> Unit>() - val scheduledDates = mutableListOf() + val scheduledMessagesEtops = mutableListOf() var trackedDraftErrorMessageResId: Int? = null var remoteUuidOfTrackedDraft: String? = null var trackedDraftAction: DraftAction? = null + var trackedScheduledDraftDate: String? = null + var trackedUnscheduledDraftUrl: String? = null var isTrackedDraftSuccess: Boolean? = null val drafts = draftController.getDraftsWithActions(mailboxContentRealm) @@ -137,8 +143,12 @@ class DraftsActionsWorker @AssistedInject constructor( var haveAllDraftsSucceeded = true drafts.asReversed().forEach { draft -> + val isTargetDraft = draft.localUuid == draftLocalUuid - if (isTargetDraft) trackedDraftAction = draft.action + if (isTargetDraft) { + trackedDraftAction = draft.action + trackedScheduledDraftDate = draft.scheduleDate + } runCatching { val updatedDraft = uploadAttachmentsWithMutex(draft, mailbox, draftController, mailboxContentRealm) @@ -146,10 +156,12 @@ class DraftsActionsWorker @AssistedInject constructor( if (isSuccess) { if (isTargetDraft) { remoteUuidOfTrackedDraft = savedDraftUuid + trackedUnscheduledDraftUrl = unscheduleDraftUrl isTrackedDraftSuccess = true } - scheduledDate?.let(scheduledDates::add) + scheduledMessageEtop?.let(scheduledMessagesEtops::add) realmActionOnDraft?.let(realmActionsOnDraft::add) + } else if (isTargetDraft) { trackedDraftErrorMessageResId = errorMessageResId!! isTrackedDraftSuccess = false @@ -203,12 +215,14 @@ class DraftsActionsWorker @AssistedInject constructor( showDraftErrorNotification(isTrackedDraftSuccess, trackedDraftErrorMessageResId, trackedDraftAction) return computeResult( - scheduledDates, + scheduledMessagesEtops, haveAllDraftsSucceeded, isTrackedDraftSuccess, remoteUuidOfTrackedDraft, trackedDraftAction, trackedDraftErrorMessageResId, + trackedScheduledDraftDate, + trackedUnscheduledDraftUrl, ) } @@ -232,15 +246,19 @@ class DraftsActionsWorker @AssistedInject constructor( } private fun computeResult( - scheduledDates: MutableList, + scheduledMessagesEtops: MutableList, haveAllDraftsSucceeded: Boolean, isTrackedDraftSuccess: Boolean?, remoteUuidOfTrackedDraft: String?, trackedDraftAction: DraftAction?, trackedDraftErrorMessageResId: Int?, + trackedScheduledDraftDate: String?, + trackedUnscheduleDraftUrl: String?, ): Result { - val biggestScheduledDate = scheduledDates.mapNotNull { dateFormatWithTimezone.parse(it)?.time }.maxOrNull() + val biggestScheduledMessagesEtop = scheduledMessagesEtops.mapNotNull { + dateFormatWithTimezone.parse(it)?.time + }.maxOrNull() return if (haveAllDraftsSucceeded || isTrackedDraftSuccess == true) { val outputData = if (isSnackbarFeedbackNeeded) { @@ -248,8 +266,10 @@ class DraftsActionsWorker @AssistedInject constructor( REMOTE_DRAFT_UUID_KEY to draftLocalUuid?.let { remoteUuidOfTrackedDraft }, ASSOCIATED_MAILBOX_UUID_KEY to draftLocalUuid?.let { mailbox.uuid }, RESULT_DRAFT_ACTION_KEY to draftLocalUuid?.let { trackedDraftAction?.name }, - BIGGEST_SCHEDULED_DATE_KEY to biggestScheduledDate, + BIGGEST_SCHEDULED_MESSAGES_ETOP_KEY to biggestScheduledMessagesEtop, RESULT_USER_ID_KEY to userId, + SCHEDULED_DRAFT_DATE_KEY to trackedScheduledDraftDate, + UNSCHEDULE_DRAFT_URL_KEY to trackedUnscheduleDraftUrl, ) } else { Data.EMPTY @@ -259,7 +279,7 @@ class DraftsActionsWorker @AssistedInject constructor( val outputData = if (isSnackbarFeedbackNeeded) { workDataOf( ERROR_MESSAGE_RESID_KEY to trackedDraftErrorMessageResId, - BIGGEST_SCHEDULED_DATE_KEY to biggestScheduledDate, + BIGGEST_SCHEDULED_MESSAGES_ETOP_KEY to biggestScheduledMessagesEtop, RESULT_USER_ID_KEY to userId, ) } else { @@ -271,7 +291,8 @@ class DraftsActionsWorker @AssistedInject constructor( data class DraftActionResult( val realmActionOnDraft: ((MutableRealm) -> Unit)?, - val scheduledDate: String?, + val scheduledMessageEtop: String?, + val unscheduleDraftUrl: String?, val errorMessageResId: Int?, val savedDraftUuid: String?, val isSuccess: Boolean, @@ -280,7 +301,8 @@ class DraftsActionsWorker @AssistedInject constructor( private suspend fun executeDraftAction(draft: Draft, mailboxUuid: String, isFirstTime: Boolean = true): DraftActionResult { var realmActionOnDraft: ((MutableRealm) -> Unit)? = null - var scheduledDate: String? = null + var scheduledMessageEtop: String? = null + var scheduleDraftAction: String? = null var savedDraftUuid: String? = null SentryDebug.addDraftBreadcrumbs(draft, step = "executeDraftAction (action = ${draft.action?.name.toString()})") @@ -299,7 +321,8 @@ class DraftsActionsWorker @AssistedInject constructor( return DraftActionResult( realmActionOnDraft = null, - scheduledDate = null, + scheduledMessageEtop = null, + unscheduleDraftUrl = null, errorMessageResId = R.string.errorCorruptAttachment, savedDraftUuid = null, isSuccess = false, @@ -315,7 +338,7 @@ class DraftsActionsWorker @AssistedInject constructor( action = null } } - scheduledDate = dateFormatWithTimezone.format(Date()) + scheduledMessageEtop = dateFormatWithTimezone.format(Date()) savedDraftUuid = data.draftRemoteUuid } ?: run { retryWithNewIdentityOrThrow(draft, mailboxUuid, isFirstTime) @@ -325,8 +348,8 @@ class DraftsActionsWorker @AssistedInject constructor( suspend fun executeSendAction() = with(ApiRepository.sendDraft(mailboxUuid, draft, okHttpClient)) { when { isSuccess() -> { - scheduledDate = data?.scheduledDate realmActionOnDraft = deleteDraftCallback(draft) + scheduledMessageEtop = data?.scheduledMessageEtop } error?.exception is SerializationException -> { realmActionOnDraft = deleteDraftCallback(draft) @@ -342,15 +365,38 @@ class DraftsActionsWorker @AssistedInject constructor( } } + suspend fun executeScheduleAction() = with(ApiRepository.scheduleDraft(mailboxUuid, draft, okHttpClient)) { + when { + isSuccess() -> { + realmActionOnDraft = deleteDraftCallback(draft) + scheduledMessageEtop = dateFormatWithTimezone.format(Date()) + scheduleDraftAction = data?.unscheduleDraftUrl + } + error?.exception is SerializationException -> { + realmActionOnDraft = deleteDraftCallback(draft) + Sentry.captureMessage("Return JSON for SendScheduleDraft API call was modified", SentryLevel.ERROR) { scope -> + scope.setExtra("Is data null ?", "${data == null}") + scope.setExtra("Error code", error?.code.toString()) + scope.setExtra("Error description", error?.description.toString()) + } + } + else -> { + retryWithNewIdentityOrThrow(draft, mailboxUuid, isFirstTime) + } + } + } + when (draft.action) { DraftAction.SAVE -> executeSaveAction() DraftAction.SEND -> executeSendAction() + DraftAction.SCHEDULE -> executeScheduleAction() else -> Unit } return DraftActionResult( realmActionOnDraft = realmActionOnDraft, - scheduledDate = scheduledDate, + scheduledMessageEtop = scheduledMessageEtop, + unscheduleDraftUrl = scheduleDraftAction, errorMessageResId = null, savedDraftUuid = savedDraftUuid, isSuccess = true, @@ -442,7 +488,9 @@ class DraftsActionsWorker @AssistedInject constructor( const val REMOTE_DRAFT_UUID_KEY = "remoteDraftUuidKey" const val ASSOCIATED_MAILBOX_UUID_KEY = "associatedMailboxUuidKey" const val RESULT_DRAFT_ACTION_KEY = "resultDraftActionKey" - const val BIGGEST_SCHEDULED_DATE_KEY = "biggestScheduledDateKey" + const val BIGGEST_SCHEDULED_MESSAGES_ETOP_KEY = "biggestScheduledMessagesEtopKey" const val RESULT_USER_ID_KEY = "resultUserIdKey" + const val SCHEDULED_DRAFT_DATE_KEY = "scheduledDraftDateKey" + const val UNSCHEDULE_DRAFT_URL_KEY = "unscheduleDraftUrlKey" } } diff --git a/app/src/main/res/drawable-night/ic_update_logo.xml b/app/src/main/res/drawable-night/ic_update_logo.xml new file mode 100644 index 0000000000..a92b63548d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_update_logo.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_afternoon_schedule.xml b/app/src/main/res/drawable/ic_afternoon_schedule.xml new file mode 100644 index 0000000000..03530e5f56 --- /dev/null +++ b/app/src/main/res/drawable/ic_afternoon_schedule.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_return.xml b/app/src/main/res/drawable/ic_arrow_return.xml new file mode 100644 index 0000000000..f7247eff53 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_return.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_evening_schedule.xml b/app/src/main/res/drawable/ic_evening_schedule.xml new file mode 100644 index 0000000000..fafaa4ff2c --- /dev/null +++ b/app/src/main/res/drawable/ic_evening_schedule.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_last_schedule_selected.xml b/app/src/main/res/drawable/ic_last_schedule_selected.xml new file mode 100644 index 0000000000..bb5cb61693 --- /dev/null +++ b/app/src/main/res/drawable/ic_last_schedule_selected.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_morning_schedule.xml b/app/src/main/res/drawable/ic_morning_schedule.xml new file mode 100644 index 0000000000..728f864ca8 --- /dev/null +++ b/app/src/main/res/drawable/ic_morning_schedule.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_morning_sunrise_schedule.xml b/app/src/main/res/drawable/ic_morning_sunrise_schedule.xml new file mode 100644 index 0000000000..175da5f613 --- /dev/null +++ b/app/src/main/res/drawable/ic_morning_sunrise_schedule.xml @@ -0,0 +1,72 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pen.xml b/app/src/main/res/drawable/ic_pen.xml new file mode 100644 index 0000000000..389ab8b76d --- /dev/null +++ b/app/src/main/res/drawable/ic_pen.xml @@ -0,0 +1,35 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_schedule_send.xml b/app/src/main/res/drawable/ic_schedule_send.xml new file mode 100644 index 0000000000..1db6c46351 --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule_send.xml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_sent_messages.xml b/app/src/main/res/drawable/ic_sent_messages.xml deleted file mode 100644 index 6e2d5f9ed5..0000000000 --- a/app/src/main/res/drawable/ic_sent_messages.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_update_logo.xml b/app/src/main/res/drawable/ic_update_logo.xml index 5b2509e3e9..f91cc39159 100644 --- a/app/src/main/res/drawable/ic_update_logo.xml +++ b/app/src/main/res/drawable/ic_update_logo.xml @@ -1,6 +1,6 @@ + android:width="263dp" + android:height="194dp" + android:viewportWidth="263" + android:viewportHeight="194"> + + + + + + android:fillType="evenOdd" + android:pathData="M114.7,112.7C114.7,111.49 115.89,110.63 117.07,110.99L137.17,117.04C137.94,117.27 138.46,117.96 138.46,118.75V135.58C138.46,136.78 137.27,137.64 136.1,137.29L115.99,131.23C115.22,131 114.7,130.31 114.7,129.52V112.7Z" /> - + + android:fillType="evenOdd" + android:pathData="M114.7,109.66L138.46,116.82L126.51,127.23C125.62,128.01 124.22,127.76 123.67,126.72L114.7,109.66Z" /> + android:fillColor="#FF8500" + android:pathData="M252.56,71L251.35,64.2L247.87,64.82L249.07,71.62L242.18,70.3L241.61,73.7L248.47,74.85L245.46,81.15L248.83,82.56L251.69,76.1L256.94,81.12L259.22,78.66L254.29,73.81L260.46,70.36L258.58,67.4L252.56,71Z" /> + + android:pathData="M83.52,44.19L84.51,39.54L82.14,39.04L81.16,43.69L77.14,41.08L75.9,43.1L79.95,45.59L76.43,48.84L78.2,50.6L81.67,47.22L83.7,51.77L85.77,50.78L83.9,46.43L88.68,45.81L88.25,43.43L83.52,44.19Z" /> + android:fillColor="#FFC10A" + android:pathData="M159.6,7.82V0.92H156.06V7.82L149.51,5.33L148.35,8.58L154.9,10.9L150.84,16.58L153.92,18.56L157.86,12.7L162.15,18.56L164.82,16.53L160.82,10.9L167.49,8.58L166.16,5.33L159.6,7.82Z" /> + android:pathData="M252.43,124.65V120.34H250.22V124.65L246.12,123.09L245.39,125.13L249.49,126.58L246.96,130.14L248.88,131.37L251.35,127.7L254.03,131.37L255.7,130.1L253.2,126.58L257.37,125.13L256.54,123.09L252.43,124.65Z" /> + + + + + + + + + android:fillColor="#F1F1F1" + android:pathData="M194.67,79.36C193.52,80.52 189.45,80.41 189.45,80.41C189.45,80.41 189.34,76.34 190.5,75.19C191.65,74.04 193.52,74.04 194.67,75.19C195.82,76.34 195.82,78.21 194.67,79.36Z" /> diff --git a/app/src/main/res/layout/activity_no_mailbox.xml b/app/src/main/res/layout/activity_no_mailbox.xml index 8a868f7fed..809739422e 100644 --- a/app/src/main/res/layout/activity_no_mailbox.xml +++ b/app/src/main/res/layout/activity_no_mailbox.xml @@ -66,7 +66,7 @@ android:id="@+id/noMailboxIconLayout" android:layout_width="0dp" android:layout_height="250dp" - app:layout_constraintBottom_toBottomOf="@+id/bottomWave" + app:layout_constraintBottom_toBottomOf="@id/bottomWave" app:layout_constraintEnd_toStartOf="@id/end" app:layout_constraintStart_toEndOf="@id/start" app:lottie_autoPlay="true" diff --git a/app/src/main/res/layout/bottom_sheet_account.xml b/app/src/main/res/layout/bottom_sheet_account.xml index 26a28510f3..e6ba53cb4c 100644 --- a/app/src/main/res/layout/bottom_sheet_account.xml +++ b/app/src/main/res/layout/bottom_sheet_account.xml @@ -50,7 +50,7 @@ app:icon="@drawable/ic_add_thin" app:iconPaddingEnd="@dimen/marginStandardMedium" app:iconPaddingStart="@dimen/marginStandard" - app:text="@string/buttonAddAccount" + app:title="@string/buttonAddAccount" app:visibleDivider="false" /> diff --git a/app/src/main/res/layout/bottom_sheet_actions_menu.xml b/app/src/main/res/layout/bottom_sheet_actions_menu.xml index 305cab8a35..c0451e5cb7 100644 --- a/app/src/main/res/layout/bottom_sheet_actions_menu.xml +++ b/app/src/main/res/layout/bottom_sheet_actions_menu.xml @@ -43,7 +43,7 @@ android:layout_height="wrap_content" android:visibility="gone" app:icon="@drawable/ic_view_in_light" - app:text="@string/actionViewInLight" + app:title="@string/actionViewInLight" app:visibleDivider="false" tools:visibility="visible" /> @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:visibility="gone" app:icon="@drawable/ic_alarm_clock" - app:text="@string/actionPostpone" + app:title="@string/actionPostpone" app:visibleDivider="false" tools:visibility="visible" /> @@ -63,7 +63,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:icon="@drawable/ic_email_action_move" - app:text="@string/actionMove" + app:title="@string/actionMove" app:visibleDivider="false" /> @@ -81,7 +81,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:icon="@drawable/ic_envelope" - app:text="@string/actionMarkAsUnread" + app:title="@string/actionMarkAsUnread" app:visibleDivider="false" /> @@ -115,7 +115,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:icon="@drawable/ic_email_action_share" - app:text="@string/shareEmail" + app:title="@string/shareEmail" app:visibleDivider="false" /> diff --git a/app/src/main/res/layout/bottom_sheet_attachment_actions.xml b/app/src/main/res/layout/bottom_sheet_attachment_actions.xml index b99e1bc74d..cd672c9fc1 100644 --- a/app/src/main/res/layout/bottom_sheet_attachment_actions.xml +++ b/app/src/main/res/layout/bottom_sheet_attachment_actions.xml @@ -35,7 +35,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:icon="@drawable/ic_open_with" - app:text="@string/openWith" + app:title="@string/openWith" app:visibleDivider="false" /> + app:title="@string/saveToDriveItem" /> + app:title="@string/saveToDriveDeviceItem" /> diff --git a/app/src/main/res/layout/bottom_sheet_detailed_contact.xml b/app/src/main/res/layout/bottom_sheet_detailed_contact.xml index 40e34788f5..868e263737 100644 --- a/app/src/main/res/layout/bottom_sheet_detailed_contact.xml +++ b/app/src/main/res/layout/bottom_sheet_detailed_contact.xml @@ -58,7 +58,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:icon="@drawable/ic_contact_action_write" - app:text="@string/contactActionWriteEmail" + app:title="@string/contactActionWriteEmail" app:visibleDivider="false" /> + app:title="@string/contactActionAddToContacts" /> + app:title="@string/contactActionCopyEmailAddress" /> diff --git a/app/src/main/res/layout/bottom_sheet_junk.xml b/app/src/main/res/layout/bottom_sheet_junk.xml index cc02ae68aa..17611cb177 100644 --- a/app/src/main/res/layout/bottom_sheet_junk.xml +++ b/app/src/main/res/layout/bottom_sheet_junk.xml @@ -27,7 +27,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:icon="@drawable/ic_spam" - app:text="@string/actionSpam" + app:title="@string/actionSpam" app:visibleDivider="false" /> + app:title="@string/actionPhishing" /> + app:title="@string/actionBlockSender" /> diff --git a/app/src/main/res/layout/bottom_sheet_multi_select.xml b/app/src/main/res/layout/bottom_sheet_multi_select.xml index 2f5cca1586..32657584f8 100644 --- a/app/src/main/res/layout/bottom_sheet_multi_select.xml +++ b/app/src/main/res/layout/bottom_sheet_multi_select.xml @@ -35,14 +35,14 @@ android:layout_height="wrap_content" android:visibility="gone" app:icon="@drawable/ic_alarm_clock" - app:text="@string/actionPostpone" /> + app:title="@string/actionPostpone" /> + app:title="@string/actionStar" /> diff --git a/app/src/main/res/layout/bottom_sheet_schedule_send.xml b/app/src/main/res/layout/bottom_sheet_schedule_send.xml new file mode 100644 index 0000000000..105d9fc416 --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet_schedule_send.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_sheet_upgrade_product.xml b/app/src/main/res/layout/bottom_sheet_upgrade_product.xml new file mode 100644 index 0000000000..cbb3055737 --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet_upgrade_product.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/cardview_thread_item.xml b/app/src/main/res/layout/cardview_thread_item.xml index 2ef584493c..9a5332a10a 100644 --- a/app/src/main/res/layout/cardview_thread_item.xml +++ b/app/src/main/res/layout/cardview_thread_item.xml @@ -1,6 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/dialog_select_date_and_time_for_scheduled_draft.xml b/app/src/main/res/layout/dialog_select_date_and_time_for_scheduled_draft.xml new file mode 100644 index 0000000000..3c6fb671a6 --- /dev/null +++ b/app/src/main/res/layout/dialog_select_date_and_time_for_scheduled_draft.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_account_management_settings.xml b/app/src/main/res/layout/fragment_account_management_settings.xml index ae928ab1ac..ea7e8c7597 100644 --- a/app/src/main/res/layout/fragment_account_management_settings.xml +++ b/app/src/main/res/layout/fragment_account_management_settings.xml @@ -76,8 +76,8 @@ app:icon="@drawable/ic_bin" app:iconColor="@color/redDestructiveAction" app:iconSize="@dimen/standardIconSize" - app:text="@string/buttonAccountDelete" - app:textColor="@color/redDestructiveAction" /> + app:title="@string/buttonAccountDelete" + app:titleColor="@color/redDestructiveAction" /> + app:layout_constraintBottom_toTopOf="@id/externalBanner" + app:layout_constraintTop_toBottomOf="@id/toolbar"> - - + - + android:orientation="horizontal" + tools:visibility="gone"> - + - + - - + - + + + android:orientation="horizontal" + android:visibility="gone" + tools:visibility="visible"> - - + + - + app:layout_constraintTop_toTopOf="parent"> + + + + + + diff --git a/app/src/main/res/layout/fragment_thread.xml b/app/src/main/res/layout/fragment_thread.xml index 1e872282a0..d5f3a7b31a 100644 --- a/app/src/main/res/layout/fragment_thread.xml +++ b/app/src/main/res/layout/fragment_thread.xml @@ -124,8 +124,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/threadCoordinatorLayout" - app:menu="@menu/message_menu" /> + app:layout_constraintTop_toBottomOf="@id/threadCoordinatorLayout" /> - + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:orientation="vertical"> - - - - + app:dividerColor="@color/dividerColor" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_menu_drawer_footer.xml b/app/src/main/res/layout/item_menu_drawer_footer.xml index b47b877206..f974656ece 100644 --- a/app/src/main/res/layout/item_menu_drawer_footer.xml +++ b/app/src/main/res/layout/item_menu_drawer_footer.xml @@ -92,7 +92,7 @@ android:layout_marginStart="@dimen/marginStandardMedium" app:layout_constraintBottom_toBottomOf="@id/storageIndicator" app:layout_constraintStart_toEndOf="@id/storageIndicator" - app:layout_constraintTop_toTopOf="@+id/storageIndicator" + app:layout_constraintTop_toTopOf="@id/storageIndicator" tools:text="60.5 Ko / 20 Go utilisés" /> diff --git a/app/src/main/res/layout/item_message.xml b/app/src/main/res/layout/item_message.xml index 1184dfcf74..1ba6772115 100644 --- a/app/src/main/res/layout/item_message.xml +++ b/app/src/main/res/layout/item_message.xml @@ -1,6 +1,6 @@ + + + diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml index 89b4387fcd..d6ede54a07 100644 --- a/app/src/main/res/navigation/main_navigation.xml +++ b/app/src/main/res/navigation/main_navigation.xml @@ -1,6 +1,6 @@ @color/orca diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index ab48e1a612..41619ebaeb 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -61,8 +61,10 @@ - - + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 463d5fb6a5..fe594c820d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -169,6 +169,7 @@ @color/shark @color/yellow_light @color/white + @color/coral_light @color/mouse diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index abbf4e4431..fd94b6f8c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,6 +139,7 @@ Create Create an account Add a folder + Custom schedule detach mailbox Download Download all @@ -153,6 +154,7 @@ Log in with another account Login Maybe + Modify More Get more storage New message @@ -160,8 +162,10 @@ Open in my calendar Refuse Request password + Reschedule Restore emails Schedule email to send later + Schedule See All Share @@ -191,6 +195,7 @@ Me Contacts Selected account + Action icon Back Delete %s Delete draft @@ -221,6 +226,9 @@ Reply Storage status Open mail actions + Schedule icon + Scheduled message + Scheduled date Selected This folder contains unread mails User avatar @@ -237,12 +245,17 @@ Name of the folder None Parent folder + Choose a date and time Date: The deletion of your account will be final. You will not be able to reactivate your account. + You will soon be able to unlock the limits of your ik.me email address\n(storage limit, custom schedule, sending and more…) + Need more storage and features? To fix display problems, please update the Android System Webview application. Problem displaying your emails Drafts (Draft) + This message will be moved to your drafts to be sent whenever you want. + Edit Send You are about to send a message without a subject. Do you want to continue? Empty subject There is nothing to see here. @@ -264,6 +277,7 @@ At least one recipient is required Attachment cannot be found An error has occurred while sending your answer + Select an upcoming date Failed to handle draft, an attachment is corrupted Draft cannot be found Scheduled or sent messages cannot be edited @@ -285,6 +299,10 @@ The folder name should not exceed 255 characters The server is refusing all recipients The server is refusing the sender + + You can’t schedule an email less than %d minute in the future + You can’t schedule an email less than %d minutes in the future + Sending limit reached Too many recipients Unable to create the folder @@ -313,6 +331,8 @@ From: Google Play Services are required Inbox + Last selected schedule + Later this morning and Loading… Access to your mailbox is currently blocked.\nFor further information, please read FAQ. @@ -333,6 +353,8 @@ Draft On %1$s, %2$s wrote: Display the conversation + Monday afternoon + Monday morning Your mailbox is full and you can‘t receive any more messages 🥲 You will soon be able to unblock the limits of your ik.me email address (storage limit, sending, forwarding, …) Is your box full? Get more storage! @@ -354,6 +376,7 @@ %d new messages Type your message + Next monday This message is empty. No conversation selected in %s You don’t have any folder yet… @@ -401,6 +424,8 @@ Do you like Infomaniak Mail? Save to device Save to kDrive + Schedule sending + This email will be sent on this date: %s Scheduled messages All messages Search an email @@ -409,7 +434,9 @@ Read Unread Search + Select date No signature + Select time Send Sent messages Accent color @@ -523,6 +550,9 @@ Number copied to clipboard Successfully reported Launching the restoration + Your message has been saved in drafts. + The message will be sent on %s + The message is being scheduled Sender successfully blacklisted Senders successfully blacklisted @@ -548,6 +578,8 @@ To download the kSync application from the Play Store, click on \"Install\", then return to your Mail application. Installed Activate the calendars and address books in kSync that you wish to synchronize with your phone. + This afternoon + This evening Are you sure you want to delete this message permanently? Are you sure you want to delete these messages permanently? @@ -577,6 +609,7 @@ My accounts To: + Tomorrow morning You can’t add this address because you have reached the limit of recipients Trash (unknown)