From 33f1f4c4e930512983cf373e2e9f10804b9e54c4 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Thu, 9 Apr 2026 15:38:08 +0200 Subject: [PATCH 01/21] feat: Add function to get list of contact with query and limit --- .../data/cache/userInfo/MergedContactController.kt | 8 ++++++++ .../mail/ui/main/search/SearchViewModel.kt | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt index a48addddd87..85ae4013073 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt @@ -42,6 +42,14 @@ class MergedContactController @Inject constructor(@UserInfoRealm private val use .sort(MergedContact::name.name) .sort(MergedContact::comesFromApi.name, Sort.DESCENDING) } + fun searchMergedContacts(searchQuery: String, limit: Int = 5): List { + return userInfoRealm.query("name CONTAINS[c] $0 OR email CONTAINS[c] $0", searchQuery, searchQuery) + .sort(MergedContact::name.name) + .sort(MergedContact::comesFromApi.name, Sort.DESCENDING) + .limit(limit) + .find() + .map { it } + } private fun getMergedContactFromContactGroupQuery(contact: ContactGroup): RealmQuery { return userInfoRealm.query("${MergedContact::remoteContactGroupIds.name} == $0", contact.id) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 7b9c557e533..d746957cc5e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -18,6 +18,7 @@ package com.infomaniak.mail.ui.main.search import android.app.Application +import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle @@ -29,12 +30,15 @@ import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.LocalSettings.ThreadMode import com.infomaniak.mail.data.api.ApiRepository +import com.infomaniak.mail.data.api.ApiRoutes.contact import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxContent.ThreadController import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController +import com.infomaniak.mail.data.cache.userInfo.MergedContactController import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.Companion.DUMMY_FOLDER_ID +import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.di.IoDispatcher @@ -55,6 +59,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.text.Normalizer import javax.inject.Inject @HiltViewModel @@ -63,6 +68,7 @@ class SearchViewModel @Inject constructor( private val globalCoroutineScope: CoroutineScope, private val mailboxController: MailboxController, private val messageController: MessageController, + private val mergedContactController: MergedContactController, private val refreshController: RefreshController, private val savedStateHandle: SavedStateHandle, private val searchUtils: SearchUtils, @@ -82,6 +88,7 @@ class SearchViewModel @Inject constructor( var currentSearchQuery: String = "" private set + val contactsResults = MutableLiveData>() private var currentFilters = mutableSetOf() var isAllFoldersSelected: Boolean = false @@ -203,6 +210,11 @@ class SearchViewModel @Inject constructor( searchJob = launch { delay(SEARCH_DEBOUNCE_DURATION) ensureActive() + + val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) + .replace("\\p{M}".toRegex(), "") + val contacts = mergedContactController.searchMergedContacts(queryClean) + contactsResults.postValue(contacts) mailboxController .getMailbox(AccountUtils.currentUserId, AccountUtils.currentMailboxId) From 5659f2f621d54d7310ac1173720d68f3b5be3a1a Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 10 Apr 2026 16:02:52 +0200 Subject: [PATCH 02/21] feat: Display contacts on search and handle click actions --- .../mail/ui/main/folder/ThreadItem.kt | 3 ++ .../mail/ui/main/folder/ThreadListAdapter.kt | 46 +++++++++++++++++++ .../main/folder/ThreadListAdapterCallbacks.kt | 3 ++ .../mail/ui/main/folder/ThreadListFragment.kt | 3 ++ .../mail/ui/main/search/SearchFragment.kt | 12 +++++ .../mail/ui/main/search/SearchViewModel.kt | 15 ++++-- .../main/res/layout/item_contact_header.xml | 28 +++++++++++ 7 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/layout/item_contact_header.xml diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt index f5c2308e24c..2deb13f6258 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt @@ -18,11 +18,14 @@ package com.infomaniak.mail.ui.main.folder import com.infomaniak.mail.data.models.Folder +import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.thread.Thread sealed interface ThreadListItem { data class Content(val thread: Thread) : ThreadListItem data class DateSeparator(val title: String) : ThreadListItem + data object ContactHeader : ThreadListItem + data class ContactItem(val contact: MergedContact) : ThreadListItem data class FlushFolderButton(val folderRole: Folder.FolderRole) : ThreadListItem data object LoadMore : ThreadListItem } 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 9be94ea93e3..b8426670090 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 @@ -22,6 +22,7 @@ import android.content.res.ColorStateList import android.graphics.Canvas import android.text.Spannable import android.text.TextUtils.TruncateAt +import android.util.Log import android.view.HapticFeedbackConstants import android.view.LayoutInflater import android.view.View @@ -61,10 +62,13 @@ import com.infomaniak.mail.data.LocalSettings.ThreadDensity import com.infomaniak.mail.data.cache.RealmDatabase import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.SwipeAction +import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.correspondent.Recipient import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.databinding.CardviewThreadItemBinding import com.infomaniak.mail.databinding.ItemBannerWithActionViewBinding +import com.infomaniak.mail.databinding.ItemContactBinding +import com.infomaniak.mail.databinding.ItemContactHeaderBinding import com.infomaniak.mail.databinding.ItemThreadDateSeparatorBinding import com.infomaniak.mail.databinding.ItemThreadLoadMoreButtonBinding import com.infomaniak.mail.ui.main.folder.ThreadListAdapter.ThreadListViewHolder @@ -120,6 +124,8 @@ class ThreadListAdapter @Inject constructor( private var swipingIsAuthorized: Boolean = true private var isLoadMoreDisplayed = false + private var searchContact = emptyList() + private var folderRole: FolderRole? = null private var multiSelection: MultiSelectionListener? = null private var isFolderNameVisible: Boolean = false @@ -156,6 +162,8 @@ class ThreadListAdapter @Inject constructor( is ThreadListItem.Content -> DisplayType.THREAD.layout is ThreadListItem.DateSeparator -> DisplayType.DATE_SEPARATOR.layout is ThreadListItem.FlushFolderButton -> DisplayType.FLUSH_FOLDER_BUTTON.layout + is ThreadListItem.ContactItem -> DisplayType.CONTACT_ITEM.layout + is ThreadListItem.ContactHeader -> DisplayType.CONTACT_HEADER.layout ThreadListItem.LoadMore -> DisplayType.LOAD_MORE_BUTTON.layout } }.getOrDefault(super.getItemViewType(position)) @@ -173,6 +181,8 @@ class ThreadListAdapter @Inject constructor( return when (val item = dataSet[position]) { is ThreadListItem.Content -> item.thread.uid.hashCode().toLong() is ThreadListItem.DateSeparator -> item.title.hashCode().toLong() + ThreadListItem.ContactHeader -> "contact_header".hashCode().toLong() + is ThreadListItem.ContactItem -> item.contact.email.hashCode().toLong() else -> super.getItemId(position) } }.getOrDefault(super.getItemId(position)) @@ -189,6 +199,8 @@ class ThreadListAdapter @Inject constructor( R.layout.item_thread_date_separator -> ItemThreadDateSeparatorBinding.inflate(layoutInflater, parent, false) R.layout.item_banner_with_action_view -> ItemBannerWithActionViewBinding.inflate(layoutInflater, parent, false) R.layout.item_thread_load_more_button -> ItemThreadLoadMoreButtonBinding.inflate(layoutInflater, parent, false) + R.layout.item_contact -> ItemContactBinding.inflate(layoutInflater, parent, false) + R.layout.item_contact_header -> ItemContactHeaderBinding.inflate(layoutInflater, parent, false) else -> CardviewThreadItemBinding.inflate(layoutInflater, parent, false) } @@ -229,6 +241,15 @@ class ThreadListAdapter @Inject constructor( DisplayType.LOAD_MORE_BUTTON.layout -> { (this as ItemThreadLoadMoreButtonBinding).displayLoadMoreButton() } + DisplayType.CONTACT_HEADER.layout -> { + (this as ItemContactHeaderBinding).displayContactHeader() + } + DisplayType.CONTACT_ITEM.layout -> { + (this as ItemContactBinding).displayContactItem( + (item as ThreadListItem.ContactItem).contact, + true + ) + } } } @@ -594,6 +615,18 @@ class ThreadListAdapter @Inject constructor( } } + private fun ItemContactBinding.displayContactItem(contact: MergedContact, last: Boolean) { + contactDetails.setMergedContact(contact) + + contactDetails.setOnClickListener { + callbacks?.onContactClicked?.invoke(contact) + } + } + + private fun ItemContactHeaderBinding.displayContactHeader() { + contactsTitle.text = context.getString(R.string.contactsSearch) // TODO: manage plurals + } + override fun onSwipeStarted(item: ThreadListItem, viewHolder: ThreadListViewHolder) { if (item is ThreadListItem.Content) item.thread.updateDynamicIcons() } @@ -743,6 +776,13 @@ class ThreadListAdapter @Inject constructor( scope: CoroutineScope, ) = mutableListOf().apply { + if (searchContact.isNotEmpty()){ + add(ThreadListItem.ContactHeader) + searchContact.forEach { contact -> + add(ThreadListItem.ContactItem(contact)) + } + } + if ((folderRole == FolderRole.TRASH || folderRole == FolderRole.SPAM) && threads.isNotEmpty()) { add(ThreadListItem.FlushFolderButton(folderRole)) } @@ -798,6 +838,10 @@ class ThreadListAdapter @Inject constructor( folderRole = newRole } + fun updateSearchContacts(contacts: List){ + searchContact = contacts + } + fun updateSelection() { notifyItemRangeChanged(0, itemCount, NotificationType.SELECTED_STATE) } @@ -824,6 +868,8 @@ class ThreadListAdapter @Inject constructor( DATE_SEPARATOR(R.layout.item_thread_date_separator), FLUSH_FOLDER_BUTTON(R.layout.item_banner_with_action_view), LOAD_MORE_BUTTON(R.layout.item_thread_load_more_button), + CONTACT_HEADER(R.layout.item_contact_header), + CONTACT_ITEM(R.layout.item_contact), } enum class NotificationType { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt index 2a0f2839159..04127821720 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.mail.ui.main.folder +import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread @@ -28,4 +29,6 @@ interface ThreadListAdapterCallbacks { var onPositionClickedChanged: (position: Int, previousPosition: Int) -> Unit var deleteThreadInRealm: (threadUid: String) -> Unit val getFeatureFlags: () -> Mailbox.FeatureFlagSet? + + var onContactClicked: ((MergedContact) -> Unit)? } 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 7abbd2232e6..a258e677a1e 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 @@ -77,6 +77,7 @@ import com.infomaniak.mail.data.LocalSettings.ThreadDensity.COMPACT import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.SwipeAction +import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox.FeatureFlagSet import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter @@ -384,6 +385,8 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { override var deleteThreadInRealm: (String) -> Unit = { threadUid -> mainViewModel.deleteThreadInRealm(threadUid) } override val getFeatureFlags: () -> FeatureFlagSet? = { mainViewModel.featureFlagsLive.value } + + override var onContactClicked: ((MergedContact) -> Unit)? = null }, multiSelection = object : MultiSelectionListener { override var isEnabled by mainViewModel::isMultiSelectOn diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 809589fb612..3aea5a20558 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -46,6 +46,7 @@ import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.MatomoMail.trackThreadListEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.models.Folder +import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter @@ -112,6 +113,11 @@ class SearchFragment : TwoPaneFragment() { searchViewModel.executePendingSearch() + searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> + threadListAdapter.updateSearchContacts(contacts) + threadListAdapter.notifyDataSetChanged() + } + setupAdapter() setupListeners() @@ -198,6 +204,12 @@ class SearchFragment : TwoPaneFragment() { override var deleteThreadInRealm: (String) -> Unit = { threadUid -> mainViewModel.deleteThreadInRealm(threadUid) } override val getFeatureFlags: () -> Mailbox.FeatureFlagSet? = { mainViewModel.featureFlagsLive.value } + + override var onContactClicked: ((MergedContact) -> Unit)? = { contact -> + val emailWithQuotes = "\"${contact.email}\"" + binding.searchBar.searchTextInput.setText(emailWithQuotes) + binding.searchBar.searchTextInput.setSelection(emailWithQuotes.length) + } }, ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index d746957cc5e..68d343a5b53 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -210,11 +210,16 @@ class SearchViewModel @Inject constructor( searchJob = launch { delay(SEARCH_DEBOUNCE_DURATION) ensureActive() - - val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) - .replace("\\p{M}".toRegex(), "") - val contacts = mergedContactController.searchMergedContacts(queryClean) - contactsResults.postValue(contacts) + + if (query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query)){ + val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) + .replace("\\p{M}".toRegex(), "") + val contacts = mergedContactController.searchMergedContacts(queryClean) + contactsResults.postValue(contacts) + }else{ + contactsResults.postValue(emptyList()) + } + mailboxController .getMailbox(AccountUtils.currentUserId, AccountUtils.currentMailboxId) diff --git a/app/src/main/res/layout/item_contact_header.xml b/app/src/main/res/layout/item_contact_header.xml new file mode 100644 index 00000000000..a6ba9a61022 --- /dev/null +++ b/app/src/main/res/layout/item_contact_header.xml @@ -0,0 +1,28 @@ + + From 1ce3d372b658f2308a94e652655378ca10da4b7f Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 10 Apr 2026 16:53:22 +0200 Subject: [PATCH 03/21] fix: Clear previous contact search results when search button is clicked again --- .../com/infomaniak/mail/ui/main/search/SearchFragment.kt | 4 ++++ .../com/infomaniak/mail/ui/main/search/SearchViewModel.kt | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 3aea5a20558..47fc46bcf04 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -19,6 +19,7 @@ package com.infomaniak.mail.ui.main.search import android.os.Bundle import android.os.CountDownTimer +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -45,6 +46,7 @@ import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.MatomoMail.trackThreadListEvent import com.infomaniak.mail.R +import com.infomaniak.mail.data.api.ApiRoutes.contacts import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox @@ -113,6 +115,7 @@ class SearchFragment : TwoPaneFragment() { searchViewModel.executePendingSearch() + threadListAdapter.updateSearchContacts(emptyList()) searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> threadListAdapter.updateSearchContacts(contacts) threadListAdapter.notifyDataSetChanged() @@ -219,6 +222,7 @@ class SearchFragment : TwoPaneFragment() { private fun setupListeners() = with(binding) { toolbar.setNavigationOnClickListener { searchViewModel.resetFolderFilter() + searchViewModel.contactsResults.value = emptyList() findNavController().popBackStack() } swipeRefreshLayout.setOnRefreshListener { searchViewModel.refreshSearch() } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 68d343a5b53..84bcd38b6fd 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -189,6 +189,7 @@ class SearchViewModel @Inject constructor( } override fun onCleared() { + contactsResults.value = emptyList() cancelSearch() globalCoroutineScope.launch(ioDispatcher) { searchUtils.deleteRealmSearchData() @@ -211,12 +212,12 @@ class SearchViewModel @Inject constructor( delay(SEARCH_DEBOUNCE_DURATION) ensureActive() - if (query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query)){ + if (query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query)) { val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") val contacts = mergedContactController.searchMergedContacts(queryClean) contactsResults.postValue(contacts) - }else{ + } else { contactsResults.postValue(emptyList()) } From a1fbe76044b63492729710c739cc03516bbf2725 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 10 Apr 2026 17:51:22 +0200 Subject: [PATCH 04/21] feat: Clear contacts when a filter is selected --- .../mail/ui/main/folder/ThreadListAdapter.kt | 9 +++++++++ .../mail/ui/main/search/SearchFragment.kt | 19 ++++++++++++++----- .../mail/ui/main/search/SearchViewModel.kt | 10 ++++++++-- 3 files changed, 31 insertions(+), 7 deletions(-) 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 b8426670090..3aabe6ac0be 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 @@ -454,6 +454,15 @@ class ThreadListAdapter @Inject constructor( return null } + fun clearContacts() { + searchContact = emptyList() + val newDataSet = dataSet.filterNot { item -> + item is ThreadListItem.ContactItem || item is ThreadListItem.ContactHeader + }.toMutableList() + dataSet = newDataSet + notifyDataSetChanged() + } + /** * Sometimes, we want to select a Thread before even having any Thread in the Adapter (example: coming from a Notification). * The selected Thread's UI will update when the Adapter triggers the next batch of `onBindViewHolder()`. diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 47fc46bcf04..272c0b20335 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -115,7 +115,7 @@ class SearchFragment : TwoPaneFragment() { searchViewModel.executePendingSearch() - threadListAdapter.updateSearchContacts(emptyList()) + threadListAdapter.clearContacts() searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> threadListAdapter.updateSearchContacts(contacts) threadListAdapter.notifyDataSetChanged() @@ -263,7 +263,7 @@ class SearchFragment : TwoPaneFragment() { private fun setAttachmentsUi() = with(searchViewModel) { binding.attachments.setOnCheckedChangeListener { _, isChecked -> - + if (isChecked) threadListAdapter.clearContacts() setFilter(ThreadFilter.ATTACHMENTS, isChecked) } } @@ -272,9 +272,18 @@ class SearchFragment : TwoPaneFragment() { binding.mutuallyExclusiveChipGroup.setOnCheckedStateChangeListener { chipGroup, _ -> when (chipGroup.checkedChipId) { - R.id.read -> setFilter(ThreadFilter.SEEN) - R.id.unread -> setFilter(ThreadFilter.UNSEEN) - R.id.favorites -> setFilter(ThreadFilter.STARRED) + R.id.read -> { + threadListAdapter.clearContacts() + setFilter(ThreadFilter.SEEN) + } + R.id.unread -> { + threadListAdapter.clearContacts() + setFilter(ThreadFilter.UNSEEN) + } + R.id.favorites -> { + threadListAdapter.clearContacts() + setFilter(ThreadFilter.STARRED) + } else -> unselectMutuallyExclusiveFilters() } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 84bcd38b6fd..06daeb2e4d3 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -137,6 +137,9 @@ class SearchViewModel @Inject constructor( fun searchQuery(query: String, saveInHistory: Boolean = false) = viewModelScope.launch(ioCoroutineContext) { if (query.isNotBlank() && isLengthTooShort(query)) return@launch + if (saveInHistory){ + contactsResults.postValue(emptyList()) + } search(query.trim().also { currentSearchQuery = it }, saveInHistory) } @@ -154,6 +157,7 @@ class SearchViewModel @Inject constructor( fun setFilter(filter: ThreadFilter, isEnabled: Boolean = true) = viewModelScope.launch(ioCoroutineContext) { if (isEnabled && currentFilters.contains(filter)) return@launch if (isEnabled) { + contactsResults.value = emptyList() trackSearchEvent(filter.matomoName) filter.select() } else { @@ -208,17 +212,19 @@ class SearchViewModel @Inject constructor( shouldGetNextPage: Boolean = false, ) = withContext(ioCoroutineContext) { cancelSearch() + + contactsResults.postValue(emptyList()) searchJob = launch { delay(SEARCH_DEBOUNCE_DURATION) ensureActive() + contactsResults.postValue(emptyList()) + if (query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query)) { val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") val contacts = mergedContactController.searchMergedContacts(queryClean) contactsResults.postValue(contacts) - } else { - contactsResults.postValue(emptyList()) } From c29dcd212b570c6a9e0ad1937272c2af25f36a00 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 10 Apr 2026 18:04:02 +0200 Subject: [PATCH 05/21] feat: Clear contacts when Enter key is pressed --- .../java/com/infomaniak/mail/ui/main/search/SearchFragment.kt | 1 + .../java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 272c0b20335..dbefa151ac8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -301,6 +301,7 @@ class SearchFragment : TwoPaneFragment() { } handleEditorSearchAction { query -> + threadListAdapter.clearContacts() searchViewModel.searchQuery(query, saveInHistory = true) trackSearchEvent(MatomoName.ValidateSearch) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 06daeb2e4d3..ea28d802c83 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -220,7 +220,7 @@ class SearchViewModel @Inject constructor( contactsResults.postValue(emptyList()) - if (query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query)) { + if (!saveInHistory && query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query)) { val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") val contacts = mergedContactController.searchMergedContacts(queryClean) From dee0f8d740181ef63333f544510a7f099cb79894 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Mon, 13 Apr 2026 15:39:36 +0200 Subject: [PATCH 06/21] feat: Add custom item_contact_search to replace existing implementation containing unnecessary elements for this use case --- .../mail/ui/main/AvatarNameEmailView.kt | 9 ++++ .../mail/ui/main/folder/ThreadListAdapter.kt | 42 +++++++++++---- .../mail/ui/main/search/SearchViewModel.kt | 2 +- .../main/res/layout/cardview_thread_item.xml | 4 +- .../main/res/layout/item_contact_search.xml | 54 +++++++++++++++++++ app/src/main/res/values/dimens.xml | 2 + 6 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 app/src/main/res/layout/item_contact_search.xml diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt index 5e9c8861415..a400ef311fe 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt @@ -28,6 +28,8 @@ import androidx.annotation.ColorInt import androidx.core.text.toSpannable import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.marginStart +import androidx.core.view.updateLayoutParams import com.infomaniak.core.legacy.utils.getAttributes import com.infomaniak.core.legacy.utils.setMarginsRelative import com.infomaniak.mail.R @@ -145,6 +147,13 @@ class AvatarNameEmailView @JvmOverloads constructor( userEmail.text = searchQuery } + fun setAvatarMarginStart(margin: Int) = with(binding) { + avatarLayout.updateLayoutParams { + marginStart = margin + } + } + + override fun setOnClickListener(onClickListener: OnClickListener?) { binding.root.setOnClickListener(onClickListener) } 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 3aabe6ac0be..f60a0e0e9f4 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 @@ -50,6 +50,7 @@ import com.infomaniak.core.legacy.utils.capitalizeFirstChar import com.infomaniak.core.legacy.utils.context import com.infomaniak.core.legacy.utils.setMarginsRelative import com.infomaniak.core.matomo.Matomo.TrackerAction +import com.infomaniak.core.ui.view.extension.setMargins import com.infomaniak.core.ui.view.toPx import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeAdapter import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView @@ -69,6 +70,7 @@ import com.infomaniak.mail.databinding.CardviewThreadItemBinding import com.infomaniak.mail.databinding.ItemBannerWithActionViewBinding import com.infomaniak.mail.databinding.ItemContactBinding import com.infomaniak.mail.databinding.ItemContactHeaderBinding +import com.infomaniak.mail.databinding.ItemContactSearchBinding import com.infomaniak.mail.databinding.ItemThreadDateSeparatorBinding import com.infomaniak.mail.databinding.ItemThreadLoadMoreButtonBinding import com.infomaniak.mail.ui.main.folder.ThreadListAdapter.ThreadListViewHolder @@ -126,6 +128,8 @@ class ThreadListAdapter @Inject constructor( private var searchContact = emptyList() + private var spaceAfterLastContact: Int = 8 + private var folderRole: FolderRole? = null private var multiSelection: MultiSelectionListener? = null private var isFolderNameVisible: Boolean = false @@ -199,7 +203,7 @@ class ThreadListAdapter @Inject constructor( R.layout.item_thread_date_separator -> ItemThreadDateSeparatorBinding.inflate(layoutInflater, parent, false) R.layout.item_banner_with_action_view -> ItemBannerWithActionViewBinding.inflate(layoutInflater, parent, false) R.layout.item_thread_load_more_button -> ItemThreadLoadMoreButtonBinding.inflate(layoutInflater, parent, false) - R.layout.item_contact -> ItemContactBinding.inflate(layoutInflater, parent, false) + R.layout.item_contact_search -> ItemContactSearchBinding.inflate(layoutInflater, parent, false) R.layout.item_contact_header -> ItemContactHeaderBinding.inflate(layoutInflater, parent, false) else -> CardviewThreadItemBinding.inflate(layoutInflater, parent, false) } @@ -242,12 +246,19 @@ class ThreadListAdapter @Inject constructor( (this as ItemThreadLoadMoreButtonBinding).displayLoadMoreButton() } DisplayType.CONTACT_HEADER.layout -> { - (this as ItemContactHeaderBinding).displayContactHeader() + val totalContacts = dataSet.count { it is ThreadListItem.ContactItem } + (this as ItemContactHeaderBinding).displayContactHeader(totalContacts) } DisplayType.CONTACT_ITEM.layout -> { - (this as ItemContactBinding).displayContactItem( - (item as ThreadListItem.ContactItem).contact, - true + val contactItem = item as ThreadListItem.ContactItem + val totalContacts = dataSet.count { it is ThreadListItem.ContactItem } + + val contactsUpToThisPosition = dataSet.subList(0, position + 1).count { it is ThreadListItem.ContactItem } + val isLastContact = contactsUpToThisPosition == totalContacts + + (this as ItemContactSearchBinding).displayContactItem( + contactItem.contact, + isLastContact ) } } @@ -624,15 +635,24 @@ class ThreadListAdapter @Inject constructor( } } - private fun ItemContactBinding.displayContactItem(contact: MergedContact, last: Boolean) { + private fun ItemContactSearchBinding.displayContactItem(contact: MergedContact, isLastContact: Boolean) { contactDetails.setMergedContact(contact) + contactDetails.setAvatarMarginStart(0) + if (isLastContact) { + val marginBottom = root.context.resources.getDimensionPixelSize(R.dimen.spaceBetweenContactsAndThreads) + (contactDetails.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin = marginBottom + } - contactDetails.setOnClickListener { + + contactWithSpace.setOnClickListener { callbacks?.onContactClicked?.invoke(contact) } + + + } - private fun ItemContactHeaderBinding.displayContactHeader() { + private fun ItemContactHeaderBinding.displayContactHeader(totalContact: Int) { contactsTitle.text = context.getString(R.string.contactsSearch) // TODO: manage plurals } @@ -785,7 +805,7 @@ class ThreadListAdapter @Inject constructor( scope: CoroutineScope, ) = mutableListOf().apply { - if (searchContact.isNotEmpty()){ + if (searchContact.isNotEmpty()) { add(ThreadListItem.ContactHeader) searchContact.forEach { contact -> add(ThreadListItem.ContactItem(contact)) @@ -847,7 +867,7 @@ class ThreadListAdapter @Inject constructor( folderRole = newRole } - fun updateSearchContacts(contacts: List){ + fun updateSearchContacts(contacts: List) { searchContact = contacts } @@ -878,7 +898,7 @@ class ThreadListAdapter @Inject constructor( FLUSH_FOLDER_BUTTON(R.layout.item_banner_with_action_view), LOAD_MORE_BUTTON(R.layout.item_thread_load_more_button), CONTACT_HEADER(R.layout.item_contact_header), - CONTACT_ITEM(R.layout.item_contact), + CONTACT_ITEM(R.layout.item_contact_search), } enum class NotificationType { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index ea28d802c83..5123118f76c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -157,7 +157,7 @@ class SearchViewModel @Inject constructor( fun setFilter(filter: ThreadFilter, isEnabled: Boolean = true) = viewModelScope.launch(ioCoroutineContext) { if (isEnabled && currentFilters.contains(filter)) return@launch if (isEnabled) { - contactsResults.value = emptyList() + contactsResults.postValue(emptyList()) trackSearchEvent(filter.matomoName) filter.select() } else { diff --git a/app/src/main/res/layout/cardview_thread_item.xml b/app/src/main/res/layout/cardview_thread_item.xml index 83aac6e1687..b2473e312f6 100644 --- a/app/src/main/res/layout/cardview_thread_item.xml +++ b/app/src/main/res/layout/cardview_thread_item.xml @@ -52,8 +52,8 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index bc81ba2244d..34b572cd3fe 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -91,6 +91,8 @@ 300 10dp 16dp + 8dp + 8dp 100dp 32dp From b6eaeefdab2fd581f805ac73d5fd21b37c46083c Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Mon, 13 Apr 2026 16:45:05 +0200 Subject: [PATCH 07/21] fix: Reimplement contact display logic to properly show or hide contacts --- .../mail/ui/main/folder/ThreadListAdapter.kt | 8 --- .../mail/ui/main/search/SearchFragment.kt | 6 --- .../mail/ui/main/search/SearchViewModel.kt | 50 +++++++++++++++++-- 3 files changed, 45 insertions(+), 19 deletions(-) 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 f60a0e0e9f4..7cc5a92328c 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 @@ -465,14 +465,6 @@ class ThreadListAdapter @Inject constructor( return null } - fun clearContacts() { - searchContact = emptyList() - val newDataSet = dataSet.filterNot { item -> - item is ThreadListItem.ContactItem || item is ThreadListItem.ContactHeader - }.toMutableList() - dataSet = newDataSet - notifyDataSetChanged() - } /** * Sometimes, we want to select a Thread before even having any Thread in the Adapter (example: coming from a Notification). diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index dbefa151ac8..26ad32b7517 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -115,7 +115,6 @@ class SearchFragment : TwoPaneFragment() { searchViewModel.executePendingSearch() - threadListAdapter.clearContacts() searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> threadListAdapter.updateSearchContacts(contacts) threadListAdapter.notifyDataSetChanged() @@ -263,7 +262,6 @@ class SearchFragment : TwoPaneFragment() { private fun setAttachmentsUi() = with(searchViewModel) { binding.attachments.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) threadListAdapter.clearContacts() setFilter(ThreadFilter.ATTACHMENTS, isChecked) } } @@ -273,15 +271,12 @@ class SearchFragment : TwoPaneFragment() { when (chipGroup.checkedChipId) { R.id.read -> { - threadListAdapter.clearContacts() setFilter(ThreadFilter.SEEN) } R.id.unread -> { - threadListAdapter.clearContacts() setFilter(ThreadFilter.UNSEEN) } R.id.favorites -> { - threadListAdapter.clearContacts() setFilter(ThreadFilter.STARRED) } else -> unselectMutuallyExclusiveFilters() @@ -301,7 +296,6 @@ class SearchFragment : TwoPaneFragment() { } handleEditorSearchAction { query -> - threadListAdapter.clearContacts() searchViewModel.searchQuery(query, saveInHistory = true) trackSearchEvent(MatomoName.ValidateSearch) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 5123118f76c..c116bed5f88 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -62,6 +62,13 @@ import kotlinx.coroutines.withContext import java.text.Normalizer import javax.inject.Inject +enum class SearchUiState { + IDLE, + TYPING, + FILTERING, + VALIDATED +} + @HiltViewModel class SearchViewModel @Inject constructor( application: Application, @@ -88,6 +95,8 @@ class SearchViewModel @Inject constructor( var currentSearchQuery: String = "" private set + val uiState = MutableLiveData(SearchUiState.IDLE) + val contactsResults = MutableLiveData>() private var currentFilters = mutableSetOf() var isAllFoldersSelected: Boolean = false @@ -125,6 +134,15 @@ class SearchViewModel @Inject constructor( if (hasPendingSearch) search() } + private fun shouldShowContacts(): Boolean { + val state = uiState.value + val hasQuery = currentSearchQuery.isNotBlank() + val hasNoFilters = currentFilters.isEmpty() + val notValidated = state != SearchUiState.VALIDATED + + return state == SearchUiState.TYPING && hasQuery && hasNoFilters && notValidated + } + fun resetFolderFilter() { filterFolder = null isAllFoldersSelected = false @@ -137,8 +155,11 @@ class SearchViewModel @Inject constructor( fun searchQuery(query: String, saveInHistory: Boolean = false) = viewModelScope.launch(ioCoroutineContext) { if (query.isNotBlank() && isLengthTooShort(query)) return@launch - if (saveInHistory){ + if (saveInHistory) { + uiState.postValue(SearchUiState.VALIDATED) contactsResults.postValue(emptyList()) + } else { + uiState.postValue(SearchUiState.TYPING) } search(query.trim().also { currentSearchQuery = it }, saveInHistory) } @@ -156,17 +177,32 @@ class SearchViewModel @Inject constructor( fun setFilter(filter: ThreadFilter, isEnabled: Boolean = true) = viewModelScope.launch(ioCoroutineContext) { if (isEnabled && currentFilters.contains(filter)) return@launch + + uiState.postValue(SearchUiState.FILTERING) + contactsResults.postValue(emptyList()) + if (isEnabled) { - contactsResults.postValue(emptyList()) trackSearchEvent(filter.matomoName) filter.select() } else { filter.unselect() + if (currentFilters.isEmpty()) { + val newState = if (currentSearchQuery.isNotBlank()) SearchUiState.TYPING else SearchUiState.IDLE + uiState.postValue(newState) + } } } fun unselectMutuallyExclusiveFilters() = viewModelScope.launch(ioCoroutineContext) { currentFilters.removeAll(setOf(ThreadFilter.SEEN, ThreadFilter.UNSEEN, ThreadFilter.STARRED)) + val newState = if (currentFilters.isEmpty() && currentSearchQuery.isNotBlank()) { + SearchUiState.TYPING + } else if (currentFilters.isEmpty()) { + SearchUiState.IDLE + } else { + SearchUiState.FILTERING + } + uiState.postValue(newState) search(filters = currentFilters) } @@ -213,18 +249,22 @@ class SearchViewModel @Inject constructor( ) = withContext(ioCoroutineContext) { cancelSearch() - contactsResults.postValue(emptyList()) searchJob = launch { delay(SEARCH_DEBOUNCE_DURATION) ensureActive() - contactsResults.postValue(emptyList()) + val showContacts = shouldShowContacts() && + query.isNotBlank() && + !query.contains("\"") && + !isLengthTooShort(query) - if (!saveInHistory && query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query)) { + if (showContacts) { val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") val contacts = mergedContactController.searchMergedContacts(queryClean) contactsResults.postValue(contacts) + }else{ + contactsResults.postValue(emptyList()) } From f44adef29f09f35fdc33dd3a83d732dae8f217c9 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Tue, 14 Apr 2026 11:43:21 +0200 Subject: [PATCH 08/21] fix: Display contacts even when no threads are present in results and improve bottom margin handling for the last contact --- .../mail/ui/main/AvatarNameEmailView.kt | 2 - .../mail/ui/main/folder/ThreadItem.kt | 1 + .../mail/ui/main/folder/ThreadListAdapter.kt | 55 +++++++++---------- .../mail/ui/main/search/SearchFragment.kt | 15 +++-- .../mail/ui/main/search/SearchViewModel.kt | 31 +++++++---- app/src/main/res/layout/item_spacer_small.xml | 20 +++++++ 6 files changed, 75 insertions(+), 49 deletions(-) create mode 100644 app/src/main/res/layout/item_spacer_small.xml diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt index a400ef311fe..c3b08b8e6f7 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt @@ -152,8 +152,6 @@ class AvatarNameEmailView @JvmOverloads constructor( marginStart = margin } } - - override fun setOnClickListener(onClickListener: OnClickListener?) { binding.root.setOnClickListener(onClickListener) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt index 2deb13f6258..6988e572f07 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt @@ -28,4 +28,5 @@ sealed interface ThreadListItem { data class ContactItem(val contact: MergedContact) : ThreadListItem data class FlushFolderButton(val folderRole: Folder.FolderRole) : ThreadListItem data object LoadMore : ThreadListItem + data object Spacer : ThreadListItem } 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 7cc5a92328c..2df601f62ce 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 @@ -50,7 +50,6 @@ import com.infomaniak.core.legacy.utils.capitalizeFirstChar import com.infomaniak.core.legacy.utils.context import com.infomaniak.core.legacy.utils.setMarginsRelative import com.infomaniak.core.matomo.Matomo.TrackerAction -import com.infomaniak.core.ui.view.extension.setMargins import com.infomaniak.core.ui.view.toPx import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeAdapter import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView @@ -68,9 +67,9 @@ import com.infomaniak.mail.data.models.correspondent.Recipient import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.databinding.CardviewThreadItemBinding import com.infomaniak.mail.databinding.ItemBannerWithActionViewBinding -import com.infomaniak.mail.databinding.ItemContactBinding import com.infomaniak.mail.databinding.ItemContactHeaderBinding import com.infomaniak.mail.databinding.ItemContactSearchBinding +import com.infomaniak.mail.databinding.ItemSpacerSmallBinding import com.infomaniak.mail.databinding.ItemThreadDateSeparatorBinding import com.infomaniak.mail.databinding.ItemThreadLoadMoreButtonBinding import com.infomaniak.mail.ui.main.folder.ThreadListAdapter.ThreadListViewHolder @@ -128,8 +127,6 @@ class ThreadListAdapter @Inject constructor( private var searchContact = emptyList() - private var spaceAfterLastContact: Int = 8 - private var folderRole: FolderRole? = null private var multiSelection: MultiSelectionListener? = null private var isFolderNameVisible: Boolean = false @@ -168,6 +165,7 @@ class ThreadListAdapter @Inject constructor( is ThreadListItem.FlushFolderButton -> DisplayType.FLUSH_FOLDER_BUTTON.layout is ThreadListItem.ContactItem -> DisplayType.CONTACT_ITEM.layout is ThreadListItem.ContactHeader -> DisplayType.CONTACT_HEADER.layout + is ThreadListItem.Spacer -> DisplayType.SPACER.layout ThreadListItem.LoadMore -> DisplayType.LOAD_MORE_BUTTON.layout } }.getOrDefault(super.getItemViewType(position)) @@ -205,6 +203,7 @@ class ThreadListAdapter @Inject constructor( R.layout.item_thread_load_more_button -> ItemThreadLoadMoreButtonBinding.inflate(layoutInflater, parent, false) R.layout.item_contact_search -> ItemContactSearchBinding.inflate(layoutInflater, parent, false) R.layout.item_contact_header -> ItemContactHeaderBinding.inflate(layoutInflater, parent, false) + R.layout.item_spacer_small -> ItemSpacerSmallBinding.inflate(layoutInflater, parent, false) else -> CardviewThreadItemBinding.inflate(layoutInflater, parent, false) } @@ -212,13 +211,13 @@ class ThreadListAdapter @Inject constructor( } override fun onBindViewHolder(holder: ThreadListViewHolder, position: Int, payloads: MutableList) = runCatchingRealm { - val payload = payloads.firstOrNull() if (payload !is NotificationType) { super.onBindViewHolder(holder, position, payloads) return@runCatchingRealm } + if (payload == NotificationType.SELECTED_STATE && holder.itemViewType == DisplayType.THREAD.layout) { val binding = holder.binding as CardviewThreadItemBinding val thread = (dataSet[position] as ThreadListItem.Content).thread @@ -251,15 +250,10 @@ class ThreadListAdapter @Inject constructor( } DisplayType.CONTACT_ITEM.layout -> { val contactItem = item as ThreadListItem.ContactItem - val totalContacts = dataSet.count { it is ThreadListItem.ContactItem } - - val contactsUpToThisPosition = dataSet.subList(0, position + 1).count { it is ThreadListItem.ContactItem } - val isLastContact = contactsUpToThisPosition == totalContacts - - (this as ItemContactSearchBinding).displayContactItem( - contactItem.contact, - isLastContact - ) + (this as ItemContactSearchBinding).displayContactItem(contactItem.contact) + } + DisplayType.SPACER.layout -> { + return } } } @@ -627,21 +621,13 @@ class ThreadListAdapter @Inject constructor( } } - private fun ItemContactSearchBinding.displayContactItem(contact: MergedContact, isLastContact: Boolean) { + private fun ItemContactSearchBinding.displayContactItem(contact: MergedContact) { contactDetails.setMergedContact(contact) contactDetails.setAvatarMarginStart(0) - if (isLastContact) { - val marginBottom = root.context.resources.getDimensionPixelSize(R.dimen.spaceBetweenContactsAndThreads) - (contactDetails.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin = marginBottom - } - contactWithSpace.setOnClickListener { - callbacks?.onContactClicked?.invoke(contact) + callbacks?.onContactClicked?.invoke(contact) // TODO: ripple } - - - } private fun ItemContactHeaderBinding.displayContactHeader(totalContact: Int) { @@ -789,6 +775,18 @@ class ThreadListAdapter @Inject constructor( } } + fun addContactOnList(): List { + return mutableListOf().apply { + if (searchContact.isNotEmpty()) { + add(ThreadListItem.ContactHeader) + searchContact.forEach { contact -> + add(ThreadListItem.ContactItem(contact)) + } + add(ThreadListItem.Spacer) + } + } + } + private fun formatList( threads: List, context: Context, @@ -797,12 +795,7 @@ class ThreadListAdapter @Inject constructor( scope: CoroutineScope, ) = mutableListOf().apply { - if (searchContact.isNotEmpty()) { - add(ThreadListItem.ContactHeader) - searchContact.forEach { contact -> - add(ThreadListItem.ContactItem(contact)) - } - } + addAll(addContactOnList()) if ((folderRole == FolderRole.TRASH || folderRole == FolderRole.SPAM) && threads.isNotEmpty()) { add(ThreadListItem.FlushFolderButton(folderRole)) @@ -861,6 +854,7 @@ class ThreadListAdapter @Inject constructor( fun updateSearchContacts(contacts: List) { searchContact = contacts + notifyDataSetChanged() } fun updateSelection() { @@ -891,6 +885,7 @@ class ThreadListAdapter @Inject constructor( LOAD_MORE_BUTTON(R.layout.item_thread_load_more_button), CONTACT_HEADER(R.layout.item_contact_header), CONTACT_ITEM(R.layout.item_contact_search), + SPACER(R.layout.item_spacer_small) } enum class NotificationType { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 26ad32b7517..0921292b64d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -19,6 +19,7 @@ package com.infomaniak.mail.ui.main.search import android.os.Bundle import android.os.CountDownTimer +import android.service.autofill.Dataset import android.util.Log import android.view.LayoutInflater import android.view.View @@ -115,11 +116,6 @@ class SearchFragment : TwoPaneFragment() { searchViewModel.executePendingSearch() - searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> - threadListAdapter.updateSearchContacts(contacts) - threadListAdapter.notifyDataSetChanged() - } - setupAdapter() setupListeners() @@ -133,6 +129,7 @@ class SearchFragment : TwoPaneFragment() { observeVisibilityModeUpdates() observeSearchResults() observeHistory() + observeContactsResults() } private fun handleEdgeToEdge(): Unit = with(binding) { @@ -405,6 +402,14 @@ class SearchFragment : TwoPaneFragment() { } } + private fun observeContactsResults() { + searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> + threadListAdapter.updateSearchContacts(contacts) + threadListAdapter.dataSet = threadListAdapter.addContactOnList() + threadListAdapter.notifyDataSetChanged() + } + } + private fun updateHistoryEmptyStateVisibility(isThereHistory: Boolean) = with(binding) { recentSearches.isVisible = isThereHistory noHistory.isGone = isThereHistory diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index c116bed5f88..bcf70d58bf8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -31,6 +31,7 @@ import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.LocalSettings.ThreadMode import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.api.ApiRoutes.contact +import com.infomaniak.mail.data.api.ApiRoutes.contacts import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxContent.ThreadController @@ -62,12 +63,7 @@ import kotlinx.coroutines.withContext import java.text.Normalizer import javax.inject.Inject -enum class SearchUiState { - IDLE, - TYPING, - FILTERING, - VALIDATED -} + @HiltViewModel class SearchViewModel @Inject constructor( @@ -258,13 +254,16 @@ class SearchViewModel @Inject constructor( !query.contains("\"") && !isLengthTooShort(query) - if (showContacts) { + val contacts = if (showContacts) { val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") - val contacts = mergedContactController.searchMergedContacts(queryClean) - contactsResults.postValue(contacts) - }else{ + val contactsList = mergedContactController.searchMergedContacts(queryClean) + + contactsResults.postValue(contactsList) + contactsList + } else { contactsResults.postValue(emptyList()) + emptyList() } @@ -276,7 +275,7 @@ class SearchViewModel @Inject constructor( if (!shouldGetNextPage) resetPaginationData() computeSearchFilters(folder, filters, query)?.let { newFilters -> - fetchThreads(folder, newFilters, query, shouldGetNextPage) + fetchThreads(folder, newFilters, query, shouldGetNextPage, contacts.isNotEmpty()) if (saveInHistory) query.let(history::postValue) } } @@ -304,6 +303,7 @@ class SearchViewModel @Inject constructor( newFilters: Set, query: String, shouldGetNextPage: Boolean, + hasContacts: Boolean = false, ) { visibilityMode.postValue(VisibilityMode.LOADING) @@ -339,7 +339,7 @@ class SearchViewModel @Inject constructor( val resultsVisibilityMode = when { newFilters.isEmpty() && isLengthTooShort(query) -> VisibilityMode.RECENT_SEARCHES - threadController.getSearchThreadsCount() == 0L -> VisibilityMode.NO_RESULTS + threadController.getSearchThreadsCount() == 0L && !hasContacts -> VisibilityMode.NO_RESULTS else -> VisibilityMode.RESULTS } @@ -372,6 +372,13 @@ class SearchViewModel @Inject constructor( threadController.saveSearchThreads(searchThreads) } + enum class SearchUiState { + IDLE, + TYPING, + FILTERING, + VALIDATED + } + companion object { private val TAG: String = SearchViewModel::class.java.simpleName private const val MIN_SEARCH_QUERY = 2 // The minimum value allowed for a search query diff --git a/app/src/main/res/layout/item_spacer_small.xml b/app/src/main/res/layout/item_spacer_small.xml new file mode 100644 index 00000000000..db0b221bff6 --- /dev/null +++ b/app/src/main/res/layout/item_spacer_small.xml @@ -0,0 +1,20 @@ + + From 4b1de5cdb22cebf6aaccd34a43ea1fe3d53e862e Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Tue, 14 Apr 2026 14:05:44 +0200 Subject: [PATCH 09/21] feat: Add rounded ripple effect matching thread style --- .../mail/ui/main/AvatarNameEmailView.kt | 5 ++ .../mail/ui/main/folder/ThreadListAdapter.kt | 1 + .../main/res/layout/item_contact_search.xml | 61 +++++++++++-------- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt index c3b08b8e6f7..8cbd7518ca2 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt @@ -152,6 +152,11 @@ class AvatarNameEmailView @JvmOverloads constructor( marginStart = margin } } + + fun removeBackground() = with(binding){ + root.background = null + } + override fun setOnClickListener(onClickListener: OnClickListener?) { binding.root.setOnClickListener(onClickListener) } 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 2df601f62ce..45e36d87c56 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 @@ -624,6 +624,7 @@ class ThreadListAdapter @Inject constructor( private fun ItemContactSearchBinding.displayContactItem(contact: MergedContact) { contactDetails.setMergedContact(contact) contactDetails.setAvatarMarginStart(0) + contactDetails.removeBackground() contactWithSpace.setOnClickListener { callbacks?.onContactClicked?.invoke(contact) // TODO: ripple diff --git a/app/src/main/res/layout/item_contact_search.xml b/app/src/main/res/layout/item_contact_search.xml index c36a38ce578..d2edbe71ffd 100644 --- a/app/src/main/res/layout/item_contact_search.xml +++ b/app/src/main/res/layout/item_contact_search.xml @@ -15,40 +15,49 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - - - - + android:layout_marginStart="@dimen/marginStandardVerySmall" + app:cardCornerRadius="@null" + app:cardPreventCornerOverlap="false" + app:shapeAppearanceOverlay="@style/RoundedDecoratedTextItemShapeAppearance"> - - + android:background="?selectableItemBackground" + android:orientation="horizontal"> + + + + + - + + From 3298174869111a89841fbcce3938dcc2c8a104de Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Tue, 14 Apr 2026 14:54:31 +0200 Subject: [PATCH 10/21] fix: Clean code --- .../cache/userInfo/MergedContactController.kt | 1 + .../mail/ui/main/folder/ThreadListAdapter.kt | 10 ++++------ .../ui/main/folder/ThreadListAdapterCallbacks.kt | 1 - .../mail/ui/main/search/SearchFragment.kt | 15 +++------------ .../mail/ui/main/search/SearchViewModel.kt | 4 ---- 5 files changed, 8 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt index 85ae4013073..b2bd45dea8c 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt @@ -42,6 +42,7 @@ class MergedContactController @Inject constructor(@UserInfoRealm private val use .sort(MergedContact::name.name) .sort(MergedContact::comesFromApi.name, Sort.DESCENDING) } + fun searchMergedContacts(searchQuery: String, limit: Int = 5): List { return userInfoRealm.query("name CONTAINS[c] $0 OR email CONTAINS[c] $0", searchQuery, searchQuery) .sort(MergedContact::name.name) 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 45e36d87c56..3d94a21fa63 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 @@ -22,7 +22,6 @@ import android.content.res.ColorStateList import android.graphics.Canvas import android.text.Spannable import android.text.TextUtils.TruncateAt -import android.util.Log import android.view.HapticFeedbackConstants import android.view.LayoutInflater import android.view.View @@ -245,8 +244,7 @@ class ThreadListAdapter @Inject constructor( (this as ItemThreadLoadMoreButtonBinding).displayLoadMoreButton() } DisplayType.CONTACT_HEADER.layout -> { - val totalContacts = dataSet.count { it is ThreadListItem.ContactItem } - (this as ItemContactHeaderBinding).displayContactHeader(totalContacts) + (this as ItemContactHeaderBinding).displayContactHeader() } DisplayType.CONTACT_ITEM.layout -> { val contactItem = item as ThreadListItem.ContactItem @@ -627,12 +625,12 @@ class ThreadListAdapter @Inject constructor( contactDetails.removeBackground() contactWithSpace.setOnClickListener { - callbacks?.onContactClicked?.invoke(contact) // TODO: ripple + callbacks?.onContactClicked?.invoke(contact) } } - private fun ItemContactHeaderBinding.displayContactHeader(totalContact: Int) { - contactsTitle.text = context.getString(R.string.contactsSearch) // TODO: manage plurals + private fun ItemContactHeaderBinding.displayContactHeader() { + contactsTitle.text = context.getString(R.string.contactsSearch) } override fun onSwipeStarted(item: ThreadListItem, viewHolder: ThreadListViewHolder) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt index 04127821720..b451e85b4d1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt @@ -29,6 +29,5 @@ interface ThreadListAdapterCallbacks { var onPositionClickedChanged: (position: Int, previousPosition: Int) -> Unit var deleteThreadInRealm: (threadUid: String) -> Unit val getFeatureFlags: () -> Mailbox.FeatureFlagSet? - var onContactClicked: ((MergedContact) -> Unit)? } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 0921292b64d..d30b9ef8d24 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -19,8 +19,6 @@ package com.infomaniak.mail.ui.main.search import android.os.Bundle import android.os.CountDownTimer -import android.service.autofill.Dataset -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -47,7 +45,6 @@ import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.MatomoMail.trackThreadListEvent import com.infomaniak.mail.R -import com.infomaniak.mail.data.api.ApiRoutes.contacts import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox @@ -267,15 +264,9 @@ class SearchFragment : TwoPaneFragment() { binding.mutuallyExclusiveChipGroup.setOnCheckedStateChangeListener { chipGroup, _ -> when (chipGroup.checkedChipId) { - R.id.read -> { - setFilter(ThreadFilter.SEEN) - } - R.id.unread -> { - setFilter(ThreadFilter.UNSEEN) - } - R.id.favorites -> { - setFilter(ThreadFilter.STARRED) - } + R.id.read -> setFilter(ThreadFilter.SEEN) + R.id.unread -> setFilter(ThreadFilter.UNSEEN) + R.id.favorites -> setFilter(ThreadFilter.STARRED) else -> unselectMutuallyExclusiveFilters() } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index bcf70d58bf8..aa981a4beb6 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -18,7 +18,6 @@ package com.infomaniak.mail.ui.main.search import android.app.Application -import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle @@ -30,8 +29,6 @@ import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.LocalSettings.ThreadMode import com.infomaniak.mail.data.api.ApiRepository -import com.infomaniak.mail.data.api.ApiRoutes.contact -import com.infomaniak.mail.data.api.ApiRoutes.contacts import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxContent.ThreadController @@ -64,7 +61,6 @@ import java.text.Normalizer import javax.inject.Inject - @HiltViewModel class SearchViewModel @Inject constructor( application: Application, From bbfbd93091e9e6426333e54d5809d93e1b2b113a Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Thu, 16 Apr 2026 12:53:22 +0200 Subject: [PATCH 11/21] fix: Prevent contacts from displaying when performing a search, switching folders, and searching again --- .../infomaniak/mail/ui/main/folder/TwoPaneFragment.kt | 2 +- .../infomaniak/mail/ui/main/search/SearchFragment.kt | 10 ++++++++-- .../infomaniak/mail/ui/main/search/SearchViewModel.kt | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt index ffe68685a30..0f293a4a88a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt @@ -136,7 +136,7 @@ abstract class TwoPaneFragment : Fragment() { } } - fun handleOnBackPressed() { + open fun handleOnBackPressed() { when { isOnlyRightShown() -> { if (SDK_INT >= 29) requireActivity().window.isNavigationBarContrastEnforced = true diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index d30b9ef8d24..29f17061500 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -169,6 +169,13 @@ class SearchFragment : TwoPaneFragment() { } } + override fun handleOnBackPressed(){ + if (!isOnlyRightShown()){ + searchViewModel.clearSearchState() + } + super.handleOnBackPressed() + } + private fun setupAdapter() { threadListAdapter( folderRole = null, @@ -214,8 +221,7 @@ class SearchFragment : TwoPaneFragment() { private fun setupListeners() = with(binding) { toolbar.setNavigationOnClickListener { - searchViewModel.resetFolderFilter() - searchViewModel.contactsResults.value = emptyList() + searchViewModel.clearSearchState() findNavController().popBackStack() } swipeRefreshLayout.setOnRefreshListener { searchViewModel.refreshSearch() } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index aa981a4beb6..fd70ba9b2b2 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -141,6 +141,12 @@ class SearchViewModel @Inject constructor( unselectAllChipFilters() } + fun clearSearchState(){ + currentSearchQuery = "" + contactsResults.value = emptyList() + resetFolderFilter() + } + fun refreshSearch() = viewModelScope.launch(ioCoroutineContext) { search() } From 66dd3437ef781b1233342dfb5348514db02ecd1d Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Thu, 16 Apr 2026 13:20:18 +0200 Subject: [PATCH 12/21] fix: Resolve LiveData race condition in state checking --- .../mail/ui/main/search/SearchViewModel.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index fd70ba9b2b2..4e91c0a1830 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -89,6 +89,8 @@ class SearchViewModel @Inject constructor( val uiState = MutableLiveData(SearchUiState.IDLE) + private var currentUiState: SearchUiState = SearchUiState.IDLE + val contactsResults = MutableLiveData>() private var currentFilters = mutableSetOf() var isAllFoldersSelected: Boolean = false @@ -127,12 +129,11 @@ class SearchViewModel @Inject constructor( } private fun shouldShowContacts(): Boolean { - val state = uiState.value val hasQuery = currentSearchQuery.isNotBlank() val hasNoFilters = currentFilters.isEmpty() - val notValidated = state != SearchUiState.VALIDATED + val notValidated = currentUiState != SearchUiState.VALIDATED - return state == SearchUiState.TYPING && hasQuery && hasNoFilters && notValidated + return currentUiState == SearchUiState.TYPING && hasQuery && hasNoFilters && notValidated } fun resetFolderFilter() { @@ -141,7 +142,7 @@ class SearchViewModel @Inject constructor( unselectAllChipFilters() } - fun clearSearchState(){ + fun clearSearchState() { currentSearchQuery = "" contactsResults.value = emptyList() resetFolderFilter() @@ -153,11 +154,11 @@ class SearchViewModel @Inject constructor( fun searchQuery(query: String, saveInHistory: Boolean = false) = viewModelScope.launch(ioCoroutineContext) { if (query.isNotBlank() && isLengthTooShort(query)) return@launch + + currentUiState = if (saveInHistory) SearchUiState.VALIDATED else SearchUiState.TYPING + uiState.postValue(currentUiState) if (saveInHistory) { - uiState.postValue(SearchUiState.VALIDATED) contactsResults.postValue(emptyList()) - } else { - uiState.postValue(SearchUiState.TYPING) } search(query.trim().also { currentSearchQuery = it }, saveInHistory) } @@ -176,6 +177,7 @@ class SearchViewModel @Inject constructor( fun setFilter(filter: ThreadFilter, isEnabled: Boolean = true) = viewModelScope.launch(ioCoroutineContext) { if (isEnabled && currentFilters.contains(filter)) return@launch + currentUiState = SearchUiState.FILTERING uiState.postValue(SearchUiState.FILTERING) contactsResults.postValue(emptyList()) From b782edd23b091f2dcc9f9f8a5674f67a70c79632 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Thu, 16 Apr 2026 13:39:33 +0200 Subject: [PATCH 13/21] refactor: Clean code --- .../java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt | 1 - 1 file changed, 1 deletion(-) 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 3d94a21fa63..183a98da8ba 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 @@ -853,7 +853,6 @@ class ThreadListAdapter @Inject constructor( fun updateSearchContacts(contacts: List) { searchContact = contacts - notifyDataSetChanged() } fun updateSelection() { From 1092a7989201825ca00246f0a43e760e0b79c750 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Thu, 16 Apr 2026 14:05:15 +0200 Subject: [PATCH 14/21] fix: Add missing element from commit 01d14c15e3e456873a28199878978611ba1c9d34 --- .../java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 4e91c0a1830..0f15c3b912b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -188,6 +188,7 @@ class SearchViewModel @Inject constructor( filter.unselect() if (currentFilters.isEmpty()) { val newState = if (currentSearchQuery.isNotBlank()) SearchUiState.TYPING else SearchUiState.IDLE + currentUiState = newState uiState.postValue(newState) } } @@ -202,6 +203,7 @@ class SearchViewModel @Inject constructor( } else { SearchUiState.FILTERING } + currentUiState = newState uiState.postValue(newState) search(filters = currentFilters) } From 26f4a6378eab662216537a34a5b62f00ea4d3df3 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Thu, 16 Apr 2026 14:11:07 +0200 Subject: [PATCH 15/21] fix: Prevent dataset overwrite --- .../com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt | 4 +++- .../com/infomaniak/mail/ui/main/search/SearchFragment.kt | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 183a98da8ba..c3e2a8fa575 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 @@ -851,8 +851,10 @@ class ThreadListAdapter @Inject constructor( folderRole = newRole } - fun updateSearchContacts(contacts: List) { + fun updateSearchContacts(contacts: List, lifecycleScope: LifecycleCoroutineScope) { searchContact = contacts + val currentThreads = dataSet.filterIsInstance().map { it.thread } + updateList(currentThreads, lifecycleScope) } fun updateSelection() { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 29f17061500..33a92b0417f 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -29,6 +29,7 @@ import androidx.core.view.updatePaddingRelative import androidx.core.widget.doOnTextChanged import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy @@ -401,9 +402,7 @@ class SearchFragment : TwoPaneFragment() { private fun observeContactsResults() { searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> - threadListAdapter.updateSearchContacts(contacts) - threadListAdapter.dataSet = threadListAdapter.addContactOnList() - threadListAdapter.notifyDataSetChanged() + threadListAdapter.updateSearchContacts(contacts, viewLifecycleOwner.lifecycleScope) } } From c6b75086477bc0be4460162d9a2b7fedaff50bd1 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 17 Apr 2026 09:28:13 +0200 Subject: [PATCH 16/21] refactor: Clean code --- .../cache/userInfo/MergedContactController.kt | 1 - .../mail/ui/main/AvatarNameEmailView.kt | 8 ++++---- .../mail/ui/main/folder/ThreadListAdapter.kt | 18 ++++-------------- .../mail/ui/main/search/SearchFragment.kt | 4 ++-- .../mail/ui/main/search/SearchViewModel.kt | 12 ++++++------ 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt index b2bd45dea8c..9805c2fe422 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt @@ -49,7 +49,6 @@ class MergedContactController @Inject constructor(@UserInfoRealm private val use .sort(MergedContact::comesFromApi.name, Sort.DESCENDING) .limit(limit) .find() - .map { it } } private fun getMergedContactFromContactGroupQuery(contact: ContactGroup): RealmQuery { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt index 8cbd7518ca2..94190f70471 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/AvatarNameEmailView.kt @@ -147,14 +147,14 @@ class AvatarNameEmailView @JvmOverloads constructor( userEmail.text = searchQuery } - fun setAvatarMarginStart(margin: Int) = with(binding) { - avatarLayout.updateLayoutParams { + fun setAvatarMarginStart(margin: Int) { + binding.avatarLayout.updateLayoutParams { marginStart = margin } } - fun removeBackground() = with(binding){ - root.background = null + fun removeBackground(){ + binding.root.background = null } override fun setOnClickListener(onClickListener: OnClickListener?) { 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 c3e2a8fa575..1de26761d57 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 @@ -216,7 +216,6 @@ class ThreadListAdapter @Inject constructor( return@runCatchingRealm } - if (payload == NotificationType.SELECTED_STATE && holder.itemViewType == DisplayType.THREAD.layout) { val binding = holder.binding as CardviewThreadItemBinding val thread = (dataSet[position] as ThreadListItem.Content).thread @@ -240,19 +239,13 @@ class ThreadListAdapter @Inject constructor( (this as ItemBannerWithActionViewBinding) .displayFlushFolderButton((item as ThreadListItem.FlushFolderButton).folderRole) } - DisplayType.LOAD_MORE_BUTTON.layout -> { - (this as ItemThreadLoadMoreButtonBinding).displayLoadMoreButton() - } - DisplayType.CONTACT_HEADER.layout -> { - (this as ItemContactHeaderBinding).displayContactHeader() - } + DisplayType.LOAD_MORE_BUTTON.layout -> (this as ItemThreadLoadMoreButtonBinding).displayLoadMoreButton() + DisplayType.CONTACT_HEADER.layout -> (this as ItemContactHeaderBinding).displayContactHeader() DisplayType.CONTACT_ITEM.layout -> { val contactItem = item as ThreadListItem.ContactItem (this as ItemContactSearchBinding).displayContactItem(contactItem.contact) } - DisplayType.SPACER.layout -> { - return - } + DisplayType.SPACER.layout -> Unit } } @@ -457,7 +450,6 @@ class ThreadListAdapter @Inject constructor( return null } - /** * Sometimes, we want to select a Thread before even having any Thread in the Adapter (example: coming from a Notification). * The selected Thread's UI will update when the Adapter triggers the next batch of `onBindViewHolder()`. @@ -778,9 +770,7 @@ class ThreadListAdapter @Inject constructor( return mutableListOf().apply { if (searchContact.isNotEmpty()) { add(ThreadListItem.ContactHeader) - searchContact.forEach { contact -> - add(ThreadListItem.ContactItem(contact)) - } + searchContact.forEach { contact -> add(ThreadListItem.ContactItem(contact)) } add(ThreadListItem.Spacer) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 33a92b0417f..ba4fc3c25fa 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -170,8 +170,8 @@ class SearchFragment : TwoPaneFragment() { } } - override fun handleOnBackPressed(){ - if (!isOnlyRightShown()){ + override fun handleOnBackPressed() { + if (!isOnlyRightShown()) { searchViewModel.clearSearchState() } super.handleOnBackPressed() diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 0f15c3b912b..fa335489337 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -155,11 +155,14 @@ class SearchViewModel @Inject constructor( fun searchQuery(query: String, saveInHistory: Boolean = false) = viewModelScope.launch(ioCoroutineContext) { if (query.isNotBlank() && isLengthTooShort(query)) return@launch - currentUiState = if (saveInHistory) SearchUiState.VALIDATED else SearchUiState.TYPING - uiState.postValue(currentUiState) if (saveInHistory) { + currentUiState = SearchUiState.VALIDATED contactsResults.postValue(emptyList()) + } else { + currentUiState = SearchUiState.TYPING } + uiState.postValue(currentUiState) + search(query.trim().also { currentSearchQuery = it }, saveInHistory) } @@ -264,14 +267,11 @@ class SearchViewModel @Inject constructor( val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") val contactsList = mergedContactController.searchMergedContacts(queryClean) - - contactsResults.postValue(contactsList) contactsList } else { - contactsResults.postValue(emptyList()) emptyList() } - + contactsResults.postValue(contacts) mailboxController .getMailbox(AccountUtils.currentUserId, AccountUtils.currentMailboxId) From 61a487fe6a84c21e2d005ece3b53e8be3cf8bd62 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 17 Apr 2026 09:44:37 +0200 Subject: [PATCH 17/21] refactor: Use DateSeparator in contact header and rename it --- .../mail/ui/main/folder/ThreadItem.kt | 3 +- .../mail/ui/main/folder/ThreadListAdapter.kt | 34 +++++++------------ .../mail/utils/extensions/Extensions.kt | 2 +- .../main/res/layout/item_contact_header.xml | 28 --------------- ...tor.xml => item_thread_text_separator.xml} | 0 5 files changed, 14 insertions(+), 53 deletions(-) delete mode 100644 app/src/main/res/layout/item_contact_header.xml rename app/src/main/res/layout/{item_thread_date_separator.xml => item_thread_text_separator.xml} (100%) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt index 6988e572f07..b6f69a23411 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt @@ -23,8 +23,7 @@ import com.infomaniak.mail.data.models.thread.Thread sealed interface ThreadListItem { data class Content(val thread: Thread) : ThreadListItem - data class DateSeparator(val title: String) : ThreadListItem - data object ContactHeader : ThreadListItem + data class TextSeparator(val title: String) : ThreadListItem data class ContactItem(val contact: MergedContact) : ThreadListItem data class FlushFolderButton(val folderRole: Folder.FolderRole) : ThreadListItem data object LoadMore : ThreadListItem 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 1de26761d57..a5fe446f0fe 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 @@ -66,11 +66,10 @@ import com.infomaniak.mail.data.models.correspondent.Recipient import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.databinding.CardviewThreadItemBinding import com.infomaniak.mail.databinding.ItemBannerWithActionViewBinding -import com.infomaniak.mail.databinding.ItemContactHeaderBinding import com.infomaniak.mail.databinding.ItemContactSearchBinding import com.infomaniak.mail.databinding.ItemSpacerSmallBinding -import com.infomaniak.mail.databinding.ItemThreadDateSeparatorBinding import com.infomaniak.mail.databinding.ItemThreadLoadMoreButtonBinding +import com.infomaniak.mail.databinding.ItemThreadTextSeparatorBinding import com.infomaniak.mail.ui.main.folder.ThreadListAdapter.ThreadListViewHolder import com.infomaniak.mail.ui.main.thread.SubjectFormatter import com.infomaniak.mail.ui.main.thread.SubjectFormatter.TagColor @@ -160,10 +159,9 @@ class ThreadListAdapter @Inject constructor( override fun getItemViewType(position: Int): Int = runCatchingRealm { return when (dataSet[position]) { is ThreadListItem.Content -> DisplayType.THREAD.layout - is ThreadListItem.DateSeparator -> DisplayType.DATE_SEPARATOR.layout + is ThreadListItem.TextSeparator -> DisplayType.TEXT_SEPARATOR.layout is ThreadListItem.FlushFolderButton -> DisplayType.FLUSH_FOLDER_BUTTON.layout is ThreadListItem.ContactItem -> DisplayType.CONTACT_ITEM.layout - is ThreadListItem.ContactHeader -> DisplayType.CONTACT_HEADER.layout is ThreadListItem.Spacer -> DisplayType.SPACER.layout ThreadListItem.LoadMore -> DisplayType.LOAD_MORE_BUTTON.layout } @@ -181,8 +179,7 @@ class ThreadListAdapter @Inject constructor( override fun getItemId(position: Int): Long = runCatchingRealm { return when (val item = dataSet[position]) { is ThreadListItem.Content -> item.thread.uid.hashCode().toLong() - is ThreadListItem.DateSeparator -> item.title.hashCode().toLong() - ThreadListItem.ContactHeader -> "contact_header".hashCode().toLong() + is ThreadListItem.TextSeparator -> item.title.hashCode().toLong() is ThreadListItem.ContactItem -> item.contact.email.hashCode().toLong() else -> super.getItemId(position) } @@ -197,11 +194,10 @@ class ThreadListAdapter @Inject constructor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThreadListViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = when (viewType) { - R.layout.item_thread_date_separator -> ItemThreadDateSeparatorBinding.inflate(layoutInflater, parent, false) + R.layout.item_thread_text_separator -> ItemThreadTextSeparatorBinding.inflate(layoutInflater, parent, false) R.layout.item_banner_with_action_view -> ItemBannerWithActionViewBinding.inflate(layoutInflater, parent, false) R.layout.item_thread_load_more_button -> ItemThreadLoadMoreButtonBinding.inflate(layoutInflater, parent, false) R.layout.item_contact_search -> ItemContactSearchBinding.inflate(layoutInflater, parent, false) - R.layout.item_contact_header -> ItemContactHeaderBinding.inflate(layoutInflater, parent, false) R.layout.item_spacer_small -> ItemSpacerSmallBinding.inflate(layoutInflater, parent, false) else -> CardviewThreadItemBinding.inflate(layoutInflater, parent, false) } @@ -232,15 +228,14 @@ class ThreadListAdapter @Inject constructor( DisplayType.THREAD.layout -> { (this as CardviewThreadItemBinding).displayThread((item as ThreadListItem.Content).thread, position) } - DisplayType.DATE_SEPARATOR.layout -> { - (this as ItemThreadDateSeparatorBinding).displayDateSeparator((item as ThreadListItem.DateSeparator).title) + DisplayType.TEXT_SEPARATOR.layout -> { + (this as ItemThreadTextSeparatorBinding).displayDateSeparator((item as ThreadListItem.TextSeparator).title) } DisplayType.FLUSH_FOLDER_BUTTON.layout -> { (this as ItemBannerWithActionViewBinding) .displayFlushFolderButton((item as ThreadListItem.FlushFolderButton).folderRole) } DisplayType.LOAD_MORE_BUTTON.layout -> (this as ItemThreadLoadMoreButtonBinding).displayLoadMoreButton() - DisplayType.CONTACT_HEADER.layout -> (this as ItemContactHeaderBinding).displayContactHeader() DisplayType.CONTACT_ITEM.layout -> { val contactItem = item as ThreadListItem.ContactItem (this as ItemContactSearchBinding).displayContactItem(contactItem.contact) @@ -583,7 +578,7 @@ class ThreadListAdapter @Inject constructor( setTextColor(context.getColor(R.color.primaryTextColor)) } - private fun ItemThreadDateSeparatorBinding.displayDateSeparator(title: String) { + private fun ItemThreadTextSeparatorBinding.displayDateSeparator(title: String) { sectionTitle.text = title } @@ -621,10 +616,6 @@ class ThreadListAdapter @Inject constructor( } } - private fun ItemContactHeaderBinding.displayContactHeader() { - contactsTitle.text = context.getString(R.string.contactsSearch) - } - override fun onSwipeStarted(item: ThreadListItem, viewHolder: ThreadListViewHolder) { if (item is ThreadListItem.Content) item.thread.updateDynamicIcons() } @@ -766,10 +757,10 @@ class ThreadListAdapter @Inject constructor( } } - fun addContactOnList(): List { + fun addContactOnList(context: Context): List { return mutableListOf().apply { if (searchContact.isNotEmpty()) { - add(ThreadListItem.ContactHeader) + add(ThreadListItem.TextSeparator(context.getString(R.string.contactsSearch))) searchContact.forEach { contact -> add(ThreadListItem.ContactItem(contact)) } add(ThreadListItem.Spacer) } @@ -784,7 +775,7 @@ class ThreadListAdapter @Inject constructor( scope: CoroutineScope, ) = mutableListOf().apply { - addAll(addContactOnList()) + addAll(addContactOnList(context)) if ((folderRole == FolderRole.TRASH || folderRole == FolderRole.SPAM) && threads.isNotEmpty()) { add(ThreadListItem.FlushFolderButton(folderRole)) @@ -805,7 +796,7 @@ class ThreadListAdapter @Inject constructor( val sectionTitle = thread.getSectionTitle(context) if (sectionTitle != previousSectionTitle) { - add(ThreadListItem.DateSeparator(sectionTitle)) + add(ThreadListItem.TextSeparator(sectionTitle)) previousSectionTitle = sectionTitle } @@ -870,10 +861,9 @@ class ThreadListAdapter @Inject constructor( private enum class DisplayType(val layout: Int) { THREAD(R.layout.cardview_thread_item), - DATE_SEPARATOR(R.layout.item_thread_date_separator), + TEXT_SEPARATOR(R.layout.item_thread_text_separator), FLUSH_FOLDER_BUTTON(R.layout.item_banner_with_action_view), LOAD_MORE_BUTTON(R.layout.item_thread_load_more_button), - CONTACT_HEADER(R.layout.item_contact_header), CONTACT_ITEM(R.layout.item_contact_search), SPACER(R.layout.item_spacer_small) } 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 b70f87b6126..e75f350c0fe 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 @@ -369,7 +369,7 @@ fun DragDropSwipeRecyclerView.addStickyDateDecoration(adapter: ThreadListAdapter parent = this, shouldFadeOutHeader = false, isHeader = { position -> - return@HeaderItemDecoration position >= 0 && adapter.dataSet[position] is ThreadListItem.DateSeparator + return@HeaderItemDecoration position >= 0 && adapter.dataSet[position] is ThreadListItem.TextSeparator }, ), ) diff --git a/app/src/main/res/layout/item_contact_header.xml b/app/src/main/res/layout/item_contact_header.xml deleted file mode 100644 index a6ba9a61022..00000000000 --- a/app/src/main/res/layout/item_contact_header.xml +++ /dev/null @@ -1,28 +0,0 @@ - - diff --git a/app/src/main/res/layout/item_thread_date_separator.xml b/app/src/main/res/layout/item_thread_text_separator.xml similarity index 100% rename from app/src/main/res/layout/item_thread_date_separator.xml rename to app/src/main/res/layout/item_thread_text_separator.xml From a89962a717681402c1fa32a57b54a28a6ab09042 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 17 Apr 2026 14:38:37 +0200 Subject: [PATCH 18/21] fix: Combine contacts and thread results for search --- .../mail/ui/main/folder/ThreadItem.kt | 2 +- .../mail/ui/main/folder/ThreadListAdapter.kt | 81 ++++++------------ .../mail/ui/main/search/SearchFragment.kt | 82 ++++++++++++++++--- .../mail/ui/main/search/SearchViewModel.kt | 23 ++++-- .../infomaniak/mail/utils/ThreadListUtils.kt | 47 +++++++++++ .../mail/utils/extensions/Extensions.kt | 2 +- ...ator.xml => item_thread_section_title.xml} | 0 7 files changed, 163 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/infomaniak/mail/utils/ThreadListUtils.kt rename app/src/main/res/layout/{item_thread_text_separator.xml => item_thread_section_title.xml} (100%) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt index b6f69a23411..c81a038f41f 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt @@ -23,7 +23,7 @@ import com.infomaniak.mail.data.models.thread.Thread sealed interface ThreadListItem { data class Content(val thread: Thread) : ThreadListItem - data class TextSeparator(val title: String) : ThreadListItem + data class SectionTitle(val title: String) : ThreadListItem data class ContactItem(val contact: MergedContact) : ThreadListItem data class FlushFolderButton(val folderRole: Folder.FolderRole) : ThreadListItem data object LoadMore : ThreadListItem 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 a5fe446f0fe..cfbd1e7992f 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 @@ -38,14 +38,6 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.google.android.material.card.MaterialCardView -import com.infomaniak.core.common.utils.format -import com.infomaniak.core.common.utils.isInTheFuture -import com.infomaniak.core.common.utils.isThisMonth -import com.infomaniak.core.common.utils.isThisWeek -import com.infomaniak.core.common.utils.isThisYear -import com.infomaniak.core.common.utils.isToday -import com.infomaniak.core.common.utils.isYesterday -import com.infomaniak.core.legacy.utils.capitalizeFirstChar import com.infomaniak.core.legacy.utils.context import com.infomaniak.core.legacy.utils.setMarginsRelative import com.infomaniak.core.matomo.Matomo.TrackerAction @@ -69,7 +61,7 @@ import com.infomaniak.mail.databinding.ItemBannerWithActionViewBinding import com.infomaniak.mail.databinding.ItemContactSearchBinding import com.infomaniak.mail.databinding.ItemSpacerSmallBinding import com.infomaniak.mail.databinding.ItemThreadLoadMoreButtonBinding -import com.infomaniak.mail.databinding.ItemThreadTextSeparatorBinding +import com.infomaniak.mail.databinding.ItemThreadSectionTitleBinding import com.infomaniak.mail.ui.main.folder.ThreadListAdapter.ThreadListViewHolder import com.infomaniak.mail.ui.main.thread.SubjectFormatter import com.infomaniak.mail.ui.main.thread.SubjectFormatter.TagColor @@ -78,13 +70,12 @@ import com.infomaniak.mail.ui.main.thread.ThreadFragment.NextThreadTarget.NEXT_C import com.infomaniak.mail.ui.main.thread.ThreadFragment.NextThreadTarget.PREVIOUS_CHRONOLOGICAL_THREAD import com.infomaniak.mail.utils.RealmChangesBinding import com.infomaniak.mail.utils.SentryDebug +import com.infomaniak.mail.utils.ThreadListUtils import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.extensions.formatSubject import com.infomaniak.mail.utils.extensions.getAttributeColor import com.infomaniak.mail.utils.extensions.isEmail -import com.infomaniak.mail.utils.extensions.isLastWeek import com.infomaniak.mail.utils.extensions.postfixWithTag -import com.infomaniak.mail.utils.extensions.toDate import dagger.hilt.android.qualifiers.ActivityContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -123,8 +114,6 @@ class ThreadListAdapter @Inject constructor( private var swipingIsAuthorized: Boolean = true private var isLoadMoreDisplayed = false - private var searchContact = emptyList() - private var folderRole: FolderRole? = null private var multiSelection: MultiSelectionListener? = null private var isFolderNameVisible: Boolean = false @@ -159,7 +148,7 @@ class ThreadListAdapter @Inject constructor( override fun getItemViewType(position: Int): Int = runCatchingRealm { return when (dataSet[position]) { is ThreadListItem.Content -> DisplayType.THREAD.layout - is ThreadListItem.TextSeparator -> DisplayType.TEXT_SEPARATOR.layout + is ThreadListItem.SectionTitle -> DisplayType.TEXT_SEPARATOR.layout is ThreadListItem.FlushFolderButton -> DisplayType.FLUSH_FOLDER_BUTTON.layout is ThreadListItem.ContactItem -> DisplayType.CONTACT_ITEM.layout is ThreadListItem.Spacer -> DisplayType.SPACER.layout @@ -179,7 +168,7 @@ class ThreadListAdapter @Inject constructor( override fun getItemId(position: Int): Long = runCatchingRealm { return when (val item = dataSet[position]) { is ThreadListItem.Content -> item.thread.uid.hashCode().toLong() - is ThreadListItem.TextSeparator -> item.title.hashCode().toLong() + is ThreadListItem.SectionTitle -> item.title.hashCode().toLong() is ThreadListItem.ContactItem -> item.contact.email.hashCode().toLong() else -> super.getItemId(position) } @@ -194,7 +183,7 @@ class ThreadListAdapter @Inject constructor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThreadListViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = when (viewType) { - R.layout.item_thread_text_separator -> ItemThreadTextSeparatorBinding.inflate(layoutInflater, parent, false) + R.layout.item_thread_section_title -> ItemThreadSectionTitleBinding.inflate(layoutInflater, parent, false) R.layout.item_banner_with_action_view -> ItemBannerWithActionViewBinding.inflate(layoutInflater, parent, false) R.layout.item_thread_load_more_button -> ItemThreadLoadMoreButtonBinding.inflate(layoutInflater, parent, false) R.layout.item_contact_search -> ItemContactSearchBinding.inflate(layoutInflater, parent, false) @@ -229,7 +218,7 @@ class ThreadListAdapter @Inject constructor( (this as CardviewThreadItemBinding).displayThread((item as ThreadListItem.Content).thread, position) } DisplayType.TEXT_SEPARATOR.layout -> { - (this as ItemThreadTextSeparatorBinding).displayDateSeparator((item as ThreadListItem.TextSeparator).title) + (this as ItemThreadSectionTitleBinding).displayDateSeparator((item as ThreadListItem.SectionTitle).title) } DisplayType.FLUSH_FOLDER_BUTTON.layout -> { (this as ItemBannerWithActionViewBinding) @@ -578,7 +567,7 @@ class ThreadListAdapter @Inject constructor( setTextColor(context.getColor(R.color.primaryTextColor)) } - private fun ItemThreadTextSeparatorBinding.displayDateSeparator(title: String) { + private fun ItemThreadSectionTitleBinding.displayDateSeparator(title: String) { sectionTitle.text = title } @@ -736,6 +725,20 @@ class ThreadListAdapter @Inject constructor( } } + fun updateListWithThreadListItems( + threadListItems: List, + lifecycleScope: LifecycleCoroutineScope + ) { + formatListJob?.cancel() + formatListJob = lifecycleScope.launch { + Dispatchers.Main { + // Put back "Load more" button if it was already there + dataSet = if (isLoadMoreDisplayed) threadListItems + ThreadListItem.LoadMore else threadListItems + refreshSelectedPositionIfPossible() + } + } + } + private fun checkShouldUpdateOpenedThreadPosition(currentUid: String): Boolean { val currentPos = openedThreadPosition @@ -757,16 +760,6 @@ class ThreadListAdapter @Inject constructor( } } - fun addContactOnList(context: Context): List { - return mutableListOf().apply { - if (searchContact.isNotEmpty()) { - add(ThreadListItem.TextSeparator(context.getString(R.string.contactsSearch))) - searchContact.forEach { contact -> add(ThreadListItem.ContactItem(contact)) } - add(ThreadListItem.Spacer) - } - } - } - private fun formatList( threads: List, context: Context, @@ -775,8 +768,6 @@ class ThreadListAdapter @Inject constructor( scope: CoroutineScope, ) = mutableListOf().apply { - addAll(addContactOnList(context)) - if ((folderRole == FolderRole.TRASH || folderRole == FolderRole.SPAM) && threads.isNotEmpty()) { add(ThreadListItem.FlushFolderButton(folderRole)) } @@ -794,9 +785,9 @@ class ThreadListAdapter @Inject constructor( threads.forEach { thread -> scope.ensureActive() - val sectionTitle = thread.getSectionTitle(context) + val sectionTitle = ThreadListUtils.getSectionTitle(thread, context) if (sectionTitle != previousSectionTitle) { - add(ThreadListItem.TextSeparator(sectionTitle)) + add(ThreadListItem.SectionTitle(sectionTitle)) previousSectionTitle = sectionTitle } @@ -806,7 +797,7 @@ class ThreadListAdapter @Inject constructor( } } - private fun cleanMultiSelectionItems(threads: List, scope: CoroutineScope) { + fun cleanMultiSelectionItems(threads: List, scope: CoroutineScope) { if (multiSelection?.selectedItems?.let(threads::containsAll) == false) { multiSelection?.selectedItems?.removeAll { scope.ensureActive() @@ -815,29 +806,10 @@ class ThreadListAdapter @Inject constructor( } } - private fun Thread.getSectionTitle(context: Context): String = with(internalDate.toDate()) { - return when { - isInTheFuture() -> context.getString(R.string.comingSoon) - isToday() -> context.getString(R.string.threadListSectionToday) - isYesterday() -> context.getString(R.string.messageDetailsYesterday) - isThisWeek() -> context.getString(R.string.threadListSectionThisWeek) - isLastWeek() -> context.getString(R.string.threadListSectionLastWeek) - isThisMonth() -> context.getString(R.string.threadListSectionThisMonth) - isThisYear() -> format(FULL_MONTH).capitalizeFirstChar() - else -> format(MONTH_AND_YEAR).capitalizeFirstChar() - } - } - fun updateFolderRole(newRole: FolderRole?) { folderRole = newRole } - fun updateSearchContacts(contacts: List, lifecycleScope: LifecycleCoroutineScope) { - searchContact = contacts - val currentThreads = dataSet.filterIsInstance().map { it.thread } - updateList(currentThreads, lifecycleScope) - } - fun updateSelection() { notifyItemRangeChanged(0, itemCount, NotificationType.SELECTED_STATE) } @@ -861,7 +833,7 @@ class ThreadListAdapter @Inject constructor( private enum class DisplayType(val layout: Int) { THREAD(R.layout.cardview_thread_item), - TEXT_SEPARATOR(R.layout.item_thread_text_separator), + TEXT_SEPARATOR(R.layout.item_thread_section_title), FLUSH_FOLDER_BUTTON(R.layout.item_banner_with_action_view), LOAD_MORE_BUTTON(R.layout.item_thread_load_more_button), CONTACT_ITEM(R.layout.item_contact_search), @@ -876,9 +848,6 @@ class ThreadListAdapter @Inject constructor( companion object { private const val SWIPE_ANIMATION_THRESHOLD = 0.15f - - private const val FULL_MONTH = "MMMM" - private const val MONTH_AND_YEAR = "MMMM yyyy" } class ThreadListViewHolder(val binding: ViewBinding) : ViewHolder(binding.root) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index ba4fc3c25fa..126a664dde4 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.mail.ui.main.search +import android.content.Context import android.os.Bundle import android.os.CountDownTimer import android.view.LayoutInflater @@ -46,6 +47,7 @@ import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.MatomoMail.trackThreadListEvent import com.infomaniak.mail.R +import com.infomaniak.mail.data.LocalSettings.ThreadDensity import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox @@ -53,11 +55,13 @@ import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.databinding.FragmentSearchBinding import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks +import com.infomaniak.mail.ui.main.folder.ThreadListItem import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment -import com.infomaniak.mail.utils.RealmChangesBinding.Companion.bindResultsChangeToAdapter +import com.infomaniak.mail.utils.ThreadListUtils import com.infomaniak.mail.utils.Utils.Shortcuts +import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets import com.infomaniak.mail.utils.extensions.applyWindowInsetsListener @@ -67,6 +71,10 @@ import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safelyAnimatedNavigation import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch @AndroidEntryPoint class SearchFragment : TwoPaneFragment() { @@ -127,7 +135,6 @@ class SearchFragment : TwoPaneFragment() { observeVisibilityModeUpdates() observeSearchResults() observeHistory() - observeContactsResults() } private fun handleEdgeToEdge(): Unit = with(binding) { @@ -388,8 +395,69 @@ class SearchFragment : TwoPaneFragment() { } } - private fun observeSearchResults() { - searchViewModel.searchResults.bindResultsChangeToAdapter(viewLifecycleOwner, threadListAdapter) + private fun observeSearchResults() = viewLifecycleOwner.lifecycleScope.launch { + searchViewModel.allSearchResults.collectLatest { searchResults -> + with(searchResults) { + val formattedList = runCatchingRealm { + formatSearchList( + threads, + contacts, + requireContext(), + localSettings.threadDensity, + viewLifecycleOwner.lifecycleScope + ) + }.getOrDefault(emptyList()) + + threadListAdapter.updateListWithThreadListItems(formattedList, viewLifecycleOwner.lifecycleScope) + } + } + } + + private fun formatSearchList( + threads: List, + contacts: List, + context: Context, + threadDensity: ThreadDensity, + scope: CoroutineScope, + ) = mutableListOf().apply { + + when { + threadDensity == ThreadDensity.COMPACT -> { + threadListAdapter.cleanMultiSelectionItems(threads, scope) + addAll(threads.map { ThreadListItem.Content(it) }) + } + else -> { + var previousSectionTitle = "" + + contacts.forEach { contact -> + scope.ensureActive() + val sectionTitle = context.getString(R.string.contactsSearch) + previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) + add(ThreadListItem.ContactItem(contact)) + } + + if (contacts.isNotEmpty()) add(ThreadListItem.Spacer) + + threads.forEach { thread -> + scope.ensureActive() + val sectionTitle = ThreadListUtils.getSectionTitle(thread, context) + previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) + + add(ThreadListItem.Content(thread)) + } + } + } + } + + private fun MutableList.addSectionTitle( + sectionTitle: String, + previousSectionTitle: String + ): String { + if (sectionTitle != previousSectionTitle) { + add(ThreadListItem.SectionTitle(sectionTitle)) + return sectionTitle + } + return previousSectionTitle } private fun observeHistory() { @@ -400,12 +468,6 @@ class SearchFragment : TwoPaneFragment() { } } - private fun observeContactsResults() { - searchViewModel.contactsResults.observe(viewLifecycleOwner) { contacts -> - threadListAdapter.updateSearchContacts(contacts, viewLifecycleOwner.lifecycleScope) - } - } - private fun updateHistoryEmptyStateVisibility(isThereHistory: Boolean) = with(binding) { recentSearches.isVisible = isThereHistory noHistory.isGone = isThereHistory diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index fa335489337..d1f8611e46b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -21,7 +21,6 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.infomaniak.core.legacy.utils.SingleLiveEvent import com.infomaniak.core.sentry.SentryLog @@ -52,6 +51,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -91,7 +92,6 @@ class SearchViewModel @Inject constructor( private var currentUiState: SearchUiState = SearchUiState.IDLE - val contactsResults = MutableLiveData>() private var currentFilters = mutableSetOf() var isAllFoldersSelected: Boolean = false @@ -107,7 +107,12 @@ class SearchViewModel @Inject constructor( private val isLastPage get() = resourceNext.isNullOrBlank() private var searchJob: Job? = null - val searchResults = threadController.getSearchThreadsAsync().asLiveData(ioCoroutineContext) + val threadsSearchResults = threadController.getSearchThreadsAsync() + val contactsResults = MutableStateFlow>(emptyList()) + val allSearchResults = combine( + contactsResults, + threadsSearchResults, + ) { contacts, threads -> SearchResults(contacts, threads.list) } private val currentMailboxFlow = mailboxController.getMailboxAsync( AccountUtils.currentUserId, @@ -157,7 +162,7 @@ class SearchViewModel @Inject constructor( if (saveInHistory) { currentUiState = SearchUiState.VALIDATED - contactsResults.postValue(emptyList()) + contactsResults.value = emptyList() } else { currentUiState = SearchUiState.TYPING } @@ -182,7 +187,7 @@ class SearchViewModel @Inject constructor( currentUiState = SearchUiState.FILTERING uiState.postValue(SearchUiState.FILTERING) - contactsResults.postValue(emptyList()) + contactsResults.value = emptyList() if (isEnabled) { trackSearchEvent(filter.matomoName) @@ -271,7 +276,6 @@ class SearchViewModel @Inject constructor( } else { emptyList() } - contactsResults.postValue(contacts) mailboxController .getMailbox(AccountUtils.currentUserId, AccountUtils.currentMailboxId) @@ -284,6 +288,8 @@ class SearchViewModel @Inject constructor( fetchThreads(folder, newFilters, query, shouldGetNextPage, contacts.isNotEmpty()) if (saveInHistory) query.let(history::postValue) } + + contactsResults.emit(contacts) } } @@ -378,6 +384,11 @@ class SearchViewModel @Inject constructor( threadController.saveSearchThreads(searchThreads) } + data class SearchResults( + val contacts: List, + val threads: List, + ) + enum class SearchUiState { IDLE, TYPING, diff --git a/app/src/main/java/com/infomaniak/mail/utils/ThreadListUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/ThreadListUtils.kt new file mode 100644 index 00000000000..d626cae8375 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/utils/ThreadListUtils.kt @@ -0,0 +1,47 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.utils + +import android.content.Context +import com.infomaniak.core.common.utils.format +import com.infomaniak.core.common.utils.isInTheFuture +import com.infomaniak.core.common.utils.isThisMonth +import com.infomaniak.core.common.utils.isThisWeek +import com.infomaniak.core.common.utils.isThisYear +import com.infomaniak.core.common.utils.isToday +import com.infomaniak.core.common.utils.isYesterday +import com.infomaniak.core.legacy.utils.capitalizeFirstChar +import com.infomaniak.mail.R +import com.infomaniak.mail.data.models.thread.Thread +import com.infomaniak.mail.utils.extensions.isLastWeek +import com.infomaniak.mail.utils.extensions.toDate + +object ThreadListUtils { + fun getSectionTitle(thread: Thread, context: Context): String = with(thread.internalDate.toDate()) { + return when { + isInTheFuture() -> context.getString(R.string.comingSoon) + isToday() -> context.getString(R.string.threadListSectionToday) + isYesterday() -> context.getString(R.string.messageDetailsYesterday) + isThisWeek() -> context.getString(R.string.threadListSectionThisWeek) + isLastWeek() -> context.getString(R.string.threadListSectionLastWeek) + isThisMonth() -> context.getString(R.string.threadListSectionThisMonth) + isThisYear() -> format("MMMM").capitalizeFirstChar() + else -> format("MMMM yyyy").capitalizeFirstChar() + } + } +} 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 e75f350c0fe..2a2853b1140 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 @@ -369,7 +369,7 @@ fun DragDropSwipeRecyclerView.addStickyDateDecoration(adapter: ThreadListAdapter parent = this, shouldFadeOutHeader = false, isHeader = { position -> - return@HeaderItemDecoration position >= 0 && adapter.dataSet[position] is ThreadListItem.TextSeparator + return@HeaderItemDecoration position >= 0 && adapter.dataSet[position] is ThreadListItem.SectionTitle }, ), ) diff --git a/app/src/main/res/layout/item_thread_text_separator.xml b/app/src/main/res/layout/item_thread_section_title.xml similarity index 100% rename from app/src/main/res/layout/item_thread_text_separator.xml rename to app/src/main/res/layout/item_thread_section_title.xml From 014e32379b5671b05269daa3dc2dd2943f5567ff Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Mon, 20 Apr 2026 10:52:39 +0200 Subject: [PATCH 19/21] fix: Update query in realm to support diacritic --- .../mail/data/cache/userInfo/MergedContactController.kt | 9 +++++++-- .../infomaniak/mail/ui/main/search/SearchViewModel.kt | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt index 9805c2fe422..49dc9115f37 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/userInfo/MergedContactController.kt @@ -43,8 +43,13 @@ class MergedContactController @Inject constructor(@UserInfoRealm private val use .sort(MergedContact::comesFromApi.name, Sort.DESCENDING) } - fun searchMergedContacts(searchQuery: String, limit: Int = 5): List { - return userInfoRealm.query("name CONTAINS[c] $0 OR email CONTAINS[c] $0", searchQuery, searchQuery) + fun searchMergedContacts(searchQuery: String, searchQueryClean: String, limit: Int = 5): List { + val queryStr = if (searchQuery != searchQueryClean) { + "(name CONTAINS[c] $0 OR email CONTAINS[c] $0) OR (name CONTAINS[c] $1 OR email CONTAINS[c] $1)" + } else { + "name CONTAINS[c] $0 OR email CONTAINS[c] $0" + } + return userInfoRealm.query(queryStr, searchQuery, searchQueryClean) .sort(MergedContact::name.name) .sort(MergedContact::comesFromApi.name, Sort.DESCENDING) .limit(limit) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index d1f8611e46b..ef32e9fe147 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -271,7 +271,7 @@ class SearchViewModel @Inject constructor( val contacts = if (showContacts) { val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") - val contactsList = mergedContactController.searchMergedContacts(queryClean) + val contactsList = mergedContactController.searchMergedContacts(query,queryClean) contactsList } else { emptyList() From 5e760732b0fe868eabbbeb712b9d2b0cf5ca9d0f Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Mon, 20 Apr 2026 13:05:58 +0200 Subject: [PATCH 20/21] fix: Move formatSearchList in viewmodel --- .../mail/ui/main/search/SearchFragment.kt | 68 +------------------ .../mail/ui/main/search/SearchViewModel.kt | 63 ++++++++++++++--- 2 files changed, 56 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 126a664dde4..ea3b8bf4314 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -17,7 +17,6 @@ */ package com.infomaniak.mail.ui.main.search -import android.content.Context import android.os.Bundle import android.os.CountDownTimer import android.view.LayoutInflater @@ -47,7 +46,6 @@ import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.MatomoMail.trackThreadListEvent import com.infomaniak.mail.R -import com.infomaniak.mail.data.LocalSettings.ThreadDensity import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox @@ -55,13 +53,10 @@ import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.databinding.FragmentSearchBinding import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks -import com.infomaniak.mail.ui.main.folder.ThreadListItem import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment -import com.infomaniak.mail.utils.ThreadListUtils import com.infomaniak.mail.utils.Utils.Shortcuts -import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets import com.infomaniak.mail.utils.extensions.applyWindowInsetsListener @@ -71,8 +66,6 @@ import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safelyAnimatedNavigation import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -397,67 +390,8 @@ class SearchFragment : TwoPaneFragment() { private fun observeSearchResults() = viewLifecycleOwner.lifecycleScope.launch { searchViewModel.allSearchResults.collectLatest { searchResults -> - with(searchResults) { - val formattedList = runCatchingRealm { - formatSearchList( - threads, - contacts, - requireContext(), - localSettings.threadDensity, - viewLifecycleOwner.lifecycleScope - ) - }.getOrDefault(emptyList()) - - threadListAdapter.updateListWithThreadListItems(formattedList, viewLifecycleOwner.lifecycleScope) - } - } - } - - private fun formatSearchList( - threads: List, - contacts: List, - context: Context, - threadDensity: ThreadDensity, - scope: CoroutineScope, - ) = mutableListOf().apply { - - when { - threadDensity == ThreadDensity.COMPACT -> { - threadListAdapter.cleanMultiSelectionItems(threads, scope) - addAll(threads.map { ThreadListItem.Content(it) }) - } - else -> { - var previousSectionTitle = "" - - contacts.forEach { contact -> - scope.ensureActive() - val sectionTitle = context.getString(R.string.contactsSearch) - previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) - add(ThreadListItem.ContactItem(contact)) - } - - if (contacts.isNotEmpty()) add(ThreadListItem.Spacer) - - threads.forEach { thread -> - scope.ensureActive() - val sectionTitle = ThreadListUtils.getSectionTitle(thread, context) - previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) - - add(ThreadListItem.Content(thread)) - } - } - } - } - - private fun MutableList.addSectionTitle( - sectionTitle: String, - previousSectionTitle: String - ): String { - if (sectionTitle != previousSectionTitle) { - add(ThreadListItem.SectionTitle(sectionTitle)) - return sectionTitle + threadListAdapter.updateListWithThreadListItems(searchResults, viewLifecycleOwner.lifecycleScope) } - return previousSectionTitle } private fun observeHistory() { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index ef32e9fe147..e152239b97b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -18,6 +18,7 @@ package com.infomaniak.mail.ui.main.search import android.app.Application +import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle @@ -25,7 +26,9 @@ import androidx.lifecycle.viewModelScope import com.infomaniak.core.legacy.utils.SingleLiveEvent import com.infomaniak.core.sentry.SentryLog import com.infomaniak.mail.MatomoMail.trackSearchEvent +import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.data.LocalSettings.ThreadDensity import com.infomaniak.mail.data.LocalSettings.ThreadMode import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.cache.mailboxContent.MessageController @@ -39,9 +42,12 @@ import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.di.IoDispatcher +import com.infomaniak.mail.ui.main.folder.ThreadListItem import com.infomaniak.mail.ui.main.search.SearchFragment.VisibilityMode import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.SearchUtils +import com.infomaniak.mail.utils.ThreadListUtils +import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.coroutineContext import dagger.hilt.android.lifecycle.HiltViewModel import io.sentry.Sentry @@ -61,7 +67,6 @@ import kotlinx.coroutines.withContext import java.text.Normalizer import javax.inject.Inject - @HiltViewModel class SearchViewModel @Inject constructor( application: Application, @@ -112,7 +117,9 @@ class SearchViewModel @Inject constructor( val allSearchResults = combine( contactsResults, threadsSearchResults, - ) { contacts, threads -> SearchResults(contacts, threads.list) } + ) { contacts, threads -> + runCatchingRealm { formatSearchList(threads.list, contacts, application, globalCoroutineScope) } .getOrDefault(emptyList()) + } private val currentMailboxFlow = mailboxController.getMailboxAsync( AccountUtils.currentUserId, @@ -171,6 +178,51 @@ class SearchViewModel @Inject constructor( search(query.trim().also { currentSearchQuery = it }, saveInHistory) } + private fun MutableList.addSectionTitle( + sectionTitle: String, + previousSectionTitle: String + ): String { + if (sectionTitle != previousSectionTitle) { + add(ThreadListItem.SectionTitle(sectionTitle)) + return sectionTitle + } + return previousSectionTitle + } + private fun formatSearchList( + threads: List, + contacts: List, + context: Context, + scope: CoroutineScope, + ) = mutableListOf().apply { + when { + localSettings.threadDensity == ThreadDensity.COMPACT -> { + // Line taken from ThreadListAdapter.formatList() but apparently unnecessary + // threadListAdapter.cleanMultiSelectionItems(threads, scope) + addAll(threads.map { ThreadListItem.Content(it) }) + } + else -> { + var previousSectionTitle = "" + + contacts.forEach { contact -> + scope.ensureActive() + val sectionTitle = context.getString(R.string.contactsSearch) + previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) + add(ThreadListItem.ContactItem(contact)) + } + + if (contacts.isNotEmpty()) add(ThreadListItem.Spacer) + + threads.forEach { thread -> + scope.ensureActive() + val sectionTitle = ThreadListUtils.getSectionTitle(thread, context) + previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) + + add(ThreadListItem.Content(thread)) + } + } + } + } + fun selectAllFoldersFilter(isSelected: Boolean) { isAllFoldersSelected = isSelected } @@ -271,7 +323,7 @@ class SearchViewModel @Inject constructor( val contacts = if (showContacts) { val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) .replace("\\p{M}".toRegex(), "") - val contactsList = mergedContactController.searchMergedContacts(query,queryClean) + val contactsList = mergedContactController.searchMergedContacts(query, queryClean) contactsList } else { emptyList() @@ -384,11 +436,6 @@ class SearchViewModel @Inject constructor( threadController.saveSearchThreads(searchThreads) } - data class SearchResults( - val contacts: List, - val threads: List, - ) - enum class SearchUiState { IDLE, TYPING, From bbee23e66c3bc181f77a9617a867f210315e2838 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Tue, 21 Apr 2026 08:16:58 +0200 Subject: [PATCH 21/21] fix: Reduce method length --- .../mail/ui/main/search/SearchViewModel.kt | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index e152239b97b..00c36d0233e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -118,7 +118,7 @@ class SearchViewModel @Inject constructor( contactsResults, threadsSearchResults, ) { contacts, threads -> - runCatchingRealm { formatSearchList(threads.list, contacts, application, globalCoroutineScope) } .getOrDefault(emptyList()) + runCatchingRealm { formatSearchList(threads.list, contacts, application, globalCoroutineScope) }.getOrDefault(emptyList()) } private val currentMailboxFlow = mailboxController.getMailboxAsync( @@ -188,41 +188,64 @@ class SearchViewModel @Inject constructor( } return previousSectionTitle } + private fun formatSearchList( threads: List, contacts: List, context: Context, scope: CoroutineScope, - ) = mutableListOf().apply { - when { - localSettings.threadDensity == ThreadDensity.COMPACT -> { - // Line taken from ThreadListAdapter.formatList() but apparently unnecessary - // threadListAdapter.cleanMultiSelectionItems(threads, scope) - addAll(threads.map { ThreadListItem.Content(it) }) - } - else -> { - var previousSectionTitle = "" + ): List { - contacts.forEach { contact -> - scope.ensureActive() - val sectionTitle = context.getString(R.string.contactsSearch) - previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) - add(ThreadListItem.ContactItem(contact)) - } + if (localSettings.threadDensity == ThreadDensity.COMPACT) { + // Line taken from ThreadListAdapter.formatList() but apparently unnecessary + // threadListAdapter.cleanMultiSelectionItems(threads, scope) + return threads.map { ThreadListItem.Content(it) } + } - if (contacts.isNotEmpty()) add(ThreadListItem.Spacer) + return buildList { + var previousSectionTitle = "" + + addContacts(contacts, context, scope) { title -> + previousSectionTitle = addSectionTitle(title, previousSectionTitle) + } - threads.forEach { thread -> - scope.ensureActive() - val sectionTitle = ThreadListUtils.getSectionTitle(thread, context) - previousSectionTitle = addSectionTitle(sectionTitle, previousSectionTitle) + if (contacts.isNotEmpty()) add(ThreadListItem.Spacer) - add(ThreadListItem.Content(thread)) - } + addThreads(threads, context, scope) { title -> + previousSectionTitle = addSectionTitle(title, previousSectionTitle) } } } + private fun MutableList.addContacts( + contacts: List, + context: Context, + scope: CoroutineScope, + updateSection: (String) -> Unit + ) { + val sectionTitle = context.getString(R.string.contactsSearch) + + contacts.forEach { contact -> + scope.ensureActive() + updateSection(sectionTitle) + add(ThreadListItem.ContactItem(contact)) + } + } + + private fun MutableList.addThreads( + threads: List, + context: Context, + scope: CoroutineScope, + updateSection: (String) -> Unit + ) { + threads.forEach { thread -> + scope.ensureActive() + val sectionTitle = ThreadListUtils.getSectionTitle(thread, context) + updateSection(sectionTitle) + add(ThreadListItem.Content(thread)) + } + } + fun selectAllFoldersFilter(isSelected: Boolean) { isAllFoldersSelected = isSelected }