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..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,6 +43,19 @@ class MergedContactController @Inject constructor(@UserInfoRealm private val use .sort(MergedContact::comesFromApi.name, Sort.DESCENDING) } + 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) + .find() + } + private fun getMergedContactFromContactGroupQuery(contact: ContactGroup): RealmQuery { return userInfoRealm.query("${MergedContact::remoteContactGroupIds.name} == $0", contact.id) .sort(MergedContact::name.name) 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..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 @@ -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,16 @@ class AvatarNameEmailView @JvmOverloads constructor( userEmail.text = searchQuery } + fun setAvatarMarginStart(margin: Int) { + binding.avatarLayout.updateLayoutParams { + marginStart = margin + } + } + + fun removeBackground(){ + 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/ThreadItem.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadItem.kt index f5c2308e24c..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 @@ -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 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 + 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 9be94ea93e3..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 @@ -61,12 +53,15 @@ 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.ItemThreadDateSeparatorBinding +import com.infomaniak.mail.databinding.ItemContactSearchBinding +import com.infomaniak.mail.databinding.ItemSpacerSmallBinding import com.infomaniak.mail.databinding.ItemThreadLoadMoreButtonBinding +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 @@ -75,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 @@ -154,8 +148,10 @@ 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.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 ThreadListItem.LoadMore -> DisplayType.LOAD_MORE_BUTTON.layout } }.getOrDefault(super.getItemViewType(position)) @@ -172,7 +168,8 @@ 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() + is ThreadListItem.SectionTitle -> item.title.hashCode().toLong() + is ThreadListItem.ContactItem -> item.contact.email.hashCode().toLong() else -> super.getItemId(position) } }.getOrDefault(super.getItemId(position)) @@ -186,9 +183,11 @@ 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_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) + R.layout.item_spacer_small -> ItemSpacerSmallBinding.inflate(layoutInflater, parent, false) else -> CardviewThreadItemBinding.inflate(layoutInflater, parent, false) } @@ -196,7 +195,6 @@ 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) @@ -219,16 +217,19 @@ 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 ItemThreadSectionTitleBinding).displayDateSeparator((item as ThreadListItem.SectionTitle).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.LOAD_MORE_BUTTON.layout -> (this as ItemThreadLoadMoreButtonBinding).displayLoadMoreButton() + DisplayType.CONTACT_ITEM.layout -> { + val contactItem = item as ThreadListItem.ContactItem + (this as ItemContactSearchBinding).displayContactItem(contactItem.contact) } + DisplayType.SPACER.layout -> Unit } } @@ -566,7 +567,7 @@ class ThreadListAdapter @Inject constructor( setTextColor(context.getColor(R.color.primaryTextColor)) } - private fun ItemThreadDateSeparatorBinding.displayDateSeparator(title: String) { + private fun ItemThreadSectionTitleBinding.displayDateSeparator(title: String) { sectionTitle.text = title } @@ -594,6 +595,16 @@ class ThreadListAdapter @Inject constructor( } } + private fun ItemContactSearchBinding.displayContactItem(contact: MergedContact) { + contactDetails.setMergedContact(contact) + contactDetails.setAvatarMarginStart(0) + contactDetails.removeBackground() + + contactWithSpace.setOnClickListener { + callbacks?.onContactClicked?.invoke(contact) + } + } + override fun onSwipeStarted(item: ThreadListItem, viewHolder: ThreadListViewHolder) { if (item is ThreadListItem.Content) item.thread.updateDynamicIcons() } @@ -714,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 @@ -760,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.DateSeparator(sectionTitle)) + add(ThreadListItem.SectionTitle(sectionTitle)) previousSectionTitle = sectionTitle } @@ -772,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() @@ -781,19 +806,6 @@ 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 } @@ -821,9 +833,11 @@ 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_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), + SPACER(R.layout.item_spacer_small) } enum class NotificationType { @@ -834,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/folder/ThreadListAdapterCallbacks.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapterCallbacks.kt index 2a0f2839159..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 @@ -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,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/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/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 809589fb612..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 @@ -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 @@ -46,6 +47,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 @@ -54,7 +56,6 @@ import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks 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.Utils.Shortcuts import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets @@ -65,6 +66,8 @@ 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.flow.collectLatest +import kotlinx.coroutines.launch @AndroidEntryPoint class SearchFragment : TwoPaneFragment() { @@ -167,6 +170,13 @@ class SearchFragment : TwoPaneFragment() { } } + override fun handleOnBackPressed() { + if (!isOnlyRightShown()) { + searchViewModel.clearSearchState() + } + super.handleOnBackPressed() + } + private fun setupAdapter() { threadListAdapter( folderRole = null, @@ -198,6 +208,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) + } }, ) @@ -206,7 +222,7 @@ class SearchFragment : TwoPaneFragment() { private fun setupListeners() = with(binding) { toolbar.setNavigationOnClickListener { - searchViewModel.resetFolderFilter() + searchViewModel.clearSearchState() findNavController().popBackStack() } swipeRefreshLayout.setOnRefreshListener { searchViewModel.refreshSearch() } @@ -247,7 +263,6 @@ class SearchFragment : TwoPaneFragment() { private fun setAttachmentsUi() = with(searchViewModel) { binding.attachments.setOnCheckedChangeListener { _, isChecked -> - setFilter(ThreadFilter.ATTACHMENTS, isChecked) } } @@ -373,8 +388,10 @@ class SearchFragment : TwoPaneFragment() { } } - private fun observeSearchResults() { - searchViewModel.searchResults.bindResultsChangeToAdapter(viewLifecycleOwner, threadListAdapter) + private fun observeSearchResults() = viewLifecycleOwner.lifecycleScope.launch { + searchViewModel.allSearchResults.collectLatest { searchResults -> + threadListAdapter.updateListWithThreadListItems(searchResults, viewLifecycleOwner.lifecycleScope) + } } 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 7b9c557e533..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 @@ -18,29 +18,36 @@ 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 -import androidx.lifecycle.asLiveData 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 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 +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 @@ -50,11 +57,14 @@ 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 import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.text.Normalizer import javax.inject.Inject @HiltViewModel @@ -63,6 +73,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 +93,10 @@ class SearchViewModel @Inject constructor( var currentSearchQuery: String = "" private set + val uiState = MutableLiveData(SearchUiState.IDLE) + + private var currentUiState: SearchUiState = SearchUiState.IDLE + private var currentFilters = mutableSetOf() var isAllFoldersSelected: Boolean = false @@ -97,7 +112,14 @@ 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 -> + runCatchingRealm { formatSearchList(threads.list, contacts, application, globalCoroutineScope) }.getOrDefault(emptyList()) + } private val currentMailboxFlow = mailboxController.getMailboxAsync( AccountUtils.currentUserId, @@ -118,21 +140,112 @@ class SearchViewModel @Inject constructor( if (hasPendingSearch) search() } + private fun shouldShowContacts(): Boolean { + val hasQuery = currentSearchQuery.isNotBlank() + val hasNoFilters = currentFilters.isEmpty() + val notValidated = currentUiState != SearchUiState.VALIDATED + + return currentUiState == SearchUiState.TYPING && hasQuery && hasNoFilters && notValidated + } + fun resetFolderFilter() { filterFolder = null isAllFoldersSelected = false unselectAllChipFilters() } + fun clearSearchState() { + currentSearchQuery = "" + contactsResults.value = emptyList() + resetFolderFilter() + } + fun refreshSearch() = viewModelScope.launch(ioCoroutineContext) { search() } fun searchQuery(query: String, saveInHistory: Boolean = false) = viewModelScope.launch(ioCoroutineContext) { if (query.isNotBlank() && isLengthTooShort(query)) return@launch + + if (saveInHistory) { + currentUiState = SearchUiState.VALIDATED + contactsResults.value = emptyList() + } else { + currentUiState = SearchUiState.TYPING + } + uiState.postValue(currentUiState) + 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, + ): List { + + if (localSettings.threadDensity == ThreadDensity.COMPACT) { + // Line taken from ThreadListAdapter.formatList() but apparently unnecessary + // threadListAdapter.cleanMultiSelectionItems(threads, scope) + return threads.map { ThreadListItem.Content(it) } + } + + return buildList { + var previousSectionTitle = "" + + addContacts(contacts, context, scope) { title -> + previousSectionTitle = addSectionTitle(title, previousSectionTitle) + } + + if (contacts.isNotEmpty()) add(ThreadListItem.Spacer) + + 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 } @@ -146,16 +259,35 @@ 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.value = emptyList() + if (isEnabled) { trackSearchEvent(filter.matomoName) filter.select() } else { filter.unselect() + if (currentFilters.isEmpty()) { + val newState = if (currentSearchQuery.isNotBlank()) SearchUiState.TYPING else SearchUiState.IDLE + currentUiState = newState + 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 + } + currentUiState = newState + uiState.postValue(newState) search(filters = currentFilters) } @@ -182,6 +314,7 @@ class SearchViewModel @Inject constructor( } override fun onCleared() { + contactsResults.value = emptyList() cancelSearch() globalCoroutineScope.launch(ioDispatcher) { searchUtils.deleteRealmSearchData() @@ -200,10 +333,25 @@ class SearchViewModel @Inject constructor( shouldGetNextPage: Boolean = false, ) = withContext(ioCoroutineContext) { cancelSearch() + searchJob = launch { delay(SEARCH_DEBOUNCE_DURATION) ensureActive() + val showContacts = shouldShowContacts() && + query.isNotBlank() && + !query.contains("\"") && + !isLengthTooShort(query) + + val contacts = if (showContacts) { + val queryClean = Normalizer.normalize(query, Normalizer.Form.NFD) + .replace("\\p{M}".toRegex(), "") + val contactsList = mergedContactController.searchMergedContacts(query, queryClean) + contactsList + } else { + emptyList() + } + mailboxController .getMailbox(AccountUtils.currentUserId, AccountUtils.currentMailboxId) ?.objectId @@ -212,9 +360,11 @@ 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) } + + contactsResults.emit(contacts) } } @@ -240,6 +390,7 @@ class SearchViewModel @Inject constructor( newFilters: Set, query: String, shouldGetNextPage: Boolean, + hasContacts: Boolean = false, ) { visibilityMode.postValue(VisibilityMode.LOADING) @@ -275,7 +426,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 } @@ -308,6 +459,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/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 b70f87b6126..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.DateSeparator + return@HeaderItemDecoration position >= 0 && adapter.dataSet[position] is ThreadListItem.SectionTitle }, ), ) 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/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 @@ + + diff --git a/app/src/main/res/layout/item_thread_date_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_date_separator.xml rename to app/src/main/res/layout/item_thread_section_title.xml 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