From 88c9da82928d7e19fd012c3ba81a87b8e65860ee Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sun, 31 May 2026 21:26:42 +0200 Subject: [PATCH 01/39] Add common components ParticipantAvatar and TwoLineListItem --- .../android/messaging/ui/common/components/ParticipantAvatar.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/com/android/messaging/ui/common/components/ParticipantAvatar.kt diff --git a/src/com/android/messaging/ui/common/components/ParticipantAvatar.kt b/src/com/android/messaging/ui/common/components/ParticipantAvatar.kt new file mode 100644 index 000000000..e69de29bb From 6021dd6b4a6e1f93dd970af0493fd0d45065fe6c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Tue, 9 Jun 2026 20:02:54 +0200 Subject: [PATCH 02/39] Add conversation list data repository --- .../model/ConversationListItem.kt | 47 +++++ .../model/ConversationListSnapshot.kt | 10 + .../repository/ConversationListRepository.kt | 192 ++++++++++++++++++ .../store/ConversationListStatusStore.kt | 27 +++ .../ConversationListBindsModule.kt | 28 +++ 5 files changed, 304 insertions(+) create mode 100644 src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt create mode 100644 src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt create mode 100644 src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt create mode 100644 src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt create mode 100644 src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt new file mode 100644 index 000000000..11c7f063e --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt @@ -0,0 +1,47 @@ +package com.android.messaging.data.conversationlist.model + +internal data class ConversationListItem( + val conversationId: String, + val title: String?, + val icon: String?, + val subject: String?, + val isArchived: Boolean, + val participant: ConversationListParticipant, + val latestMessage: ConversationListLatestMessage, + val draft: ConversationListDraft, + val notification: ConversationListNotification, +) + +internal data class ConversationListParticipant( + val contactId: Long, + val lookupKey: String?, + val otherNormalizedDestination: String?, + val selfId: String?, + val count: Int, + val isGroup: Boolean, + val includeEmailAddress: Boolean, + val isEnterprise: Boolean, +) + +internal data class ConversationListLatestMessage( + val isRead: Boolean, + val timestamp: Long, + val snippetText: String?, + val previewUri: String?, + val previewContentType: String?, + val status: Int, + val rawTelephonyStatus: Int, + val senderName: String?, +) + +internal data class ConversationListDraft( + val isVisible: Boolean, + val snippetText: String?, + val previewUri: String?, + val previewContentType: String?, + val subject: String?, +) + +internal data class ConversationListNotification( + val isEnabled: Boolean, +) diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt b/src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt new file mode 100644 index 000000000..b34fd5298 --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt @@ -0,0 +1,10 @@ +package com.android.messaging.data.conversationlist.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +internal data class ConversationListSnapshot( + val items: ImmutableList, + val blockedDestinations: ImmutableSet, + val hasFirstSyncCompleted: Boolean, +) diff --git a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt new file mode 100644 index 000000000..14a5f20f4 --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt @@ -0,0 +1,192 @@ +package com.android.messaging.data.conversationlist.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.data.conversationlist.model.ConversationListDraft +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListLatestMessage +import com.android.messaging.data.conversationlist.model.ConversationListNotification +import com.android.messaging.data.conversationlist.model.ConversationListParticipant +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationlist.store.ConversationListStatusStore +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ConversationListData +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.di.core.MessagingDbDispatcher +import com.android.messaging.util.db.ext.getStringOrNull +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface ConversationListRepository { + fun observeInboxSnapshot(): Flow + fun setNewestConversationVisible(isVisible: Boolean) +} + +internal class ConversationListRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + private val statusStore: ConversationListStatusStore, + @param:MessagingDbDispatcher + private val messagingDbDispatcher: CoroutineDispatcher, +) : ConversationListRepository { + + override fun observeInboxSnapshot(): Flow { + val itemsFlow = observeUri( + uri = MessagingContentProvider.CONVERSATIONS_URI, + ).map { queryInboxConversations() } + + val blockedDestinationsFlow = observeUri( + uri = MessagingContentProvider.PARTICIPANTS_URI, + ).map { queryBlockedParticipantDestinations() } + + return combine( + itemsFlow, + blockedDestinationsFlow, + ) { items, blockedDestinations -> + ConversationListSnapshot( + items = items, + blockedDestinations = blockedDestinations, + hasFirstSyncCompleted = statusStore.hasFirstSyncCompleted(), + ) + }.flowOn(messagingDbDispatcher) + } + + override fun setNewestConversationVisible(isVisible: Boolean) { + statusStore.setNewestConversationVisible(isVisible) + } + + private fun observeUri(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + contentResolver.registerContentObserver(uri, true, observer) + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun queryInboxConversations(): ImmutableList { + val cursor = contentResolver.query( + MessagingContentProvider.CONVERSATIONS_URI, + ConversationListItemData.PROJECTION, + ConversationListData.WHERE_NOT_ARCHIVED, + null, + ConversationListData.SORT_ORDER, + ) ?: return persistentListOf() + + return cursor.use { conversationCursor -> + buildList(capacity = conversationCursor.count) { + val item = ConversationListItemData() + + while (conversationCursor.moveToNext()) { + item.bind(conversationCursor) + item.toConversationListItem()?.let(::add) + } + } + }.toImmutableList() + } + + private fun queryBlockedParticipantDestinations(): ImmutableSet { + val cursor = contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + BLOCKED_PARTICIPANTS_PROJECTION, + "${ParticipantColumns.BLOCKED}=1", + null, + null, + ) ?: return persistentSetOf() + + return cursor.use { blockedParticipantsCursor -> + buildSet(capacity = blockedParticipantsCursor.count) { + while (blockedParticipantsCursor.moveToNext()) { + blockedParticipantsCursor + .getStringOrNull(ParticipantColumns.NORMALIZED_DESTINATION) + ?.takeIf(String::isNotBlank) + ?.let(::add) + } + } + }.toImmutableSet() + } + + private fun ConversationListItemData.toConversationListItem(): ConversationListItem? { + val resolvedConversationId = conversationId + ?.takeIf(String::isNotBlank) + ?: return null + + return ConversationListItem( + conversationId = resolvedConversationId, + title = name, + icon = icon, + subject = subject, + isArchived = isArchived, + participant = toParticipant(), + latestMessage = toLatestMessage(), + draft = toDraft(), + notification = ConversationListNotification( + isEnabled = notificationEnabled, + ), + ) + } + + private fun ConversationListItemData.toParticipant(): ConversationListParticipant { + return ConversationListParticipant( + contactId = participantContactId, + lookupKey = participantLookupKey, + otherNormalizedDestination = otherParticipantNormalizedDestination, + selfId = selfId, + count = participantCount, + isGroup = isGroup, + includeEmailAddress = includeEmailAddress, + isEnterprise = isEnterprise, + ) + } + + private fun ConversationListItemData.toLatestMessage(): ConversationListLatestMessage { + return ConversationListLatestMessage( + isRead = isRead, + timestamp = timestamp, + snippetText = snippetText, + previewUri = previewUri?.toString(), + previewContentType = previewContentType, + status = messageStatus, + rawTelephonyStatus = messageRawTelephonyStatus, + senderName = snippetSenderName, + ) + } + + private fun ConversationListItemData.toDraft(): ConversationListDraft { + return ConversationListDraft( + isVisible = showDraft, + snippetText = draftSnippetText, + previewUri = draftPreviewUri?.toString(), + previewContentType = draftPreviewContentType, + subject = draftSubject, + ) + } + + private companion object { + private val BLOCKED_PARTICIPANTS_PROJECTION = arrayOf( + ParticipantColumns._ID, + ParticipantColumns.NORMALIZED_DESTINATION, + ) + } +} diff --git a/src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt b/src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt new file mode 100644 index 000000000..4dfe7efca --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt @@ -0,0 +1,27 @@ +package com.android.messaging.data.conversationlist.store + +import com.android.messaging.datamodel.DataModel +import com.android.messaging.receiver.SmsReceiver +import javax.inject.Inject + +internal interface ConversationListStatusStore { + fun hasFirstSyncCompleted(): Boolean + fun setNewestConversationVisible(isVisible: Boolean) +} + +internal class ConversationListStatusStoreImpl @Inject constructor() : ConversationListStatusStore { + + override fun hasFirstSyncCompleted(): Boolean { + val dataModel = DataModel.get() + return dataModel.syncManager.hasFirstSyncCompleted + } + + override fun setNewestConversationVisible(isVisible: Boolean) { + val dataModel = DataModel.get() + dataModel.isConversationListScrolledToNewestConversation = isVisible + + if (isVisible) { + SmsReceiver.cancelSecondaryUserNotification() + } + } +} diff --git a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt new file mode 100644 index 000000000..f4e4ad162 --- /dev/null +++ b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt @@ -0,0 +1,28 @@ +package com.android.messaging.di.conversationlist + +import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.data.conversationlist.repository.ConversationListRepositoryImpl +import com.android.messaging.data.conversationlist.store.ConversationListStatusStore +import com.android.messaging.data.conversationlist.store.ConversationListStatusStoreImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ConversationListBindsModule { + + @Binds + @Reusable + abstract fun bindConversationListRepository( + impl: ConversationListRepositoryImpl, + ): ConversationListRepository + + @Binds + @Reusable + abstract fun bindConversationListStatusStore( + impl: ConversationListStatusStoreImpl, + ): ConversationListStatusStore +} From ecb9328777cf9e989b7f538e1ce2bc2be0f6a6a5 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 10 Jun 2026 16:35:46 +0200 Subject: [PATCH 03/39] Add conversation list redesign state and actions --- .../ConversationListBindsModule.kt | 32 ++ .../model/ConversationListActionTarget.kt | 6 + .../usecase/DeleteConversations.kt | 26 ++ .../usecase/SetConversationArchived.kt | 36 ++ .../usecase/SetConversationBlocked.kt | 53 +++ .../redesign/ConversationListViewModel.kt | 305 +++++++++++++++++ .../mapper/ConversationListUiStateMapper.kt | 322 ++++++++++++++++++ .../redesign/model/ConversationListAction.kt | 39 +++ .../redesign/model/ConversationListEffect.kt | 33 ++ .../model/ConversationListItemUiModel.kt | 87 +++++ .../model/ConversationListSelectionUiState.kt | 34 ++ .../redesign/model/ConversationListUiState.kt | 32 ++ 12 files changed, 1005 insertions(+) create mode 100644 src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt create mode 100644 src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt create mode 100644 src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt create mode 100644 src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt diff --git a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt index f4e4ad162..da74fcff0 100644 --- a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt +++ b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt @@ -4,6 +4,14 @@ import com.android.messaging.data.conversationlist.repository.ConversationListRe import com.android.messaging.data.conversationlist.repository.ConversationListRepositoryImpl import com.android.messaging.data.conversationlist.store.ConversationListStatusStore import com.android.messaging.data.conversationlist.store.ConversationListStatusStoreImpl +import com.android.messaging.domain.conversationlist.usecase.DeleteConversations +import com.android.messaging.domain.conversationlist.usecase.DeleteConversationsImpl +import com.android.messaging.domain.conversationlist.usecase.SetConversationArchived +import com.android.messaging.domain.conversationlist.usecase.SetConversationArchivedImpl +import com.android.messaging.domain.conversationlist.usecase.SetConversationBlocked +import com.android.messaging.domain.conversationlist.usecase.SetConversationBlockedImpl +import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapperImpl import dagger.Binds import dagger.Module import dagger.Reusable @@ -25,4 +33,28 @@ internal abstract class ConversationListBindsModule { abstract fun bindConversationListStatusStore( impl: ConversationListStatusStoreImpl, ): ConversationListStatusStore + + @Binds + @Reusable + abstract fun bindConversationListUiStateMapper( + impl: ConversationListUiStateMapperImpl, + ): ConversationListUiStateMapper + + @Binds + @Reusable + abstract fun bindDeleteConversations( + impl: DeleteConversationsImpl, + ): DeleteConversations + + @Binds + @Reusable + abstract fun bindSetConversationArchived( + impl: SetConversationArchivedImpl, + ): SetConversationArchived + + @Binds + @Reusable + abstract fun bindSetConversationBlocked( + impl: SetConversationBlockedImpl, + ): SetConversationBlocked } diff --git a/src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt b/src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt new file mode 100644 index 000000000..be1566b48 --- /dev/null +++ b/src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt @@ -0,0 +1,6 @@ +package com.android.messaging.domain.conversationlist.model + +internal data class ConversationListActionTarget( + val conversationId: String, + val cutoffTimestampMillis: Long, +) diff --git a/src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt b/src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt new file mode 100644 index 000000000..f6839292c --- /dev/null +++ b/src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt @@ -0,0 +1,26 @@ +package com.android.messaging.domain.conversationlist.usecase + +import com.android.messaging.datamodel.action.DeleteConversationAction +import com.android.messaging.domain.conversationlist.model.ConversationListActionTarget +import javax.inject.Inject + +internal interface DeleteConversations { + operator fun invoke(conversations: Collection) +} + +internal class DeleteConversationsImpl @Inject constructor() : DeleteConversations { + + override operator fun invoke(conversations: Collection) { + conversations + .asSequence() + .filter { conversation -> + conversation.conversationId.isNotBlank() + } + .forEach { conversation -> + DeleteConversationAction.deleteConversation( + conversation.conversationId, + conversation.cutoffTimestampMillis, + ) + } + } +} diff --git a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt new file mode 100644 index 000000000..38be56d2c --- /dev/null +++ b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt @@ -0,0 +1,36 @@ +package com.android.messaging.domain.conversationlist.usecase + +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction +import javax.inject.Inject + +internal interface SetConversationArchived { + operator fun invoke(conversationIds: Set, isArchived: Boolean) +} + +internal class SetConversationArchivedImpl @Inject constructor() : SetConversationArchived { + + override operator fun invoke( + conversationIds: Set, + isArchived: Boolean, + ) { + conversationIds + .asSequence() + .map { conversationId -> + conversationId.trim() + } + .filter { conversationId -> + conversationId.isNotEmpty() + } + .forEach { conversationId -> + when { + isArchived -> UpdateConversationArchiveStatusAction.archiveConversation( + conversationId, + ) + + else -> UpdateConversationArchiveStatusAction.unarchiveConversation( + conversationId, + ) + } + } + } +} diff --git a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt new file mode 100644 index 000000000..986043d8c --- /dev/null +++ b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt @@ -0,0 +1,53 @@ +package com.android.messaging.domain.conversationlist.usecase + +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction +import com.android.messaging.di.core.MainDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.coroutines.resume + +internal interface SetConversationBlocked { + suspend operator fun invoke( + destination: String, + conversationId: String, + isBlocked: Boolean, + ): Boolean +} + +internal class SetConversationBlockedImpl @Inject constructor( + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : SetConversationBlocked { + + override suspend operator fun invoke( + destination: String, + conversationId: String, + isBlocked: Boolean, + ): Boolean { + val resolvedDestination = destination.takeIf(String::isNotBlank) ?: return false + + return withContext(mainDispatcher) { + suspendCancellableCoroutine { continuation -> + val listener = UpdateDestinationBlockedAction + .UpdateDestinationBlockedActionListener { _, success, _, _ -> + if (continuation.isActive) { + continuation.resume(success) + } + } + + val actionMonitor = UpdateDestinationBlockedAction.updateDestinationBlocked( + resolvedDestination, + isBlocked, + conversationId.takeIf(String::isNotBlank), + listener, + ) + + continuation.invokeOnCancellation { + actionMonitor?.unregister() + } + } + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt new file mode 100644 index 000000000..ae49a8be7 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -0,0 +1,305 @@ +package com.android.messaging.ui.conversationlist.redesign + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.domain.conversationlist.model.ConversationListActionTarget +import com.android.messaging.domain.conversationlist.usecase.DeleteConversations +import com.android.messaging.domain.conversationlist.usecase.SetConversationArchived +import com.android.messaging.domain.conversationlist.usecase.SetConversationBlocked +import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversationUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State + +internal interface ConversationListScreenModel { + val effects: Flow + val uiState: StateFlow + + fun onAction(action: Action) +} + +@HiltViewModel +internal class ConversationListViewModel @Inject constructor( + private val repository: ConversationListRepository, + private val uiStateMapper: ConversationListUiStateMapper, + private val deleteConversations: DeleteConversations, + private val setConversationArchived: SetConversationArchived, + private val setConversationBlocked: SetConversationBlocked, +) : ViewModel(), + ConversationListScreenModel { + + private val selectedConversationIds = MutableStateFlow>( + persistentSetOf(), + ) + private val isScrollUpVisible = MutableStateFlow(false) + + private var isNewestConversationVisible = true + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + override val effects: Flow = _effects.asSharedFlow() + + override val uiState: StateFlow = combine( + repository.observeInboxSnapshot().onEach(::pruneSelection), + selectedConversationIds, + isScrollUpVisible, + ) { snapshot, selectedIds, isScrollUpVisible -> + uiStateMapper.map( + snapshot = snapshot, + selectedConversationIds = selectedIds, + isScrollUpVisible = isScrollUpVisible, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = State(), + ) + + override fun onAction(action: Action) { + when (action) { + is Action.DialogAction -> onDialogAction(action) + is Action.ListAction -> onListAction(action) + is Action.NavigationAction -> onNavigationAction(action) + is Action.SelectionAction -> onSelectionAction(action) + } + } + + private fun onDialogAction(action: Action.DialogAction) { + when (action) { + Action.BlockConfirmed -> onBlockConfirmed() + Action.DeleteConfirmed -> onDeleteConfirmed() + } + } + + private fun onListAction(action: Action.ListAction) { + when (action) { + is Action.ConversationClicked -> { + onConversationClick(action.conversationId) + } + + is Action.ConversationLongClicked -> { + onConversationLongClick(action.conversationId) + } + + is Action.NewestConversationVisibilityChanged -> { + onNewestConversationVisibilityChanged(action.isVisible) + } + } + } + + private fun onNavigationAction(action: Action.NavigationAction) { + when (action) { + Action.ArchivedConversationsClicked -> onArchivedConversationsClick() + Action.BlockedParticipantsClicked -> onBlockedParticipantsClick() + Action.ScrollUpClicked -> onScrollUpClick() + Action.SettingsClicked -> onSettingsClick() + Action.StartChatClicked -> onStartChatClick() + } + } + + private fun onSelectionAction(action: Action.SelectionAction) { + when (action) { + Action.AddContactClicked -> onAddContactClick() + Action.ArchiveClicked -> onArchiveClick() + Action.BlockClicked -> onBlockClick() + Action.SelectionCleared -> onSelectionCleared() + Action.UnarchiveClicked -> onUnarchiveClick() + } + } + + private fun onConversationClick(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + when { + uiState.value.isSelectionMode -> { + toggleSelection(resolvedConversationId) + } + + else -> { + _effects.tryEmit(Effect.OpenConversation(resolvedConversationId)) + } + } + } + + private fun onConversationLongClick(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + toggleSelection(resolvedConversationId) + } + + private fun onSelectionCleared() { + selectedConversationIds.value = persistentSetOf() + } + + private fun onStartChatClick() { + _effects.tryEmit(Effect.StartChat) + } + + private fun onArchivedConversationsClick() { + _effects.tryEmit(Effect.OpenArchivedConversations) + } + + private fun onBlockedParticipantsClick() { + _effects.tryEmit(Effect.OpenBlockedParticipants) + } + + private fun onSettingsClick() { + _effects.tryEmit(Effect.OpenSettings) + } + + private fun onScrollUpClick() { + _effects.tryEmit(Effect.ScrollToTop) + } + + private fun onNewestConversationVisibilityChanged(isVisible: Boolean) { + if (isNewestConversationVisible == isVisible) { + return + } + + isNewestConversationVisible = isVisible + isScrollUpVisible.value = !isVisible + repository.setNewestConversationVisible(isVisible) + } + + private fun onAddContactClick() { + val selectedConversation = singleSelectedConversation() ?: return + + _effects.tryEmit(Effect.ConfirmAddContact(selectedConversation)) + } + + private fun onBlockClick() { + val selectedConversation = singleSelectedConversation() ?: return + + _effects.tryEmit(Effect.ConfirmBlock(selectedConversation)) + } + + private fun onArchiveClick() { + updateSelectedArchiveStatus(isArchived = true) + } + + private fun onUnarchiveClick() { + updateSelectedArchiveStatus(isArchived = false) + } + + private fun onDeleteConfirmed() { + val selectedConversations = uiState.value.selection.selectedConversations + + if (selectedConversations.isEmpty()) { + return + } + + deleteConversations( + selectedConversations.map { conversation -> + ConversationListActionTarget( + conversationId = conversation.conversationId, + cutoffTimestampMillis = conversation.timestampMillis, + ) + }, + ) + + onSelectionCleared() + } + + private fun onBlockConfirmed() { + val selectedConversation = singleSelectedConversation() ?: return + val destination = selectedConversation.normalizedDestination ?: return + + viewModelScope.launch { + val success = setConversationBlocked( + destination = destination, + conversationId = selectedConversation.conversationId, + isBlocked = true, + ) + + _effects.emit( + Effect.ConversationBlocked( + destination = destination, + success = success, + ), + ) + + onSelectionCleared() + } + } + + private fun updateSelectedArchiveStatus(isArchived: Boolean) { + val selectedConversations = uiState.value.selection.selectedConversations + val conversationIds = selectedConversations + .map { conversation -> + conversation.conversationId + } + .toSet() + + if (conversationIds.isEmpty()) { + return + } + + setConversationArchived( + conversationIds = conversationIds, + isArchived = isArchived, + ) + + _effects.tryEmit( + Effect.ConversationsArchived( + count = conversationIds.size, + isArchived = isArchived, + ), + ) + + onSelectionCleared() + } + + private fun toggleSelection(conversationId: String) { + selectedConversationIds.update { currentSelectedIds -> + when { + conversationId in currentSelectedIds -> currentSelectedIds.remove(conversationId) + else -> currentSelectedIds.add(conversationId) + } + } + } + + private fun pruneSelection(snapshot: ConversationListSnapshot) { + val visibleConversationIds = snapshot.items + .asSequence() + .map { item -> + item.conversationId + } + .toSet() + + selectedConversationIds.update { currentSelectedIds -> + currentSelectedIds + .filter { conversationId -> + conversationId in visibleConversationIds + } + .toPersistentSet() + } + } + + private fun singleSelectedConversation(): SelectedConversationUiModel? { + return uiState.value.selection.selectedConversations.singleOrNull() + } + + private companion object { + private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt new file mode 100644 index 000000000..b9d39bf0b --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -0,0 +1,322 @@ +package com.android.messaging.ui.conversationlist.redesign.mapper + +import androidx.core.net.toUri +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListMessageStatus +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSelectionUiState +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSnippetUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState +import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversationUiModel +import com.android.messaging.ui.conversationlist.redesign.model.SelectionActionsUiState +import com.android.messaging.util.AvatarUriUtil +import com.android.messaging.util.ContentType +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +internal interface ConversationListUiStateMapper { + fun map( + snapshot: ConversationListSnapshot, + selectedConversationIds: ImmutableSet, + isScrollUpVisible: Boolean, + ): ConversationListUiState +} + +internal class ConversationListUiStateMapperImpl @Inject constructor() : + ConversationListUiStateMapper { + + override fun map( + snapshot: ConversationListSnapshot, + selectedConversationIds: ImmutableSet, + isScrollUpVisible: Boolean, + ): ConversationListUiState { + val items = snapshot.items + .map { item -> + val isSelected = item.conversationId in selectedConversationIds + + mapItem( + item = item, + isSelected = isSelected, + ) + } + .toImmutableList() + + val content = when { + items.isNotEmpty() -> { + ConversationListContentUiState.Items(items) + } + + !snapshot.hasFirstSyncCompleted -> { + ConversationListContentUiState.WaitingForSync + } + + else -> { + ConversationListContentUiState.Empty + } + } + + val selection = mapSelectionState( + items = snapshot.items, + selectedConversationIds = selectedConversationIds, + blockedDestinations = snapshot.blockedDestinations, + ) + + return ConversationListUiState( + content = content, + selection = selection, + isStartChatVisible = !selection.isActive, + isScrollUpVisible = isScrollUpVisible, + hasBlockedParticipants = snapshot.blockedDestinations.isNotEmpty(), + isSelectionMode = selection.isActive, + ) + } + + private fun mapItem( + item: ConversationListItem, + isSelected: Boolean, + ): ConversationListItemUiModel { + val preview = item.activePreview() + val isDraft = item.draft.isVisible + val isOutgoing = isDraft || !MessageData.getIsIncoming(item.latestMessage.status) + + return ConversationListItemUiModel( + conversationId = item.conversationId, + title = item.title, + avatar = ConversationListAvatarUiModel( + uri = resolveAvatarUri(item.icon), + contactId = item.participant.contactId, + lookupKey = item.participant.lookupKey, + normalizedDestination = item.participant.otherNormalizedDestination, + isGroup = item.participant.isGroup, + ), + snippet = ConversationListSnippetUiModel( + text = item.activeSnippetText(), + senderName = item.latestMessage.senderName, + preview = preview, + isDraft = isDraft, + ), + subject = item.activeSubject(), + timestampMillis = item.latestMessage.timestamp, + status = mapStatus(item), + isOutgoing = isOutgoing, + isUnread = !item.latestMessage.isRead && !isDraft, + isGroup = item.participant.isGroup, + isEnterprise = item.participant.isEnterprise, + isMuted = !item.notification.isEnabled, + isArchived = item.isArchived, + isSelected = isSelected, + ) + } + + private fun mapSelectionState( + items: List, + selectedConversationIds: ImmutableSet, + blockedDestinations: ImmutableSet, + ): ConversationListSelectionUiState { + val selectedConversations = items + .asSequence() + .filter { item -> + item.conversationId in selectedConversationIds + } + .map(::toSelectedConversation) + .toList() + .toImmutableList() + + return ConversationListSelectionUiState( + selectedConversations = selectedConversations, + actions = mapSelectionActions( + selectedConversations = selectedConversations, + blockedDestinations = blockedDestinations, + ), + isActive = selectedConversations.isNotEmpty(), + count = selectedConversations.size, + ) + } + + private fun toSelectedConversation( + item: ConversationListItem, + ): SelectedConversationUiModel { + return SelectedConversationUiModel( + conversationId = item.conversationId, + timestampMillis = item.latestMessage.timestamp, + icon = item.icon, + normalizedDestination = item.participant.otherNormalizedDestination, + participantLookupKey = item.participant.lookupKey, + isGroup = item.participant.isGroup, + isArchived = item.isArchived, + notificationEnabled = item.notification.isEnabled, + ) + } + + private fun mapSelectionActions( + selectedConversations: List, + blockedDestinations: ImmutableSet, + ): SelectionActionsUiState { + val singleSelection = selectedConversations.singleOrNull() + + return SelectionActionsUiState( + canArchive = selectedConversations.any { !it.isArchived }, + canUnarchive = selectedConversations.any { it.isArchived }, + canDelete = selectedConversations.isNotEmpty(), + canAddContact = singleSelection?.canAddContact() == true, + canBlock = singleSelection?.canBlock(blockedDestinations) == true, + ) + } + + private fun SelectedConversationUiModel.canAddContact(): Boolean { + return !isGroup && participantLookupKey.isNullOrBlank() + } + + private fun SelectedConversationUiModel.canBlock( + blockedDestinations: ImmutableSet, + ): Boolean { + val destination = normalizedDestination?.takeIf(String::isNotBlank) ?: return false + + return destination !in blockedDestinations + } + + private fun ConversationListItem.activeSnippetText(): String? { + return when { + draft.isVisible -> draft.snippetText + else -> latestMessage.snippetText + } + } + + private fun ConversationListItem.activeSubject(): String? { + return when { + draft.isVisible -> draft.subject + else -> subject + }?.takeIf(String::isNotBlank) + } + + private fun ConversationListItem.activePreview(): ConversationListPreviewUiModel? { + val previewUri = when { + draft.isVisible -> draft.previewUri + else -> latestMessage.previewUri + }?.takeIf(String::isNotBlank) + + val previewContentType = when { + draft.isVisible -> draft.previewContentType + else -> latestMessage.previewContentType + }?.takeIf(String::isNotBlank) + + return when { + previewUri != null && previewContentType != null -> { + mapPreview( + contentUri = previewUri, + contentType = previewContentType, + ) + } + + else -> null + } + } + + private fun mapPreview( + contentUri: String, + contentType: String, + ): ConversationListPreviewUiModel { + return when { + ContentType.isAudioType(contentType) -> { + ConversationListPreviewUiModel.Audio( + contentUri = contentUri, + contentType = contentType, + ) + } + + ContentType.isImageType(contentType) -> { + ConversationListPreviewUiModel.Image( + contentUri = contentUri, + contentType = contentType, + ) + } + + ContentType.isVideoType(contentType) -> { + ConversationListPreviewUiModel.Video( + contentUri = contentUri, + contentType = contentType, + ) + } + + ContentType.isVCardType(contentType) -> { + ConversationListPreviewUiModel.VCard( + contentUri = contentUri, + contentType = contentType, + ) + } + + else -> { + ConversationListPreviewUiModel.File( + contentUri = contentUri, + contentType = contentType, + ) + } + } + } + + private fun mapStatus( + item: ConversationListItem, + ): ConversationListMessageStatus { + return when { + item.draft.isVisible -> { + ConversationListMessageStatus.Draft + } + + item.latestMessage.status == MessageData.BUGLE_STATUS_OUTGOING_DRAFT -> { + ConversationListMessageStatus.Draft + } + + item.latestMessage.status == MessageData.BUGLE_STATUS_UNKNOWN -> { + ConversationListMessageStatus.Unknown + } + + item.isSendRequested() -> { + ConversationListMessageStatus.Sending + } + + item.isFailedStatus() -> { + ConversationListMessageStatus.Failed(item.latestMessage.rawTelephonyStatus) + } + + else -> ConversationListMessageStatus.Normal + } + } + + private fun ConversationListItem.isFailedStatus(): Boolean { + return when (latestMessage.status) { + MessageData.BUGLE_STATUS_OUTGOING_FAILED -> true + MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER -> true + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> true + MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE -> true + else -> false + } + } + + private fun ConversationListItem.isSendRequested(): Boolean { + return when (latestMessage.status) { + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND -> true + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY -> true + MessageData.BUGLE_STATUS_OUTGOING_SENDING -> true + MessageData.BUGLE_STATUS_OUTGOING_RESENDING -> true + else -> false + } + } + + private fun resolveAvatarUri( + icon: String?, + ): String? { + val iconUriString = icon?.takeIf(String::isNotBlank) ?: return null + val iconUri = iconUriString.toUri() + + return when { + AvatarUriUtil.isAvatarUri(iconUri) -> AvatarUriUtil.getPrimaryUri(iconUri)?.toString() + else -> iconUriString + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt new file mode 100644 index 000000000..dd6a596f6 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt @@ -0,0 +1,39 @@ +package com.android.messaging.ui.conversationlist.redesign.model + +internal sealed interface ConversationListAction { + + sealed interface DialogAction : ConversationListAction + + sealed interface ListAction : ConversationListAction + + sealed interface NavigationAction : ConversationListAction + + sealed interface SelectionAction : ConversationListAction + + data class ConversationClicked( + val conversationId: String, + ) : ListAction + + data class ConversationLongClicked( + val conversationId: String, + ) : ListAction + + data class NewestConversationVisibilityChanged( + val isVisible: Boolean, + ) : ListAction + + data object AddContactClicked : SelectionAction + data object ArchiveClicked : SelectionAction + data object BlockClicked : SelectionAction + data object SelectionCleared : SelectionAction + data object UnarchiveClicked : SelectionAction + + data object ArchivedConversationsClicked : NavigationAction + data object BlockedParticipantsClicked : NavigationAction + data object ScrollUpClicked : NavigationAction + data object SettingsClicked : NavigationAction + data object StartChatClicked : NavigationAction + + data object BlockConfirmed : DialogAction + data object DeleteConfirmed : DialogAction +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt new file mode 100644 index 000000000..07210dd1e --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt @@ -0,0 +1,33 @@ +package com.android.messaging.ui.conversationlist.redesign.model + +internal sealed interface ConversationListEffect { + + data object StartChat : ConversationListEffect + data object OpenArchivedConversations : ConversationListEffect + data object OpenBlockedParticipants : ConversationListEffect + data object OpenSettings : ConversationListEffect + + data class ConversationsArchived( + val count: Int, + val isArchived: Boolean, + ) : ConversationListEffect + + data class OpenConversation( + val conversationId: String, + ) : ConversationListEffect + + data class ConfirmAddContact( + val selectedConversation: SelectedConversationUiModel, + ) : ConversationListEffect + + data class ConfirmBlock( + val selectedConversation: SelectedConversationUiModel, + ) : ConversationListEffect + + data class ConversationBlocked( + val destination: String, + val success: Boolean, + ) : ConversationListEffect + + data object ScrollToTop : ConversationListEffect +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt new file mode 100644 index 000000000..fa3e54b0e --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt @@ -0,0 +1,87 @@ +package com.android.messaging.ui.conversationlist.redesign.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Immutable +internal data class ConversationListItemUiModel( + val conversationId: String, + val title: String?, + val avatar: ConversationListAvatarUiModel, + val snippet: ConversationListSnippetUiModel, + val subject: String?, + val timestampMillis: Long, + val status: ConversationListMessageStatus, + val isOutgoing: Boolean, + val isUnread: Boolean, + val isGroup: Boolean, + val isEnterprise: Boolean, + val isMuted: Boolean, + val isArchived: Boolean, + val isSelected: Boolean, +) + +@Immutable +internal data class ConversationListAvatarUiModel( + val uri: String?, + val contactId: Long, + val lookupKey: String?, + val normalizedDestination: String?, + val isGroup: Boolean, +) + +@Immutable +internal data class ConversationListSnippetUiModel( + val text: String?, + val senderName: String?, + val preview: ConversationListPreviewUiModel?, + val isDraft: Boolean, +) + +@Immutable +internal sealed interface ConversationListPreviewUiModel { + val contentUri: String + val contentType: String + + @Immutable + data class Audio( + override val contentUri: String, + override val contentType: String, + ) : ConversationListPreviewUiModel + + @Immutable + data class File( + override val contentUri: String, + override val contentType: String, + ) : ConversationListPreviewUiModel + + @Immutable + data class Image( + override val contentUri: String, + override val contentType: String, + ) : ConversationListPreviewUiModel + + @Immutable + data class VCard( + override val contentUri: String, + override val contentType: String, + ) : ConversationListPreviewUiModel + + @Immutable + data class Video( + override val contentUri: String, + override val contentType: String, + ) : ConversationListPreviewUiModel +} + +@Stable +internal sealed interface ConversationListMessageStatus { + data object Unknown : ConversationListMessageStatus + data object Normal : ConversationListMessageStatus + data object Sending : ConversationListMessageStatus + data object Draft : ConversationListMessageStatus + + data class Failed( + val rawTelephonyStatus: Int, + ) : ConversationListMessageStatus +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt new file mode 100644 index 000000000..3d7457464 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt @@ -0,0 +1,34 @@ +package com.android.messaging.ui.conversationlist.redesign.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationListSelectionUiState( + val selectedConversations: ImmutableList = persistentListOf(), + val actions: SelectionActionsUiState = SelectionActionsUiState(), + val isActive: Boolean = false, + val count: Int = 0, +) + +@Immutable +internal data class SelectedConversationUiModel( + val conversationId: String, + val timestampMillis: Long, + val icon: String?, + val normalizedDestination: String?, + val participantLookupKey: String?, + val isGroup: Boolean, + val isArchived: Boolean, + val notificationEnabled: Boolean, +) + +@Immutable +internal data class SelectionActionsUiState( + val canArchive: Boolean = false, + val canUnarchive: Boolean = false, + val canDelete: Boolean = false, + val canAddContact: Boolean = false, + val canBlock: Boolean = false, +) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt new file mode 100644 index 000000000..982f76405 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt @@ -0,0 +1,32 @@ +package com.android.messaging.ui.conversationlist.redesign.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ConversationListUiState( + val content: ConversationListContentUiState = ConversationListContentUiState.Loading, + val selection: ConversationListSelectionUiState = ConversationListSelectionUiState(), + val isStartChatVisible: Boolean = true, + val isScrollUpVisible: Boolean = false, + val hasBlockedParticipants: Boolean = false, + val isSelectionMode: Boolean = false, +) + +@Immutable +internal sealed interface ConversationListContentUiState { + + @Immutable + data object Loading : ConversationListContentUiState + + @Immutable + data object WaitingForSync : ConversationListContentUiState + + @Immutable + data object Empty : ConversationListContentUiState + + @Immutable + data class Items( + val items: ImmutableList, + ) : ConversationListContentUiState +} From 9689aac511c8892d6981afc3a805f68ae371de97 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 10 Jun 2026 18:51:50 +0200 Subject: [PATCH 04/39] Map conversation list message status in data layer --- .../model/ConversationListItem.kt | 4 +- .../model/ConversationListMessageStatus.kt | 12 ++++ .../repository/ConversationListRepository.kt | 57 ++++++++++++++++--- .../usecase/SetConversationBlocked.kt | 4 +- .../redesign/ConversationListViewModel.kt | 6 +- .../mapper/ConversationListUiStateMapper.kt | 50 ++-------------- .../model/ConversationListItemUiModel.kt | 14 +---- 7 files changed, 73 insertions(+), 74 deletions(-) create mode 100644 src/com/android/messaging/data/conversationlist/model/ConversationListMessageStatus.kt diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt index 11c7f063e..4ed71d632 100644 --- a/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt +++ b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt @@ -29,8 +29,8 @@ internal data class ConversationListLatestMessage( val snippetText: String?, val previewUri: String?, val previewContentType: String?, - val status: Int, - val rawTelephonyStatus: Int, + val status: ConversationListMessageStatus, + val isIncoming: Boolean, val senderName: String?, ) diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationListMessageStatus.kt b/src/com/android/messaging/data/conversationlist/model/ConversationListMessageStatus.kt new file mode 100644 index 000000000..0f41c584e --- /dev/null +++ b/src/com/android/messaging/data/conversationlist/model/ConversationListMessageStatus.kt @@ -0,0 +1,12 @@ +package com.android.messaging.data.conversationlist.model + +internal sealed interface ConversationListMessageStatus { + data object Unknown : ConversationListMessageStatus + data object Normal : ConversationListMessageStatus + data object Sending : ConversationListMessageStatus + data object Draft : ConversationListMessageStatus + + data class Failed( + val rawTelephonyStatus: Int, + ) : ConversationListMessageStatus +} diff --git a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt index 14a5f20f4..dbabee704 100644 --- a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt +++ b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt @@ -6,6 +6,7 @@ import android.net.Uri import com.android.messaging.data.conversationlist.model.ConversationListDraft import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListLatestMessage +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus import com.android.messaging.data.conversationlist.model.ConversationListNotification import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot @@ -14,9 +15,9 @@ import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.ConversationListData import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.datamodel.data.MessageData import com.android.messaging.di.core.MessagingDbDispatcher import com.android.messaging.util.db.ext.getStringOrNull -import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -28,8 +29,10 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import javax.inject.Inject internal interface ConversationListRepository { fun observeInboxSnapshot(): Flow @@ -44,13 +47,13 @@ internal class ConversationListRepositoryImpl @Inject constructor( ) : ConversationListRepository { override fun observeInboxSnapshot(): Flow { - val itemsFlow = observeUri( - uri = MessagingContentProvider.CONVERSATIONS_URI, - ).map { queryInboxConversations() } + val itemsFlow = observeUri(MessagingContentProvider.CONVERSATIONS_URI) + .conflate() + .map { queryInboxConversations() } - val blockedDestinationsFlow = observeUri( - uri = MessagingContentProvider.PARTICIPANTS_URI, - ).map { queryBlockedParticipantDestinations() } + val blockedDestinationsFlow = observeUri(MessagingContentProvider.PARTICIPANTS_URI) + .conflate() + .map { queryBlockedParticipantDestinations() } return combine( itemsFlow, @@ -167,12 +170,48 @@ internal class ConversationListRepositoryImpl @Inject constructor( snippetText = snippetText, previewUri = previewUri?.toString(), previewContentType = previewContentType, - status = messageStatus, - rawTelephonyStatus = messageRawTelephonyStatus, + status = mapMessageStatus( + status = messageStatus, + rawTelephonyStatus = messageRawTelephonyStatus, + ), + isIncoming = MessageData.getIsIncoming(messageStatus), senderName = snippetSenderName, ) } + private fun mapMessageStatus( + status: Int, + rawTelephonyStatus: Int, + ): ConversationListMessageStatus { + return when (status) { + MessageData.BUGLE_STATUS_OUTGOING_DRAFT -> { + ConversationListMessageStatus.Draft + } + + MessageData.BUGLE_STATUS_UNKNOWN -> { + ConversationListMessageStatus.Unknown + } + + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND, + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY, + MessageData.BUGLE_STATUS_OUTGOING_SENDING, + MessageData.BUGLE_STATUS_OUTGOING_RESENDING, + -> { + ConversationListMessageStatus.Sending + } + + MessageData.BUGLE_STATUS_OUTGOING_FAILED, + MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER, + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED, + MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE, + -> { + ConversationListMessageStatus.Failed(rawTelephonyStatus) + } + + else -> ConversationListMessageStatus.Normal + } + } + private fun ConversationListItemData.toDraft(): ConversationListDraft { return ConversationListDraft( isVisible = showDraft, diff --git a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt index 986043d8c..571d54fb1 100644 --- a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt +++ b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt @@ -2,11 +2,11 @@ package com.android.messaging.domain.conversationlist.usecase import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction import com.android.messaging.di.core.MainDispatcher +import javax.inject.Inject +import kotlin.coroutines.resume import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import javax.inject.Inject -import kotlin.coroutines.resume internal interface SetConversationBlocked { suspend operator fun invoke( diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index ae49a8be7..594e57804 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -9,6 +9,9 @@ import com.android.messaging.domain.conversationlist.usecase.DeleteConversations import com.android.messaging.domain.conversationlist.usecase.SetConversationArchived import com.android.messaging.domain.conversationlist.usecase.SetConversationBlocked import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversationUiModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -26,9 +29,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State internal interface ConversationListScreenModel { val effects: Flow diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index b9d39bf0b..8ed46c49d 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -2,12 +2,11 @@ package com.android.messaging.ui.conversationlist.redesign.mapper import androidx.core.net.toUri import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus import com.android.messaging.data.conversationlist.model.ConversationListSnapshot -import com.android.messaging.datamodel.data.MessageData import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListMessageStatus import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSelectionUiState import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSnippetUiModel @@ -16,9 +15,9 @@ import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversa import com.android.messaging.ui.conversationlist.redesign.model.SelectionActionsUiState import com.android.messaging.util.AvatarUriUtil import com.android.messaging.util.ContentType +import javax.inject.Inject import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableList -import javax.inject.Inject internal interface ConversationListUiStateMapper { fun map( @@ -83,7 +82,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : ): ConversationListItemUiModel { val preview = item.activePreview() val isDraft = item.draft.isVisible - val isOutgoing = isDraft || !MessageData.getIsIncoming(item.latestMessage.status) + val isOutgoing = isDraft || !item.latestMessage.isIncoming return ConversationListItemUiModel( conversationId = item.conversationId, @@ -264,47 +263,8 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : item: ConversationListItem, ): ConversationListMessageStatus { return when { - item.draft.isVisible -> { - ConversationListMessageStatus.Draft - } - - item.latestMessage.status == MessageData.BUGLE_STATUS_OUTGOING_DRAFT -> { - ConversationListMessageStatus.Draft - } - - item.latestMessage.status == MessageData.BUGLE_STATUS_UNKNOWN -> { - ConversationListMessageStatus.Unknown - } - - item.isSendRequested() -> { - ConversationListMessageStatus.Sending - } - - item.isFailedStatus() -> { - ConversationListMessageStatus.Failed(item.latestMessage.rawTelephonyStatus) - } - - else -> ConversationListMessageStatus.Normal - } - } - - private fun ConversationListItem.isFailedStatus(): Boolean { - return when (latestMessage.status) { - MessageData.BUGLE_STATUS_OUTGOING_FAILED -> true - MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER -> true - MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> true - MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE -> true - else -> false - } - } - - private fun ConversationListItem.isSendRequested(): Boolean { - return when (latestMessage.status) { - MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND -> true - MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY -> true - MessageData.BUGLE_STATUS_OUTGOING_SENDING -> true - MessageData.BUGLE_STATUS_OUTGOING_RESENDING -> true - else -> false + item.draft.isVisible -> ConversationListMessageStatus.Draft + else -> item.latestMessage.status } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt index fa3e54b0e..56b0e71c1 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt @@ -1,7 +1,7 @@ package com.android.messaging.ui.conversationlist.redesign.model import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus @Immutable internal data class ConversationListItemUiModel( @@ -73,15 +73,3 @@ internal sealed interface ConversationListPreviewUiModel { override val contentType: String, ) : ConversationListPreviewUiModel } - -@Stable -internal sealed interface ConversationListMessageStatus { - data object Unknown : ConversationListMessageStatus - data object Normal : ConversationListMessageStatus - data object Sending : ConversationListMessageStatus - data object Draft : ConversationListMessageStatus - - data class Failed( - val rawTelephonyStatus: Int, - ) : ConversationListMessageStatus -} From f15711d6cf9e148242d7affa246be5233b4287fe Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 10 Jun 2026 19:34:50 +0200 Subject: [PATCH 05/39] Unify conversation archive, delete and block use cases --- .../BlockedParticipantsRepository.kt | 42 +++++++++++++++ .../repository/ConversationListRepository.kt | 6 +-- .../ConversationListBindsModule.kt | 24 --------- .../ConversationSettingsBindsModule.kt | 8 --- .../usecase/SetDestinationBlocked.kt | 35 ------------ .../model/ConversationListActionTarget.kt | 6 --- .../usecase/DeleteConversations.kt | 26 --------- .../usecase/SetConversationArchived.kt | 36 ------------- .../usecase/SetConversationBlocked.kt | 53 ------------------- .../usecase/SetConversationArchived.kt | 26 --------- .../redesign/ConversationListViewModel.kt | 37 ++++++------- .../delegate/ConversationSettingsDelegate.kt | 32 +++++------ 12 files changed, 79 insertions(+), 252 deletions(-) delete mode 100644 src/com/android/messaging/domain/blockedparticipants/usecase/SetDestinationBlocked.kt delete mode 100644 src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt delete mode 100644 src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt delete mode 100644 src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt delete mode 100644 src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt delete mode 100644 src/com/android/messaging/domain/conversationsettings/usecase/SetConversationArchived.kt diff --git a/src/com/android/messaging/data/blockedparticipants/repository/BlockedParticipantsRepository.kt b/src/com/android/messaging/data/blockedparticipants/repository/BlockedParticipantsRepository.kt index 70ac84f0a..4d4200f6f 100644 --- a/src/com/android/messaging/data/blockedparticipants/repository/BlockedParticipantsRepository.kt +++ b/src/com/android/messaging/data/blockedparticipants/repository/BlockedParticipantsRepository.kt @@ -10,9 +10,13 @@ import com.android.messaging.datamodel.DatabaseHelper import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction.updateDestinationBlocked import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.MainDispatcher import com.android.messaging.di.core.MessagingDbDispatcher import javax.inject.Inject +import kotlin.coroutines.resume import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -22,14 +26,23 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext internal interface BlockedParticipantsRepository { fun observeBlockedParticipants(): Flow> + + suspend fun setDestinationBlocked( + destination: String, + conversationId: String?, + isBlocked: Boolean, + ): Boolean } internal class BlockedParticipantsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, @param:MessagingDbDispatcher private val messagingDbDispatcher: CoroutineDispatcher, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, ) : BlockedParticipantsRepository { private val dataModel = DataModel.get() @@ -45,6 +58,35 @@ internal class BlockedParticipantsRepositoryImpl @Inject constructor( .flowOn(messagingDbDispatcher) } + override suspend fun setDestinationBlocked( + destination: String, + conversationId: String?, + isBlocked: Boolean, + ): Boolean { + val resolvedDestination = destination.takeIf(String::isNotBlank) ?: return false + + return withContext(mainDispatcher) { + suspendCancellableCoroutine { continuation -> + val listener = UpdateDestinationBlockedActionListener { _, success, _, _ -> + if (continuation.isActive) { + continuation.resume(success) + } + } + + val actionMonitor = updateDestinationBlocked( + resolvedDestination, + isBlocked, + conversationId?.takeIf(String::isNotBlank), + listener, + ) + + continuation.invokeOnCancellation { + actionMonitor?.unregister() + } + } + } + } + // Returns only blocked participants that have an existing 1 on 1 conversation. // Product behavior: after the user deletes a blocked chat the row should disappear // from this screen. The participant stays blocked - if a new chat is started later, diff --git a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt index dbabee704..9aad19cd9 100644 --- a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt +++ b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt @@ -18,6 +18,7 @@ import com.android.messaging.datamodel.data.ConversationListItemData import com.android.messaging.datamodel.data.MessageData import com.android.messaging.di.core.MessagingDbDispatcher import com.android.messaging.util.db.ext.getStringOrNull +import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -32,7 +33,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import javax.inject.Inject internal interface ConversationListRepository { fun observeInboxSnapshot(): Flow @@ -196,7 +196,7 @@ internal class ConversationListRepositoryImpl @Inject constructor( MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY, MessageData.BUGLE_STATUS_OUTGOING_SENDING, MessageData.BUGLE_STATUS_OUTGOING_RESENDING, - -> { + -> { ConversationListMessageStatus.Sending } @@ -204,7 +204,7 @@ internal class ConversationListRepositoryImpl @Inject constructor( MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER, MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED, MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE, - -> { + -> { ConversationListMessageStatus.Failed(rawTelephonyStatus) } diff --git a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt index da74fcff0..910d3e7cf 100644 --- a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt +++ b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt @@ -4,12 +4,6 @@ import com.android.messaging.data.conversationlist.repository.ConversationListRe import com.android.messaging.data.conversationlist.repository.ConversationListRepositoryImpl import com.android.messaging.data.conversationlist.store.ConversationListStatusStore import com.android.messaging.data.conversationlist.store.ConversationListStatusStoreImpl -import com.android.messaging.domain.conversationlist.usecase.DeleteConversations -import com.android.messaging.domain.conversationlist.usecase.DeleteConversationsImpl -import com.android.messaging.domain.conversationlist.usecase.SetConversationArchived -import com.android.messaging.domain.conversationlist.usecase.SetConversationArchivedImpl -import com.android.messaging.domain.conversationlist.usecase.SetConversationBlocked -import com.android.messaging.domain.conversationlist.usecase.SetConversationBlockedImpl import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapperImpl import dagger.Binds @@ -39,22 +33,4 @@ internal abstract class ConversationListBindsModule { abstract fun bindConversationListUiStateMapper( impl: ConversationListUiStateMapperImpl, ): ConversationListUiStateMapper - - @Binds - @Reusable - abstract fun bindDeleteConversations( - impl: DeleteConversationsImpl, - ): DeleteConversations - - @Binds - @Reusable - abstract fun bindSetConversationArchived( - impl: SetConversationArchivedImpl, - ): SetConversationArchived - - @Binds - @Reusable - abstract fun bindSetConversationBlocked( - impl: SetConversationBlockedImpl, - ): SetConversationBlocked } diff --git a/src/com/android/messaging/di/conversationsettings/ConversationSettingsBindsModule.kt b/src/com/android/messaging/di/conversationsettings/ConversationSettingsBindsModule.kt index 52dffdbcf..d517f51ac 100644 --- a/src/com/android/messaging/di/conversationsettings/ConversationSettingsBindsModule.kt +++ b/src/com/android/messaging/di/conversationsettings/ConversationSettingsBindsModule.kt @@ -4,8 +4,6 @@ import com.android.messaging.data.conversationsettings.repository.ConversationNo import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepositoryImpl import com.android.messaging.data.conversationsettings.repository.ConversationSettingsRepository import com.android.messaging.data.conversationsettings.repository.ConversationSettingsRepositoryImpl -import com.android.messaging.domain.conversationsettings.usecase.SetConversationArchived -import com.android.messaging.domain.conversationsettings.usecase.SetConversationArchivedImpl import com.android.messaging.domain.conversationsettings.usecase.SetConversationSelfParticipantId import com.android.messaging.domain.conversationsettings.usecase.SetConversationSelfParticipantIdImpl import com.android.messaging.ui.conversationsettings.screen.mapper.ConversationSettingsUiStateMapper @@ -38,12 +36,6 @@ internal abstract class ConversationSettingsBindsModule { impl: ConversationNotificationRepositoryImpl, ): ConversationNotificationRepository - @Binds - @Reusable - abstract fun bindSetConversationArchived( - impl: SetConversationArchivedImpl, - ): SetConversationArchived - @Binds @Reusable abstract fun bindSetConversationSelfParticipantId( diff --git a/src/com/android/messaging/domain/blockedparticipants/usecase/SetDestinationBlocked.kt b/src/com/android/messaging/domain/blockedparticipants/usecase/SetDestinationBlocked.kt deleted file mode 100644 index 556c1dcaf..000000000 --- a/src/com/android/messaging/domain/blockedparticipants/usecase/SetDestinationBlocked.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.android.messaging.domain.blockedparticipants.usecase - -import android.content.Context -import com.android.messaging.datamodel.action.BugleActionToasts -import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject - -internal interface SetDestinationBlocked { - operator fun invoke( - normalizedDestination: String, - blocked: Boolean, - conversationId: String? = null, - ) -} - -internal class SetDestinationBlockedImpl @Inject constructor( - @param:ApplicationContext private val context: Context, -) : SetDestinationBlocked { - - override fun invoke( - normalizedDestination: String, - blocked: Boolean, - conversationId: String?, - ) { - if (normalizedDestination.isEmpty()) return - - UpdateDestinationBlockedAction.updateDestinationBlocked( - normalizedDestination, - blocked, - conversationId, - BugleActionToasts.makeUpdateDestinationBlockedActionListener(context), - ) - } -} diff --git a/src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt b/src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt deleted file mode 100644 index be1566b48..000000000 --- a/src/com/android/messaging/domain/conversationlist/model/ConversationListActionTarget.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.android.messaging.domain.conversationlist.model - -internal data class ConversationListActionTarget( - val conversationId: String, - val cutoffTimestampMillis: Long, -) diff --git a/src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt b/src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt deleted file mode 100644 index f6839292c..000000000 --- a/src/com/android/messaging/domain/conversationlist/usecase/DeleteConversations.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.android.messaging.domain.conversationlist.usecase - -import com.android.messaging.datamodel.action.DeleteConversationAction -import com.android.messaging.domain.conversationlist.model.ConversationListActionTarget -import javax.inject.Inject - -internal interface DeleteConversations { - operator fun invoke(conversations: Collection) -} - -internal class DeleteConversationsImpl @Inject constructor() : DeleteConversations { - - override operator fun invoke(conversations: Collection) { - conversations - .asSequence() - .filter { conversation -> - conversation.conversationId.isNotBlank() - } - .forEach { conversation -> - DeleteConversationAction.deleteConversation( - conversation.conversationId, - conversation.cutoffTimestampMillis, - ) - } - } -} diff --git a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt deleted file mode 100644 index 38be56d2c..000000000 --- a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationArchived.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.android.messaging.domain.conversationlist.usecase - -import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction -import javax.inject.Inject - -internal interface SetConversationArchived { - operator fun invoke(conversationIds: Set, isArchived: Boolean) -} - -internal class SetConversationArchivedImpl @Inject constructor() : SetConversationArchived { - - override operator fun invoke( - conversationIds: Set, - isArchived: Boolean, - ) { - conversationIds - .asSequence() - .map { conversationId -> - conversationId.trim() - } - .filter { conversationId -> - conversationId.isNotEmpty() - } - .forEach { conversationId -> - when { - isArchived -> UpdateConversationArchiveStatusAction.archiveConversation( - conversationId, - ) - - else -> UpdateConversationArchiveStatusAction.unarchiveConversation( - conversationId, - ) - } - } - } -} diff --git a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt b/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt deleted file mode 100644 index 571d54fb1..000000000 --- a/src/com/android/messaging/domain/conversationlist/usecase/SetConversationBlocked.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.android.messaging.domain.conversationlist.usecase - -import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction -import com.android.messaging.di.core.MainDispatcher -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext - -internal interface SetConversationBlocked { - suspend operator fun invoke( - destination: String, - conversationId: String, - isBlocked: Boolean, - ): Boolean -} - -internal class SetConversationBlockedImpl @Inject constructor( - @param:MainDispatcher - private val mainDispatcher: CoroutineDispatcher, -) : SetConversationBlocked { - - override suspend operator fun invoke( - destination: String, - conversationId: String, - isBlocked: Boolean, - ): Boolean { - val resolvedDestination = destination.takeIf(String::isNotBlank) ?: return false - - return withContext(mainDispatcher) { - suspendCancellableCoroutine { continuation -> - val listener = UpdateDestinationBlockedAction - .UpdateDestinationBlockedActionListener { _, success, _, _ -> - if (continuation.isActive) { - continuation.resume(success) - } - } - - val actionMonitor = UpdateDestinationBlockedAction.updateDestinationBlocked( - resolvedDestination, - isBlocked, - conversationId.takeIf(String::isNotBlank), - listener, - ) - - continuation.invokeOnCancellation { - actionMonitor?.unregister() - } - } - } - } -} diff --git a/src/com/android/messaging/domain/conversationsettings/usecase/SetConversationArchived.kt b/src/com/android/messaging/domain/conversationsettings/usecase/SetConversationArchived.kt deleted file mode 100644 index c8e3a3c06..000000000 --- a/src/com/android/messaging/domain/conversationsettings/usecase/SetConversationArchived.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.android.messaging.domain.conversationsettings.usecase - -import com.android.messaging.data.conversation.repository.ConversationsRepository -import javax.inject.Inject - -internal fun interface SetConversationArchived { - operator fun invoke(conversationId: String, archived: Boolean) -} - -internal class SetConversationArchivedImpl @Inject constructor( - private val conversationsRepository: ConversationsRepository, -) : SetConversationArchived { - - override fun invoke( - conversationId: String, - archived: Boolean, - ) { - if (conversationId.isEmpty()) return - - if (archived) { - conversationsRepository.archiveConversation(conversationId) - } else { - conversationsRepository.unarchiveConversation(conversationId) - } - } -} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index 594e57804..deebd30c2 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -2,12 +2,10 @@ package com.android.messaging.ui.conversationlist.redesign import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository -import com.android.messaging.domain.conversationlist.model.ConversationListActionTarget -import com.android.messaging.domain.conversationlist.usecase.DeleteConversations -import com.android.messaging.domain.conversationlist.usecase.SetConversationArchived -import com.android.messaging.domain.conversationlist.usecase.SetConversationBlocked import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect @@ -41,9 +39,8 @@ internal interface ConversationListScreenModel { internal class ConversationListViewModel @Inject constructor( private val repository: ConversationListRepository, private val uiStateMapper: ConversationListUiStateMapper, - private val deleteConversations: DeleteConversations, - private val setConversationArchived: SetConversationArchived, - private val setConversationBlocked: SetConversationBlocked, + private val conversationsRepository: ConversationsRepository, + private val blockedParticipantsRepository: BlockedParticipantsRepository, ) : ViewModel(), ConversationListScreenModel { @@ -208,14 +205,12 @@ internal class ConversationListViewModel @Inject constructor( return } - deleteConversations( - selectedConversations.map { conversation -> - ConversationListActionTarget( - conversationId = conversation.conversationId, - cutoffTimestampMillis = conversation.timestampMillis, - ) - }, - ) + selectedConversations.forEach { conversation -> + conversationsRepository.deleteConversation( + conversationId = conversation.conversationId, + cutoffTimestamp = conversation.timestampMillis, + ) + } onSelectionCleared() } @@ -225,7 +220,7 @@ internal class ConversationListViewModel @Inject constructor( val destination = selectedConversation.normalizedDestination ?: return viewModelScope.launch { - val success = setConversationBlocked( + val success = blockedParticipantsRepository.setDestinationBlocked( destination = destination, conversationId = selectedConversation.conversationId, isBlocked = true, @@ -254,10 +249,12 @@ internal class ConversationListViewModel @Inject constructor( return } - setConversationArchived( - conversationIds = conversationIds, - isArchived = isArchived, - ) + conversationIds.forEach { conversationId -> + when { + isArchived -> conversationsRepository.archiveConversation(conversationId) + else -> conversationsRepository.unarchiveConversation(conversationId) + } + } _effects.tryEmit( Effect.ConversationsArchived( diff --git a/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt b/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt index 3cfccc5ac..c06d89735 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.conversationsettings.screen.delegate +import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepository import com.android.messaging.data.conversationsettings.repository.ConversationSettingsRepository @@ -7,8 +9,6 @@ import com.android.messaging.data.subscription.repository.ConversationSimSelecti import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.ParticipantRefresh import com.android.messaging.di.core.ApplicationCoroutineScope -import com.android.messaging.domain.blockedparticipants.usecase.SetDestinationBlocked -import com.android.messaging.domain.conversationsettings.usecase.SetConversationArchived import com.android.messaging.domain.conversationsettings.usecase.SetConversationSelfParticipantId import com.android.messaging.ui.conversationsettings.common.ConversationSettingsScreenDelegate import com.android.messaging.ui.conversationsettings.screen.mapper.ConversationSettingsUiStateMapper @@ -44,8 +44,8 @@ internal class ConversationSettingsDelegateImpl @Inject constructor( private val subscriptionsRepository: SubscriptionsRepository, private val simSelectionRepository: ConversationSimSelectionRepository, private val mapper: ConversationSettingsUiStateMapper, - private val setConversationArchived: SetConversationArchived, - private val setDestinationBlocked: SetDestinationBlocked, + private val conversationsRepository: ConversationsRepository, + private val blockedParticipantsRepository: BlockedParticipantsRepository, private val setConversationSelfParticipantId: SetConversationSelfParticipantId, @param:ApplicationCoroutineScope private val applicationScope: CoroutineScope, ) : ConversationSettingsDelegate { @@ -100,25 +100,27 @@ internal class ConversationSettingsDelegateImpl @Inject constructor( } override fun setDestinationBlocked(blocked: Boolean) { - val participant = _state.value.otherParticipant ?: return - val normalizedDestination = participant.normalizedDestination + val normalizedDestination = _state.value.otherParticipant?.normalizedDestination val conversationId = currentConversationId() if (normalizedDestination == null || conversationId == null) return - setDestinationBlocked( - normalizedDestination = normalizedDestination, - blocked = blocked, - conversationId = conversationId, - ) + applicationScope.launch { + blockedParticipantsRepository.setDestinationBlocked( + destination = normalizedDestination, + conversationId = conversationId, + isBlocked = blocked, + ) + } } override fun setArchived(archived: Boolean) { val conversationId = currentConversationId() ?: return - setConversationArchived( - conversationId = conversationId, - archived = archived, - ) + + when { + archived -> conversationsRepository.archiveConversation(conversationId) + else -> conversationsRepository.unarchiveConversation(conversationId) + } } override fun setSelfParticipantId(selfParticipantId: String) { From ef00719746cd624190b830b614104583b6f8b0b9 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 10 Jun 2026 21:07:52 +0200 Subject: [PATCH 06/39] Extract conversation list delegates --- .../ConversationListViewModelBindsModule.kt | 28 ++ .../redesign/ConversationListViewModel.kt | 240 +++++++----------- .../ConversationListActionsDelegate.kt | 95 +++++++ .../ConversationListSelectionDelegate.kt | 104 ++++++++ .../mapper/ConversationListUiStateMapper.kt | 6 - .../redesign/model/ConversationListEffect.kt | 5 +- .../model/ConversationListSelectionUiState.kt | 4 - .../redesign/model/ConversationListUiState.kt | 2 - 8 files changed, 321 insertions(+), 163 deletions(-) create mode 100644 src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt diff --git a/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt new file mode 100644 index 000000000..813d1e285 --- /dev/null +++ b/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt @@ -0,0 +1,28 @@ +package com.android.messaging.di.conversationlist + +import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegate +import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegateImpl +import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegate +import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegateImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class ConversationListViewModelBindsModule { + + @Binds + @ViewModelScoped + abstract fun bindConversationListSelectionDelegate( + impl: ConversationListSelectionDelegateImpl, + ): ConversationListSelectionDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationListActionsDelegate( + impl: ConversationListActionsDelegateImpl, + ): ConversationListActionsDelegate +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index deebd30c2..2b70274ec 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -2,20 +2,17 @@ package com.android.messaging.ui.conversationlist.redesign import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository -import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegate +import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegate import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State -import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversationUiModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.collections.immutable.PersistentSet -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -23,10 +20,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch internal interface ConversationListScreenModel { val effects: Flow @@ -39,24 +35,30 @@ internal interface ConversationListScreenModel { internal class ConversationListViewModel @Inject constructor( private val repository: ConversationListRepository, private val uiStateMapper: ConversationListUiStateMapper, - private val conversationsRepository: ConversationsRepository, - private val blockedParticipantsRepository: BlockedParticipantsRepository, + private val selectionDelegate: ConversationListSelectionDelegate, + private val actionsDelegate: ConversationListActionsDelegate, ) : ViewModel(), ConversationListScreenModel { - private val selectedConversationIds = MutableStateFlow>( - persistentSetOf(), - ) private val isScrollUpVisible = MutableStateFlow(false) - private var isNewestConversationVisible = true + private val snapshot: StateFlow = repository + .observeInboxSnapshot() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = null, + ) private val _effects = MutableSharedFlow(extraBufferCapacity = 1) - override val effects: Flow = _effects.asSharedFlow() + override val effects: Flow = merge( + _effects.asSharedFlow(), + actionsDelegate.effects, + ) override val uiState: StateFlow = combine( - repository.observeInboxSnapshot().onEach(::pruneSelection), - selectedConversationIds, + snapshot.filterNotNull(), + selectionDelegate.selectedIds, isScrollUpVisible, ) { snapshot, selectedIds, isScrollUpVisible -> uiStateMapper.map( @@ -72,6 +74,14 @@ internal class ConversationListViewModel @Inject constructor( initialValue = State(), ) + init { + selectionDelegate.bind( + scope = viewModelScope, + snapshotFlow = snapshot, + ) + actionsDelegate.bind(scope = viewModelScope) + } + override fun onAction(action: Action) { when (action) { is Action.DialogAction -> onDialogAction(action) @@ -106,21 +116,35 @@ internal class ConversationListViewModel @Inject constructor( private fun onNavigationAction(action: Action.NavigationAction) { when (action) { - Action.ArchivedConversationsClicked -> onArchivedConversationsClick() - Action.BlockedParticipantsClicked -> onBlockedParticipantsClick() - Action.ScrollUpClicked -> onScrollUpClick() - Action.SettingsClicked -> onSettingsClick() - Action.StartChatClicked -> onStartChatClick() + Action.ArchivedConversationsClicked -> { + _effects.tryEmit(Effect.OpenArchivedConversations) + } + + Action.BlockedParticipantsClicked -> { + _effects.tryEmit(Effect.OpenBlockedParticipants) + } + + Action.ScrollUpClicked -> { + _effects.tryEmit(Effect.ScrollToTop) + } + + Action.SettingsClicked -> { + _effects.tryEmit(Effect.OpenSettings) + } + + Action.StartChatClicked -> { + _effects.tryEmit(Effect.StartChat) + } } } private fun onSelectionAction(action: Action.SelectionAction) { when (action) { Action.AddContactClicked -> onAddContactClick() - Action.ArchiveClicked -> onArchiveClick() + Action.ArchiveClicked -> onArchiveClick(isArchived = true) + Action.UnarchiveClicked -> onArchiveClick(isArchived = false) Action.BlockClicked -> onBlockClick() - Action.SelectionCleared -> onSelectionCleared() - Action.UnarchiveClicked -> onUnarchiveClick() + Action.SelectionCleared -> selectionDelegate.clear() } } @@ -128,8 +152,8 @@ internal class ConversationListViewModel @Inject constructor( val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return when { - uiState.value.isSelectionMode -> { - toggleSelection(resolvedConversationId) + selectionDelegate.isSelectionActive() -> { + selectionDelegate.toggle(resolvedConversationId) } else -> { @@ -141,159 +165,77 @@ internal class ConversationListViewModel @Inject constructor( private fun onConversationLongClick(conversationId: String) { val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return - toggleSelection(resolvedConversationId) - } - - private fun onSelectionCleared() { - selectedConversationIds.value = persistentSetOf() - } - - private fun onStartChatClick() { - _effects.tryEmit(Effect.StartChat) - } - - private fun onArchivedConversationsClick() { - _effects.tryEmit(Effect.OpenArchivedConversations) - } - - private fun onBlockedParticipantsClick() { - _effects.tryEmit(Effect.OpenBlockedParticipants) - } - - private fun onSettingsClick() { - _effects.tryEmit(Effect.OpenSettings) - } - - private fun onScrollUpClick() { - _effects.tryEmit(Effect.ScrollToTop) + selectionDelegate.toggle(resolvedConversationId) } private fun onNewestConversationVisibilityChanged(isVisible: Boolean) { - if (isNewestConversationVisible == isVisible) { + if (isScrollUpVisible.value == !isVisible) { return } - isNewestConversationVisible = isVisible isScrollUpVisible.value = !isVisible repository.setNewestConversationVisible(isVisible) } - private fun onAddContactClick() { - val selectedConversation = singleSelectedConversation() ?: return - - _effects.tryEmit(Effect.ConfirmAddContact(selectedConversation)) - } + private fun onArchiveClick(isArchived: Boolean) { + val selectedItems = selectionDelegate.currentSelectedItems() - private fun onBlockClick() { - val selectedConversation = singleSelectedConversation() ?: return + if (selectedItems.isEmpty()) { + return + } - _effects.tryEmit(Effect.ConfirmBlock(selectedConversation)) + actionsDelegate.setArchived( + items = selectedItems, + isArchived = isArchived, + ) + selectionDelegate.clear() } - private fun onArchiveClick() { - updateSelectedArchiveStatus(isArchived = true) - } + private fun onAddContactClick() { + val destination = singleSelectedDestination() ?: return - private fun onUnarchiveClick() { - updateSelectedArchiveStatus(isArchived = false) + _effects.tryEmit(Effect.ConfirmAddContact(destination)) } - private fun onDeleteConfirmed() { - val selectedConversations = uiState.value.selection.selectedConversations - - if (selectedConversations.isEmpty()) { - return - } - - selectedConversations.forEach { conversation -> - conversationsRepository.deleteConversation( - conversationId = conversation.conversationId, - cutoffTimestamp = conversation.timestampMillis, - ) - } + private fun onBlockClick() { + val selectedItem = singleSelectedItem() ?: return + val destination = singleSelectedDestination() ?: return - onSelectionCleared() + _effects.tryEmit( + Effect.ConfirmBlock( + conversationId = selectedItem.conversationId, + destination = destination, + ), + ) } private fun onBlockConfirmed() { - val selectedConversation = singleSelectedConversation() ?: return - val destination = selectedConversation.normalizedDestination ?: return + val selectedItem = singleSelectedItem() ?: return - viewModelScope.launch { - val success = blockedParticipantsRepository.setDestinationBlocked( - destination = destination, - conversationId = selectedConversation.conversationId, - isBlocked = true, - ) - - _effects.emit( - Effect.ConversationBlocked( - destination = destination, - success = success, - ), - ) - - onSelectionCleared() - } + actionsDelegate.block(selectedItem) + selectionDelegate.clear() } - private fun updateSelectedArchiveStatus(isArchived: Boolean) { - val selectedConversations = uiState.value.selection.selectedConversations - val conversationIds = selectedConversations - .map { conversation -> - conversation.conversationId - } - .toSet() + private fun onDeleteConfirmed() { + val selectedItems = selectionDelegate.currentSelectedItems() - if (conversationIds.isEmpty()) { + if (selectedItems.isEmpty()) { return } - conversationIds.forEach { conversationId -> - when { - isArchived -> conversationsRepository.archiveConversation(conversationId) - else -> conversationsRepository.unarchiveConversation(conversationId) - } - } - - _effects.tryEmit( - Effect.ConversationsArchived( - count = conversationIds.size, - isArchived = isArchived, - ), - ) - - onSelectionCleared() - } - - private fun toggleSelection(conversationId: String) { - selectedConversationIds.update { currentSelectedIds -> - when { - conversationId in currentSelectedIds -> currentSelectedIds.remove(conversationId) - else -> currentSelectedIds.add(conversationId) - } - } + actionsDelegate.delete(selectedItems) + selectionDelegate.clear() } - private fun pruneSelection(snapshot: ConversationListSnapshot) { - val visibleConversationIds = snapshot.items - .asSequence() - .map { item -> - item.conversationId - } - .toSet() - - selectedConversationIds.update { currentSelectedIds -> - currentSelectedIds - .filter { conversationId -> - conversationId in visibleConversationIds - } - .toPersistentSet() - } + private fun singleSelectedItem(): ConversationListItem? { + return selectionDelegate.currentSelectedItems().singleOrNull() } - private fun singleSelectedConversation(): SelectedConversationUiModel? { - return uiState.value.selection.selectedConversations.singleOrNull() + private fun singleSelectedDestination(): String? { + return singleSelectedItem() + ?.participant + ?.otherNormalizedDestination + ?.takeIf(String::isNotBlank) } private companion object { diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt new file mode 100644 index 000000000..fd7f8a055 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt @@ -0,0 +1,95 @@ +package com.android.messaging.ui.conversationlist.redesign.delegate + +import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +internal interface ConversationListActionsDelegate { + val effects: Flow + + fun bind(scope: CoroutineScope) + fun setArchived(items: List, isArchived: Boolean) + fun delete(items: List) + fun block(item: ConversationListItem) +} + +internal class ConversationListActionsDelegateImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val blockedParticipantsRepository: BlockedParticipantsRepository, +) : ConversationListActionsDelegate { + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + override val effects: Flow = _effects.asSharedFlow() + + private var boundScope: CoroutineScope? = null + + override fun bind(scope: CoroutineScope) { + if (boundScope != null) { + return + } + + boundScope = scope + } + + override fun setArchived( + items: List, + isArchived: Boolean, + ) { + if (items.isEmpty()) { + return + } + + items.forEach { item -> + when { + isArchived -> conversationsRepository.archiveConversation(item.conversationId) + else -> conversationsRepository.unarchiveConversation(item.conversationId) + } + } + + _effects.tryEmit( + ConversationListEffect.ConversationsArchived( + count = items.size, + isArchived = isArchived, + ), + ) + } + + override fun delete(items: List) { + items.forEach { item -> + conversationsRepository.deleteConversation( + conversationId = item.conversationId, + cutoffTimestamp = item.latestMessage.timestamp, + ) + } + } + + override fun block(item: ConversationListItem) { + val destination = item + .participant + .otherNormalizedDestination + ?.takeIf(String::isNotBlank) + ?: return + + boundScope?.launch { + val success = blockedParticipantsRepository.setDestinationBlocked( + destination = destination, + conversationId = item.conversationId, + isBlocked = true, + ) + + _effects.emit( + ConversationListEffect.ConversationBlocked( + destination = destination, + success = success, + ), + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt new file mode 100644 index 000000000..4e29c5246 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt @@ -0,0 +1,104 @@ +package com.android.messaging.ui.conversationlist.redesign.delegate + +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal interface ConversationListSelectionDelegate { + val selectedIds: StateFlow> + + fun bind(scope: CoroutineScope, snapshotFlow: StateFlow) + fun toggle(conversationId: String) + fun clear() + fun isSelectionActive(): Boolean + fun currentSelectedItems(): ImmutableList +} + +internal class ConversationListSelectionDelegateImpl @Inject constructor() : + ConversationListSelectionDelegate { + + private val _selectedIds = MutableStateFlow>(persistentSetOf()) + override val selectedIds: StateFlow> = _selectedIds.asStateFlow() + + private var boundSnapshotFlow: StateFlow? = null + + override fun bind( + scope: CoroutineScope, + snapshotFlow: StateFlow, + ) { + if (boundSnapshotFlow != null) { + return + } + + boundSnapshotFlow = snapshotFlow + + scope.launch { + snapshotFlow + .filterNotNull() + .collect { snapshot -> + pruneSelection(snapshot) + } + } + } + + override fun toggle(conversationId: String) { + _selectedIds.update { currentSelectedIds -> + when { + conversationId in currentSelectedIds -> currentSelectedIds.remove(conversationId) + else -> currentSelectedIds.add(conversationId) + } + } + } + + override fun clear() { + _selectedIds.value = persistentSetOf() + } + + override fun isSelectionActive(): Boolean { + return _selectedIds.value.isNotEmpty() + } + + override fun currentSelectedItems(): ImmutableList { + val items = boundSnapshotFlow?.value?.items ?: return persistentListOf() + val currentSelectedIds = _selectedIds.value + + return items + .filter { item -> + item.conversationId in currentSelectedIds + } + .toImmutableList() + } + + private fun pruneSelection(snapshot: ConversationListSnapshot) { + if (_selectedIds.value.isEmpty()) { + return + } + + val visibleConversationIds = snapshot.items + .map { item -> + item.conversationId + } + .toSet() + + _selectedIds.update { currentSelectedIds -> + currentSelectedIds + .filter { conversationId -> + conversationId in visibleConversationIds + } + .toPersistentSet() + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index 8ed46c49d..3201e0798 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -69,10 +69,8 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : return ConversationListUiState( content = content, selection = selection, - isStartChatVisible = !selection.isActive, isScrollUpVisible = isScrollUpVisible, hasBlockedParticipants = snapshot.blockedDestinations.isNotEmpty(), - isSelectionMode = selection.isActive, ) } @@ -134,7 +132,6 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : blockedDestinations = blockedDestinations, ), isActive = selectedConversations.isNotEmpty(), - count = selectedConversations.size, ) } @@ -143,13 +140,10 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : ): SelectedConversationUiModel { return SelectedConversationUiModel( conversationId = item.conversationId, - timestampMillis = item.latestMessage.timestamp, - icon = item.icon, normalizedDestination = item.participant.otherNormalizedDestination, participantLookupKey = item.participant.lookupKey, isGroup = item.participant.isGroup, isArchived = item.isArchived, - notificationEnabled = item.notification.isEnabled, ) } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt index 07210dd1e..16b4683d1 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt @@ -17,11 +17,12 @@ internal sealed interface ConversationListEffect { ) : ConversationListEffect data class ConfirmAddContact( - val selectedConversation: SelectedConversationUiModel, + val destination: String, ) : ConversationListEffect data class ConfirmBlock( - val selectedConversation: SelectedConversationUiModel, + val conversationId: String, + val destination: String, ) : ConversationListEffect data class ConversationBlocked( diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt index 3d7457464..49ead01b0 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt @@ -9,19 +9,15 @@ internal data class ConversationListSelectionUiState( val selectedConversations: ImmutableList = persistentListOf(), val actions: SelectionActionsUiState = SelectionActionsUiState(), val isActive: Boolean = false, - val count: Int = 0, ) @Immutable internal data class SelectedConversationUiModel( val conversationId: String, - val timestampMillis: Long, - val icon: String?, val normalizedDestination: String?, val participantLookupKey: String?, val isGroup: Boolean, val isArchived: Boolean, - val notificationEnabled: Boolean, ) @Immutable diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt index 982f76405..30e214b32 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt @@ -7,10 +7,8 @@ import kotlinx.collections.immutable.ImmutableList internal data class ConversationListUiState( val content: ConversationListContentUiState = ConversationListContentUiState.Loading, val selection: ConversationListSelectionUiState = ConversationListSelectionUiState(), - val isStartChatVisible: Boolean = true, val isScrollUpVisible: Boolean = false, val hasBlockedParticipants: Boolean = false, - val isSelectionMode: Boolean = false, ) @Immutable From 113936a563a1e083d9b0dad25c34c050bb9d631a Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 10 Jun 2026 22:04:47 +0200 Subject: [PATCH 07/39] Add conversation list item components --- .../ui/common/components/TwoLineListItem.kt | 80 ++- .../redesign/ui/ConversationListItemRow.kt | 508 ++++++++++++++++++ .../redesign/ui/ConversationListTestTags.kt | 7 + .../redesign/ui/ConversationListTokens.kt | 18 + 4 files changed, 589 insertions(+), 24 deletions(-) create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTestTags.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt diff --git a/src/com/android/messaging/ui/common/components/TwoLineListItem.kt b/src/com/android/messaging/ui/common/components/TwoLineListItem.kt index 9cd019423..0d4ace7b8 100644 --- a/src/com/android/messaging/ui/common/components/TwoLineListItem.kt +++ b/src/com/android/messaging/ui/common/components/TwoLineListItem.kt @@ -28,7 +28,6 @@ private val ItemHorizontalPadding = 8.dp private val ItemVerticalPadding = 8.dp private val ListRowShape = RoundedCornerShape(percent = 50) -@OptIn(ExperimentalFoundationApi::class) @Composable internal fun TwoLineListItem( title: String, @@ -40,6 +39,54 @@ internal fun TwoLineListItem( shape: Shape = ListRowShape, color: Color = MaterialTheme.colorScheme.background, trailingContent: (@Composable () -> Unit)? = null, +) { + TwoLineListItem( + onClick = onClick, + leadingContent = leadingContent, + titleContent = { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = modifier, + onLongClick = onLongClick, + shape = shape, + color = color, + subtitleContent = when { + subtitle.isNullOrBlank() -> null + + else -> { + { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + trailingContent = trailingContent, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun TwoLineListItem( + onClick: () -> Unit, + leadingContent: @Composable () -> Unit, + titleContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + shape: Shape = ListRowShape, + color: Color = MaterialTheme.colorScheme.background, + subtitleContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, ) { Surface( modifier = modifier.fillMaxWidth(), @@ -62,9 +109,9 @@ internal fun TwoLineListItem( Spacer(modifier = Modifier.width(ItemHorizontalPadding)) - TwoLineListItemText( - title = title, - subtitle = subtitle, + TwoLineListItemContent( + titleContent = titleContent, + subtitleContent = subtitleContent, modifier = Modifier .weight(1f) .padding(horizontal = ItemHorizontalPadding), @@ -76,9 +123,9 @@ internal fun TwoLineListItem( } @Composable -private fun TwoLineListItemText( - title: String, - subtitle: String?, +private fun TwoLineListItemContent( + titleContent: @Composable () -> Unit, + subtitleContent: (@Composable () -> Unit)?, modifier: Modifier = Modifier, ) { val twoLineHeight = with(LocalDensity.current) { @@ -90,22 +137,7 @@ private fun TwoLineListItemText( modifier = modifier.heightIn(min = twoLineHeight), verticalArrangement = Arrangement.Center, ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (!subtitle.isNullOrBlank()) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + titleContent() + subtitleContent?.invoke() } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt new file mode 100644 index 000000000..2c6a0fee9 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt @@ -0,0 +1,508 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Work +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.sms.MmsUtils +import com.android.messaging.ui.common.components.TwoLineListItem +import com.android.messaging.ui.common.components.attachment.MediaThumbnail +import com.android.messaging.ui.common.components.participant.ParticipantAvatar +import com.android.messaging.ui.common.components.participant.participantAvatarLabel +import com.android.messaging.ui.common.components.participant.participantColorSeed +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSnippetUiModel +import com.android.messaging.ui.core.MessagingPreviewColumn +import com.android.messaging.util.Dates + +private const val PREVIEW_TIMESTAMP_MILLIS = 1_806_240_000_000L + +@Composable +internal fun ConversationListItemRow( + item: ConversationListItemUiModel, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TwoLineListItem( + onClick = onClick, + leadingContent = { + ConversationListItemAvatar(item) + }, + titleContent = { + ConversationListItemHeader(item) + }, + modifier = modifier.testTag( + tag = conversationListItemTestTag(item.conversationId), + ), + onLongClick = onLongClick, + color = itemContainerColor(item), + subtitleContent = { + ConversationListItemBody(item) + }, + trailingContent = { + ConversationListItemTrailing(item) + }, + ) +} + +@Composable +private fun ConversationListItemAvatar(item: ConversationListItemUiModel) { + val fallbackIcon = when { + item.avatar.isGroup -> Icons.Default.Group + else -> Icons.Default.Person + } + + ParticipantAvatar( + avatarUri = item.avatar.uri, + size = ItemAvatarSize, + fallbackLabel = participantAvatarLabel(source = item.title), + colorSeedCode = participantColorSeed( + normalizedDestination = item.avatar.normalizedDestination, + ), + fallbackSize = ItemAvatarFallbackSize, + fallbackIcon = fallbackIcon, + isSelected = item.isSelected, + ) +} + +@Composable +private fun ConversationListItemHeader(item: ConversationListItemUiModel) { + Row( + horizontalArrangement = Arrangement.spacedBy(ItemHeaderSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = item.title.orEmpty(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = itemUnreadFontWeight(item), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (item.isEnterprise) { + ConversationListItemBadgeIcon(Icons.Default.Work) + } + + if (item.isMuted) { + ConversationListItemBadgeIcon(Icons.Default.NotificationsOff) + } + + ConversationListItemStatusLabel(item) + } +} + +@Composable +private fun ConversationListItemBody(item: ConversationListItemUiModel) { + item.subject?.let { subject -> + Text( + text = subject, + style = MaterialTheme.typography.titleSmall, + color = itemSnippetColor(item), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + itemSnippetText(item)?.let { snippetText -> + Text( + text = snippetText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = itemUnreadFontWeight(item), + color = itemSnippetColor(item), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ConversationListItemTrailing(item: ConversationListItemUiModel) { + Row( + horizontalArrangement = Arrangement.spacedBy(ItemHeaderSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + if (item.isUnread) { + ConversationListItemUnreadDot() + } + + ConversationListItemPreviewThumbnail(item.snippet.preview) + } +} + +@Composable +private fun ConversationListItemBadgeIcon(icon: ImageVector) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(ItemBadgeIconSize), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun ConversationListItemUnreadDot() { + Box( + modifier = Modifier + .size(ItemUnreadDotSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) +} + +@Composable +private fun ConversationListItemPreviewThumbnail(preview: ConversationListPreviewUiModel?) { + val isVisual = preview is ConversationListPreviewUiModel.Image || + preview is ConversationListPreviewUiModel.Video + + if (preview == null || !isVisual) { + return + } + + val thumbnailSizePx = with(LocalDensity.current) { + ItemPreviewThumbnailSize.roundToPx() + } + + MediaThumbnail( + modifier = Modifier + .size(ItemPreviewThumbnailSize) + .clip(ItemPreviewThumbnailShape), + contentUri = preview.contentUri, + contentType = preview.contentType, + size = IntSize( + width = thumbnailSizePx, + height = thumbnailSizePx, + ), + ) +} + +@Composable +private fun ConversationListItemStatusLabel(item: ConversationListItemUiModel) { + val status = item.status + + val text = when (status) { + ConversationListMessageStatus.Draft, + ConversationListMessageStatus.Unknown, + -> { + stringResource(R.string.conversation_list_item_view_draft_message) + } + + ConversationListMessageStatus.Sending -> { + stringResource(R.string.message_status_sending) + } + + is ConversationListMessageStatus.Failed -> { + stringResource( + itemFailedStatusResId( + item = item, + status = status, + ), + ) + } + + ConversationListMessageStatus.Normal -> { + remember(item.timestampMillis) { + Dates.getConversationTimeString(item.timestampMillis).toString() + } + } + } + + val color = when { + status is ConversationListMessageStatus.Failed -> MaterialTheme.colorScheme.error + item.isUnread -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = color, + maxLines = 1, + ) +} + +private fun itemFailedStatusResId( + item: ConversationListItemUiModel, + status: ConversationListMessageStatus.Failed, +): Int { + return when { + item.isOutgoing -> { + MmsUtils.mapRawStatusToErrorResourceId( + MessageData.BUGLE_STATUS_OUTGOING_FAILED, + status.rawTelephonyStatus, + ) + } + + else -> R.string.message_status_download_failed + } +} + +@Composable +private fun itemSnippetText(item: ConversationListItemUiModel): String? { + val snippetText = item.snippet.text?.takeIf(String::isNotBlank) + + if (snippetText != null) { + return snippetText + } + + return when (item.snippet.preview) { + is ConversationListPreviewUiModel.Audio -> { + stringResource(R.string.conversation_list_snippet_audio_clip) + } + + is ConversationListPreviewUiModel.Image -> { + stringResource(R.string.conversation_list_snippet_picture) + } + + is ConversationListPreviewUiModel.Video -> { + stringResource(R.string.conversation_list_snippet_video) + } + + is ConversationListPreviewUiModel.VCard -> { + stringResource(R.string.conversation_list_snippet_vcard) + } + + is ConversationListPreviewUiModel.File -> stringResource(R.string.mms_text) + + null -> null + } +} + +@Composable +private fun itemContainerColor(item: ConversationListItemUiModel): Color { + return when { + item.isSelected -> MaterialTheme.colorScheme.surfaceContainerLow + else -> MaterialTheme.colorScheme.background + } +} + +private fun itemUnreadFontWeight(item: ConversationListItemUiModel): FontWeight { + return when { + item.isUnread -> FontWeight.Bold + else -> FontWeight.Normal + } +} + +@Composable +private fun itemSnippetColor(item: ConversationListItemUiModel): Color { + return when { + item.isUnread -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +@PreviewLightDark +@Composable +private fun ConversationListItemRowPreview() { + MessagingPreviewColumn { + Column(verticalArrangement = Arrangement.spacedBy(space = 2.dp)) { + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "unread", + title = "Jane Doe", + snippetText = "Are we still on for tomorrow?", + isUnread = true, + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "read", + title = "Ada Lovelace", + snippetText = "Sounds good, thanks!", + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "draft", + title = "Grace Hopper", + snippetText = "I was thinking that we could", + status = ConversationListMessageStatus.Draft, + isDraft = true, + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "sending", + title = "Amelia Brown", + snippetText = "On my way!", + status = ConversationListMessageStatus.Sending, + isOutgoing = true, + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "failed", + title = "Marina Silva", + snippetText = "Did you get my last message?", + status = ConversationListMessageStatus.Failed(rawTelephonyStatus = 0), + isOutgoing = true, + ), + onClick = {}, + onLongClick = {}, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConversationListItemRowBadgesPreview() { + MessagingPreviewColumn { + Column(verticalArrangement = Arrangement.spacedBy(space = 2.dp)) { + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "muted_group", + title = "Weekend plans", + snippetText = "Jane: I can bring snacks", + isGroup = true, + isMuted = true, + isUnread = true, + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "enterprise", + title = "Alex Appleseed", + snippetText = "The report is ready for review", + isEnterprise = true, + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "selected", + title = "Brian Cohen", + snippetText = "Happy birthday!", + isSelected = true, + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "subject", + title = "Maria Tamm", + snippetText = "Check out the details below", + subject = "Meeting agenda", + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "audio", + title = "Sara Lindberg", + snippetText = null, + preview = ConversationListPreviewUiModel.Audio( + contentUri = "content://preview/audio", + contentType = "audio/mp3", + ), + ), + onClick = {}, + onLongClick = {}, + ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "picture", + title = "Tomas Kask", + snippetText = null, + isUnread = true, + preview = ConversationListPreviewUiModel.Image( + contentUri = "content://preview/image", + contentType = "image/jpeg", + ), + ), + onClick = {}, + onLongClick = {}, + ) + } + } +} + +private fun previewConversationListItem( + conversationId: String, + title: String, + snippetText: String?, + status: ConversationListMessageStatus = ConversationListMessageStatus.Normal, + preview: ConversationListPreviewUiModel? = null, + subject: String? = null, + isOutgoing: Boolean = false, + isUnread: Boolean = false, + isGroup: Boolean = false, + isEnterprise: Boolean = false, + isMuted: Boolean = false, + isDraft: Boolean = false, + isSelected: Boolean = false, +): ConversationListItemUiModel { + return ConversationListItemUiModel( + conversationId = conversationId, + title = title, + avatar = ConversationListAvatarUiModel( + uri = null, + contactId = -1L, + lookupKey = null, + normalizedDestination = "+3161234$conversationId", + isGroup = isGroup, + ), + snippet = ConversationListSnippetUiModel( + text = snippetText, + senderName = null, + preview = preview, + isDraft = isDraft, + ), + subject = subject, + timestampMillis = PREVIEW_TIMESTAMP_MILLIS, + status = status, + isOutgoing = isOutgoing, + isUnread = isUnread, + isGroup = isGroup, + isEnterprise = isEnterprise, + isMuted = isMuted, + isArchived = false, + isSelected = isSelected, + ) +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTestTags.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTestTags.kt new file mode 100644 index 000000000..51f324849 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTestTags.kt @@ -0,0 +1,7 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +internal const val CONVERSATION_LIST_TEST_TAG = "conversation_list" + +internal fun conversationListItemTestTag(conversationId: String): String { + return "conversation_list_item_$conversationId" +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt new file mode 100644 index 000000000..bcdff6095 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt @@ -0,0 +1,18 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +internal val ItemAvatarSize = 48.dp + +internal val ItemAvatarFallbackSize = 20.dp + +internal val ItemHeaderSpacing = 8.dp + +internal val ItemBadgeIconSize = 16.dp + +internal val ItemUnreadDotSize = 8.dp + +internal val ItemPreviewThumbnailSize = 44.dp + +internal val ItemPreviewThumbnailShape = RoundedCornerShape(size = 8.dp) From eb0fb267e2c0e414df0b44b2ae2d0895142bc71e Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 10 Jun 2026 23:24:21 +0200 Subject: [PATCH 08/39] Add conversation list screen scaffold --- res/values/strings.xml | 5 + .../redesign/ConversationListEffectHandler.kt | 7 + .../redesign/ConversationListViewModel.kt | 31 +- .../ConversationListActionsDelegate.kt | 29 +- .../redesign/model/ConversationListAction.kt | 12 + .../redesign/model/ConversationListEffect.kt | 8 + .../redesign/ui/ConversationListContent.kt | 170 +++++++ .../redesign/ui/ConversationListDialogs.kt | 130 ++++++ .../redesign/ui/ConversationListItemRow.kt | 48 -- .../ui/ConversationListPreviewSupport.kt | 92 ++++ .../redesign/ui/ConversationListScreen.kt | 415 ++++++++++++++++++ .../ui/ConversationListSelectionTopAppBar.kt | 159 +++++++ .../redesign/ui/ConversationListTopAppBar.kt | 129 ++++++ 13 files changed, 1179 insertions(+), 56 deletions(-) create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ConversationListEffectHandler.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 04b84cb68..e52bc2c0c 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -757,6 +757,11 @@ Loading conversations… + + Start chat + + Scroll to top + Picture diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListEffectHandler.kt new file mode 100644 index 000000000..0907dd8e2 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListEffectHandler.kt @@ -0,0 +1,7 @@ +package com.android.messaging.ui.conversationlist.redesign + +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect + +internal interface ConversationListEffectHandler { + fun handle(effect: Effect) +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index 2b70274ec..4d31c6126 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -93,8 +93,14 @@ internal class ConversationListViewModel @Inject constructor( private fun onDialogAction(action: Action.DialogAction) { when (action) { + is Action.AddContactConfirmed -> onAddContactConfirmed(action.destination) Action.BlockConfirmed -> onBlockConfirmed() Action.DeleteConfirmed -> onDeleteConfirmed() + + is Action.ArchiveUndoClicked -> onArchiveUndoClicked( + conversationIds = action.conversationIds, + isArchived = action.isArchived, + ) } } @@ -124,6 +130,10 @@ internal class ConversationListViewModel @Inject constructor( _effects.tryEmit(Effect.OpenBlockedParticipants) } + Action.DebugOptionsClicked -> { + _effects.tryEmit(Effect.OpenDebugOptions) + } + Action.ScrollUpClicked -> { _effects.tryEmit(Effect.ScrollToTop) } @@ -185,8 +195,9 @@ internal class ConversationListViewModel @Inject constructor( } actionsDelegate.setArchived( - items = selectedItems, + conversationIds = selectedItems.map(ConversationListItem::conversationId), isArchived = isArchived, + shouldShowSnackbar = true ) selectionDelegate.clear() } @@ -197,6 +208,24 @@ internal class ConversationListViewModel @Inject constructor( _effects.tryEmit(Effect.ConfirmAddContact(destination)) } + private fun onAddContactConfirmed(destination: String) { + val resolvedDestination = destination.takeIf(String::isNotBlank) ?: return + + _effects.tryEmit(Effect.OpenAddContact(resolvedDestination)) + selectionDelegate.clear() + } + + private fun onArchiveUndoClicked( + conversationIds: List, + isArchived: Boolean, + ) { + actionsDelegate.setArchived( + conversationIds = conversationIds, + isArchived = !isArchived, + shouldShowSnackbar = false, + ) + } + private fun onBlockClick() { val selectedItem = singleSelectedItem() ?: return val destination = singleSelectedDestination() ?: return diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt index fd7f8a055..e757a5935 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt @@ -5,6 +5,7 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -15,7 +16,11 @@ internal interface ConversationListActionsDelegate { val effects: Flow fun bind(scope: CoroutineScope) - fun setArchived(items: List, isArchived: Boolean) + fun setArchived( + conversationIds: List, + isArchived: Boolean, + shouldShowSnackbar: Boolean, + ) fun delete(items: List) fun block(item: ConversationListItem) } @@ -39,23 +44,33 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( } override fun setArchived( - items: List, + conversationIds: List, isArchived: Boolean, + shouldShowSnackbar: Boolean, ) { - if (items.isEmpty()) { + val resolvedConversationIds = conversationIds + .filter(String::isNotBlank) + .distinct() + + if (resolvedConversationIds.isEmpty()) { return } - items.forEach { item -> + resolvedConversationIds.forEach { conversationId -> when { - isArchived -> conversationsRepository.archiveConversation(item.conversationId) - else -> conversationsRepository.unarchiveConversation(item.conversationId) + isArchived -> conversationsRepository.archiveConversation(conversationId) + else -> conversationsRepository.unarchiveConversation(conversationId) } } + if (!shouldShowSnackbar) { + return + } + _effects.tryEmit( ConversationListEffect.ConversationsArchived( - count = items.size, + conversationIds = resolvedConversationIds.toImmutableList(), + count = resolvedConversationIds.size, isArchived = isArchived, ), ) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt index dd6a596f6..08c416f6d 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.conversationlist.redesign.model +import kotlinx.collections.immutable.ImmutableList + internal sealed interface ConversationListAction { sealed interface DialogAction : ConversationListAction @@ -22,6 +24,15 @@ internal sealed interface ConversationListAction { val isVisible: Boolean, ) : ListAction + data class AddContactConfirmed( + val destination: String, + ) : DialogAction + + data class ArchiveUndoClicked( + val conversationIds: ImmutableList, + val isArchived: Boolean, + ) : DialogAction + data object AddContactClicked : SelectionAction data object ArchiveClicked : SelectionAction data object BlockClicked : SelectionAction @@ -30,6 +41,7 @@ internal sealed interface ConversationListAction { data object ArchivedConversationsClicked : NavigationAction data object BlockedParticipantsClicked : NavigationAction + data object DebugOptionsClicked : NavigationAction data object ScrollUpClicked : NavigationAction data object SettingsClicked : NavigationAction data object StartChatClicked : NavigationAction diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt index 16b4683d1..2aa56eee5 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt @@ -1,13 +1,17 @@ package com.android.messaging.ui.conversationlist.redesign.model +import kotlinx.collections.immutable.ImmutableList + internal sealed interface ConversationListEffect { data object StartChat : ConversationListEffect data object OpenArchivedConversations : ConversationListEffect data object OpenBlockedParticipants : ConversationListEffect data object OpenSettings : ConversationListEffect + data object OpenDebugOptions : ConversationListEffect data class ConversationsArchived( + val conversationIds: ImmutableList, val count: Int, val isArchived: Boolean, ) : ConversationListEffect @@ -20,6 +24,10 @@ internal sealed interface ConversationListEffect { val destination: String, ) : ConversationListEffect + data class OpenAddContact( + val destination: String, + ) : ConversationListEffect + data class ConfirmBlock( val conversationId: String, val destination: String, diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt new file mode 100644 index 000000000..310573f43 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt @@ -0,0 +1,170 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import com.android.messaging.ui.core.MessagingPreviewTheme +import kotlinx.collections.immutable.ImmutableList + +private const val CONVERSATION_ROW_CONTENT_TYPE = "conversation_row" + +private val ListVerticalSpacing = 2.dp + +private val ListContentPadding = 8.dp + +private val EmptyTextHorizontalPadding = 32.dp + +@Composable +internal fun ConversationListContent( + content: ConversationListContentUiState, + listState: LazyListState, + onAction: (Action) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + when (content) { + ConversationListContentUiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + ConversationListContentUiState.WaitingForSync -> { + ConversationListMessage( + text = stringResource(R.string.conversation_list_first_sync_text), + ) + } + + ConversationListContentUiState.Empty -> { + ConversationListMessage( + text = stringResource(R.string.conversation_list_empty_text), + ) + } + + is ConversationListContentUiState.Items -> { + ConversationListItems( + items = content.items, + listState = listState, + onAction = onAction, + contentPadding = contentPadding, + ) + } + } + } +} + +@Composable +private fun ConversationListItems( + items: ImmutableList, + listState: LazyListState, + onAction: (Action) -> Unit, + contentPadding: PaddingValues, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(CONVERSATION_LIST_TEST_TAG), + state = listState, + contentPadding = PaddingValues( + start = ListContentPadding, + end = ListContentPadding, + top = ListContentPadding, + bottom = contentPadding.calculateBottomPadding() + ListContentPadding, + ), + verticalArrangement = Arrangement.spacedBy(ListVerticalSpacing), + ) { + items( + items = items, + key = { item -> item.conversationId }, + contentType = { CONVERSATION_ROW_CONTENT_TYPE }, + ) { item -> + ConversationListItemRow( + item = item, + onClick = { + onAction(Action.ConversationClicked(item.conversationId)) + }, + onLongClick = { + onAction(Action.ConversationLongClicked(item.conversationId)) + }, + ) + } + } +} + +@Composable +private fun ConversationListMessage(text: String) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = EmptyTextHorizontalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationListContentEmptyPreview() { + MessagingPreviewTheme { + ConversationListContent( + content = ConversationListContentUiState.Empty, + listState = rememberLazyListState(), + onAction = {}, + contentPadding = PaddingValues(), + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationListContentWaitingForSyncPreview() { + MessagingPreviewTheme { + ConversationListContent( + content = ConversationListContentUiState.WaitingForSync, + listState = rememberLazyListState(), + onAction = {}, + contentPadding = PaddingValues(), + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationListContentItemsPreview() { + MessagingPreviewTheme { + ConversationListContent( + content = ConversationListContentUiState.Items( + items = previewConversationListItems(), + ), + listState = rememberLazyListState(), + onAction = {}, + contentPadding = PaddingValues(), + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt new file mode 100644 index 000000000..6588b1525 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt @@ -0,0 +1,130 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.messaging.R +import com.android.messaging.ui.core.MessagingPreviewTheme + +@Composable +internal fun ConversationListAddContactDialog( + destination: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.add_contact_confirmation_dialog_title)) + }, + text = { + Text(text = destination) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.add_contact_confirmation)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +internal fun ConversationListDeleteDialog( + selectedCount: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = pluralStringResource( + id = R.plurals.delete_conversations_confirmation_dialog_title, + count = selectedCount, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.delete_conversation_confirmation_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.delete_conversation_decline_button)) + } + }, + ) +} + +@Composable +internal fun ConversationListBlockDialog( + destination: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.block_confirmation_title, destination)) + }, + text = { + Text(text = stringResource(R.string.block_confirmation_message)) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@PreviewLightDark +@Composable +private fun ConversationListAddContactDialogPreview() { + MessagingPreviewTheme { + ConversationListAddContactDialog( + destination = "+1 555 0100", + onConfirm = {}, + onDismiss = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationListDeleteDialogPreview() { + MessagingPreviewTheme { + ConversationListDeleteDialog( + selectedCount = 2, + onConfirm = {}, + onDismiss = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationListBlockDialogPreview() { + MessagingPreviewTheme { + ConversationListBlockDialog( + destination = "+1 555 0100", + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt index 2c6a0fee9..0a388f054 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt @@ -39,15 +39,11 @@ import com.android.messaging.ui.common.components.attachment.MediaThumbnail import com.android.messaging.ui.common.components.participant.ParticipantAvatar import com.android.messaging.ui.common.components.participant.participantAvatarLabel import com.android.messaging.ui.common.components.participant.participantColorSeed -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSnippetUiModel import com.android.messaging.ui.core.MessagingPreviewColumn import com.android.messaging.util.Dates -private const val PREVIEW_TIMESTAMP_MILLIS = 1_806_240_000_000L - @Composable internal fun ConversationListItemRow( item: ConversationListItemUiModel, @@ -462,47 +458,3 @@ private fun ConversationListItemRowBadgesPreview() { } } } - -private fun previewConversationListItem( - conversationId: String, - title: String, - snippetText: String?, - status: ConversationListMessageStatus = ConversationListMessageStatus.Normal, - preview: ConversationListPreviewUiModel? = null, - subject: String? = null, - isOutgoing: Boolean = false, - isUnread: Boolean = false, - isGroup: Boolean = false, - isEnterprise: Boolean = false, - isMuted: Boolean = false, - isDraft: Boolean = false, - isSelected: Boolean = false, -): ConversationListItemUiModel { - return ConversationListItemUiModel( - conversationId = conversationId, - title = title, - avatar = ConversationListAvatarUiModel( - uri = null, - contactId = -1L, - lookupKey = null, - normalizedDestination = "+3161234$conversationId", - isGroup = isGroup, - ), - snippet = ConversationListSnippetUiModel( - text = snippetText, - senderName = null, - preview = preview, - isDraft = isDraft, - ), - subject = subject, - timestampMillis = PREVIEW_TIMESTAMP_MILLIS, - status = status, - isOutgoing = isOutgoing, - isUnread = isUnread, - isGroup = isGroup, - isEnterprise = isEnterprise, - isMuted = isMuted, - isArchived = false, - isSelected = isSelected, - ) -} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt new file mode 100644 index 000000000..ad88b82ce --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt @@ -0,0 +1,92 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSnippetUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal const val PREVIEW_TIMESTAMP_MILLIS = 1_806_240_000_000L + +internal fun previewConversationListItem( + conversationId: String, + title: String, + snippetText: String?, + status: ConversationListMessageStatus = ConversationListMessageStatus.Normal, + preview: ConversationListPreviewUiModel? = null, + subject: String? = null, + isOutgoing: Boolean = false, + isUnread: Boolean = false, + isGroup: Boolean = false, + isEnterprise: Boolean = false, + isMuted: Boolean = false, + isDraft: Boolean = false, + isSelected: Boolean = false, +): ConversationListItemUiModel { + return ConversationListItemUiModel( + conversationId = conversationId, + title = title, + avatar = ConversationListAvatarUiModel( + uri = null, + contactId = -1L, + lookupKey = null, + normalizedDestination = "+3161234$conversationId", + isGroup = isGroup, + ), + snippet = ConversationListSnippetUiModel( + text = snippetText, + senderName = null, + preview = preview, + isDraft = isDraft, + ), + subject = subject, + timestampMillis = PREVIEW_TIMESTAMP_MILLIS, + status = status, + isOutgoing = isOutgoing, + isUnread = isUnread, + isGroup = isGroup, + isEnterprise = isEnterprise, + isMuted = isMuted, + isArchived = false, + isSelected = isSelected, + ) +} + +internal fun previewConversationListItems(): ImmutableList { + return persistentListOf( + previewConversationListItem( + conversationId = "1", + title = "Jane Doe", + snippetText = "Are we still on for tomorrow?", + isUnread = true, + ), + previewConversationListItem( + conversationId = "2", + title = "Ada Lovelace", + snippetText = "Sounds good, thanks!", + ), + previewConversationListItem( + conversationId = "3", + title = "Grace Hopper", + snippetText = "I was thinking that we could", + status = ConversationListMessageStatus.Draft, + isDraft = true, + ), + previewConversationListItem( + conversationId = "4", + title = "Weekend plans", + snippetText = "Jane: I can bring snacks", + isGroup = true, + isMuted = true, + ), + previewConversationListItem( + conversationId = "5", + title = "Marina Silva", + snippetText = "Did you get my last message?", + status = ConversationListMessageStatus.Failed(rawTelephonyStatus = 0), + isOutgoing = true, + ), + ) +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt new file mode 100644 index 000000000..c2c6a3f76 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt @@ -0,0 +1,415 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.messaging.R +import com.android.messaging.ui.conversationlist.redesign.ConversationListEffectHandler +import com.android.messaging.ui.conversationlist.redesign.ConversationListScreenModel +import com.android.messaging.ui.conversationlist.redesign.ConversationListViewModel +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State +import com.android.messaging.ui.core.MessagingPreviewTheme +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +private val FabSpacing = 16.dp +private val StartChatButtonHeight = 56.dp +private val StartChatIconSpacing = 8.dp + +@Composable +internal fun ConversationListScreen( + effectHandler: ConversationListEffectHandler, + isDebugEnabled: Boolean, + modifier: Modifier = Modifier, + screenModel: ConversationListScreenModel = viewModel(), +) { + val uiState by screenModel.uiState.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } + + var pendingAddContactDestination by rememberSaveable { mutableStateOf(null) } + var pendingDelete by rememberSaveable { mutableStateOf(false) } + var pendingBlockDestination by rememberSaveable { mutableStateOf(null) } + + ConversationListEffects( + screenModel = screenModel, + effectHandler = effectHandler, + listState = listState, + snackbarHostState = snackbarHostState, + onConfirmAddContact = { pendingAddContactDestination = it }, + onConfirmBlock = { pendingBlockDestination = it }, + ) + + ConversationListScaffold( + uiState = uiState, + isDebugEnabled = isDebugEnabled, + listState = listState, + snackbarHostState = snackbarHostState, + onAction = screenModel::onAction, + onDeleteClick = { pendingDelete = true }, + onScrollToTop = { screenModel.onAction(Action.ScrollUpClicked) }, + modifier = modifier, + ) + + pendingAddContactDestination?.let { destination -> + ConversationListAddContactDialog( + destination = destination, + onConfirm = { + pendingAddContactDestination = null + screenModel.onAction(Action.AddContactConfirmed(destination)) + }, + onDismiss = { pendingAddContactDestination = null }, + ) + } + + if (pendingDelete) { + ConversationListDeleteDialog( + selectedCount = uiState.selection.selectedConversations.size, + onConfirm = { + pendingDelete = false + screenModel.onAction(Action.DeleteConfirmed) + }, + onDismiss = { pendingDelete = false }, + ) + } + + pendingBlockDestination?.let { destination -> + ConversationListBlockDialog( + destination = destination, + onConfirm = { + pendingBlockDestination = null + screenModel.onAction(Action.BlockConfirmed) + }, + onDismiss = { pendingBlockDestination = null }, + ) + } +} + +@Composable +private fun ConversationListEffects( + screenModel: ConversationListScreenModel, + effectHandler: ConversationListEffectHandler, + listState: LazyListState, + snackbarHostState: SnackbarHostState, + onConfirmAddContact: (String) -> Unit, + onConfirmBlock: (String) -> Unit, +) { + val context = LocalContext.current + val undoLabel = stringResource(R.string.snack_bar_undo) + val snackbarScope = rememberCoroutineScope() + + val currentContext by rememberUpdatedState(context) + val currentEffectHandler by rememberUpdatedState(effectHandler) + val currentUndoLabel by rememberUpdatedState(undoLabel) + val currentOnConfirmAddContact by rememberUpdatedState(onConfirmAddContact) + val currentOnConfirmBlock by rememberUpdatedState(onConfirmBlock) + + LaunchedEffect(screenModel) { + screenModel.effects.collect { effect -> + when (effect) { + is Effect.ConfirmAddContact -> { + currentOnConfirmAddContact(effect.destination) + } + + is Effect.ConfirmBlock -> { + currentOnConfirmBlock(effect.destination) + } + + is Effect.ConversationsArchived -> { + snackbarScope.launch { + showArchivedSnackbar( + snackbarHostState = snackbarHostState, + message = currentContext.getString( + archivedSnackbarMessageResId(isArchived = effect.isArchived), + effect.count, + ), + undoLabel = currentUndoLabel, + onUndo = { + screenModel.onAction( + Action.ArchiveUndoClicked( + conversationIds = effect.conversationIds, + isArchived = effect.isArchived, + ), + ) + }, + ) + } + } + + Effect.ScrollToTop -> { + listState.animateScrollToItem(index = 0) + } + + else -> currentEffectHandler.handle(effect) + } + } + } +} + +private fun archivedSnackbarMessageResId(isArchived: Boolean): Int { + return when { + isArchived -> R.string.archived_toast_message + else -> R.string.unarchived_toast_message + } +} + +private suspend fun showArchivedSnackbar( + snackbarHostState: SnackbarHostState, + message: String, + undoLabel: String, + onUndo: () -> Unit, +) { + val snackbarResult = snackbarHostState.showSnackbar( + message = message, + actionLabel = undoLabel, + ) + + if (snackbarResult == SnackbarResult.ActionPerformed) { + onUndo() + } +} + +@Composable +private fun ConversationListScaffold( + uiState: State, + isDebugEnabled: Boolean, + listState: LazyListState, + snackbarHostState: SnackbarHostState, + onAction: (Action) -> Unit, + onDeleteClick: () -> Unit, + onScrollToTop: () -> Unit, + modifier: Modifier = Modifier, +) { + val isSelectionMode = uiState.selection.isActive + + BackHandler(enabled = isSelectionMode) { + onAction(Action.SelectionCleared) + } + + ConversationListScrollReporter( + listState = listState, + onAction = onAction, + ) + + Scaffold( + modifier = modifier, + topBar = { + ConversationListTopBar( + uiState = uiState, + isSelectionMode = isSelectionMode, + isDebugEnabled = isDebugEnabled, + onAction = onAction, + onDeleteClick = onDeleteClick, + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + }, + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = contentPadding.calculateTopPadding()), + ) { + ConversationListContent( + content = uiState.content, + listState = listState, + onAction = onAction, + contentPadding = contentPadding, + ) + + ConversationListFabs( + isStartChatVisible = !isSelectionMode, + isScrollUpVisible = uiState.isScrollUpVisible, + onStartChatClick = { onAction(Action.StartChatClicked) }, + onScrollToTopClick = onScrollToTop, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(FabSpacing), + ) + } + } +} + +@Composable +private fun ConversationListTopBar( + uiState: State, + isSelectionMode: Boolean, + isDebugEnabled: Boolean, + onAction: (Action) -> Unit, + onDeleteClick: () -> Unit, +) { + when { + isSelectionMode -> { + ConversationListSelectionTopAppBar( + selectedCount = uiState.selection.selectedConversations.size, + actions = uiState.selection.actions, + onAction = onAction, + onDeleteClick = onDeleteClick, + ) + } + + else -> { + ConversationListTopAppBar( + hasBlockedParticipants = uiState.hasBlockedParticipants, + isDebugEnabled = isDebugEnabled, + onAction = onAction, + ) + } + } +} + +@Composable +private fun ConversationListScrollReporter( + listState: LazyListState, + onAction: (Action) -> Unit, +) { + LaunchedEffect(listState) { + snapshotFlow { + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 + } + .distinctUntilChanged() + .collect { isAtTop -> + onAction(Action.NewestConversationVisibilityChanged(isVisible = isAtTop)) + } + } +} + +@Composable +private fun ConversationListFabs( + isStartChatVisible: Boolean, + isScrollUpVisible: Boolean, + onStartChatClick: () -> Unit, + onScrollToTopClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(space = FabSpacing), + ) { + AnimatedVisibility( + visible = isScrollUpVisible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + SmallFloatingActionButton( + onClick = onScrollToTopClick, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ) { + Icon( + imageVector = Icons.Rounded.ArrowUpward, + contentDescription = stringResource(R.string.conversation_list_scroll_to_top), + ) + } + } + + AnimatedVisibility( + visible = isStartChatVisible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + Button( + modifier = Modifier.height(StartChatButtonHeight), + onClick = onStartChatClick, + shape = MaterialTheme.shapes.small, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + ) + Spacer(modifier = Modifier.size(StartChatIconSpacing)) + Text(text = stringResource(R.string.conversation_list_start_chat)) + } + } + } + } +} + +@PreviewLightDark +@Composable +private fun ConversationListScaffoldItemsPreview() { + MessagingPreviewTheme { + ConversationListScaffold( + uiState = State( + content = ConversationListContentUiState.Items( + items = previewConversationListItems(), + ), + ), + isDebugEnabled = false, + listState = rememberLazyListState(), + snackbarHostState = remember { SnackbarHostState() }, + onAction = {}, + onDeleteClick = {}, + onScrollToTop = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationListScaffoldEmptyPreview() { + MessagingPreviewTheme { + ConversationListScaffold( + uiState = State(content = ConversationListContentUiState.Empty), + isDebugEnabled = true, + listState = rememberLazyListState(), + snackbarHostState = remember { SnackbarHostState() }, + onAction = {}, + onDeleteClick = {}, + onScrollToTop = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt new file mode 100644 index 000000000..a4ecfd70e --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt @@ -0,0 +1,159 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Unarchive +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.messaging.R +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.redesign.model.SelectionActionsUiState +import com.android.messaging.ui.core.MessagingPreviewTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationListSelectionTopAppBar( + selectedCount: Int, + actions: SelectionActionsUiState, + onAction: (Action) -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + colors = selectionTopAppBarColors(), + title = { + ConversationListSelectionTitle(selectedCount) + }, + navigationIcon = { + IconButton(onClick = { onAction(Action.SelectionCleared) }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.close_selection), + ) + } + }, + actions = { + ConversationListSelectionActions( + actions = actions, + onAction = onAction, + onDeleteClick = onDeleteClick, + ) + }, + ) +} + +@Composable +private fun ConversationListSelectionTitle(selectedCount: Int) { + Text( + text = pluralStringResource( + id = R.plurals.conversation_message_selection_title, + count = selectedCount, + selectedCount, + ), + ) +} + +@Composable +private fun ConversationListSelectionActions( + actions: SelectionActionsUiState, + onAction: (Action) -> Unit, + onDeleteClick: () -> Unit, +) { + if (actions.canArchive) { + SelectionActionButton( + imageVector = Icons.Default.Archive, + labelResId = R.string.action_archive, + onClick = { onAction(Action.ArchiveClicked) }, + ) + } + + if (actions.canUnarchive) { + SelectionActionButton( + imageVector = Icons.Default.Unarchive, + labelResId = R.string.action_unarchive, + onClick = { onAction(Action.UnarchiveClicked) }, + ) + } + + if (actions.canAddContact) { + SelectionActionButton( + imageVector = Icons.Default.PersonAdd, + labelResId = R.string.action_add_contact, + onClick = { onAction(Action.AddContactClicked) }, + ) + } + + if (actions.canBlock) { + SelectionActionButton( + imageVector = Icons.Default.Block, + labelResId = R.string.action_block, + onClick = { onAction(Action.BlockClicked) }, + ) + } + + if (actions.canDelete) { + SelectionActionButton( + imageVector = Icons.Default.Delete, + labelResId = R.string.action_delete, + onClick = onDeleteClick, + ) + } +} + +@Composable +private fun SelectionActionButton( + imageVector: androidx.compose.ui.graphics.vector.ImageVector, + labelResId: Int, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + imageVector = imageVector, + contentDescription = stringResource(labelResId), + ) + } +} + +@Composable +private fun selectionTopAppBarColors(): TopAppBarColors { + return TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + titleContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) +} + +@PreviewLightDark +@Composable +private fun ConversationListSelectionTopAppBarPreview() { + MessagingPreviewTheme { + ConversationListSelectionTopAppBar( + selectedCount = 2, + actions = SelectionActionsUiState( + canArchive = true, + canDelete = true, + canAddContact = true, + canBlock = true, + ), + onAction = {}, + onDeleteClick = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt new file mode 100644 index 000000000..bc9ef1475 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt @@ -0,0 +1,129 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.messaging.R +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.core.MessagingPreviewTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationListTopAppBar( + hasBlockedParticipants: Boolean, + isDebugEnabled: Boolean, + onAction: (Action) -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + title = { + Text(text = stringResource(R.string.app_name)) + }, + actions = { + ConversationListOverflowMenu( + hasBlockedParticipants = hasBlockedParticipants, + isDebugEnabled = isDebugEnabled, + onAction = onAction, + ) + }, + ) +} + +@Composable +private fun ConversationListOverflowMenu( + hasBlockedParticipants: Boolean, + isDebugEnabled: Boolean, + onAction: (Action) -> Unit, +) { + var isExpanded by remember { + mutableStateOf(value = false) + } + + IconButton(onClick = { isExpanded = true }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options), + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + ) { + ConversationListMenuItem( + labelResId = R.string.action_menu_show_archived, + onClick = { + isExpanded = false + onAction(Action.ArchivedConversationsClicked) + }, + ) + + if (hasBlockedParticipants) { + ConversationListMenuItem( + labelResId = R.string.blocked_contacts_title, + onClick = { + isExpanded = false + onAction(Action.BlockedParticipantsClicked) + }, + ) + } + + ConversationListMenuItem( + labelResId = R.string.action_settings, + onClick = { + isExpanded = false + onAction(Action.SettingsClicked) + }, + ) + + if (isDebugEnabled) { + ConversationListMenuItem( + labelResId = R.string.action_debug_options, + onClick = { + isExpanded = false + onAction(Action.DebugOptionsClicked) + }, + ) + } + } +} + +@Composable +private fun ConversationListMenuItem( + labelResId: Int, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text(text = stringResource(labelResId)) + }, + onClick = onClick, + ) +} + +@PreviewLightDark +@Composable +private fun ConversationListTopAppBarPreview() { + MessagingPreviewTheme { + ConversationListTopAppBar( + hasBlockedParticipants = true, + isDebugEnabled = true, + onAction = {}, + ) + } +} From 0f4b2bc0928cd3557fc6f986a266c3d5f1a7cb95 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 11 Jun 2026 01:02:07 +0200 Subject: [PATCH 09/39] Decouple debug options menu from FragmentActivity --- .../debug_sms_mms_from_dump_file_dialog.xml | 32 ---- res/layout/sms_mms_dump_file_list_item.xml | 30 --- ...DebugSmsMmsFromDumpFileDialogFragment.java | 172 ------------------ .../android/messaging/util/DebugUtils.java | 80 ++++++-- 4 files changed, 64 insertions(+), 250 deletions(-) delete mode 100644 res/layout/debug_sms_mms_from_dump_file_dialog.xml delete mode 100644 res/layout/sms_mms_dump_file_list_item.xml diff --git a/res/layout/debug_sms_mms_from_dump_file_dialog.xml b/res/layout/debug_sms_mms_from_dump_file_dialog.xml deleted file mode 100644 index 342f489dd..000000000 --- a/res/layout/debug_sms_mms_from_dump_file_dialog.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - diff --git a/res/layout/sms_mms_dump_file_list_item.xml b/res/layout/sms_mms_dump_file_list_item.xml deleted file mode 100644 index a939db2b0..000000000 --- a/res/layout/sms_mms_dump_file_list_item.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java index ae9d4f855..e69de29bb 100644 --- a/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java +++ b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java @@ -1,172 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.ui.debug; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.telephony.SmsMessage; -import android.view.LayoutInflater; -import android.view.View; -import android.view.View.OnClickListener; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ListView; -import android.widget.TextView; - -import androidx.fragment.app.DialogFragment; - -import com.android.messaging.R; -import com.android.messaging.datamodel.action.ReceiveMmsMessageAction; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.receiver.SmsReceiver; -import com.android.messaging.sms.MmsUtils; -import com.android.messaging.util.DebugUtils; -import com.android.messaging.util.LogUtil; - -/** - * Class that displays UI for choosing SMS/MMS dump files for debugging - */ -public class DebugSmsMmsFromDumpFileDialogFragment extends DialogFragment { - public static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; - public static final String KEY_DUMP_FILES = "dump_files"; - public static final String KEY_ACTION = "action"; - - public static final String ACTION_LOAD = "load"; - public static final String ACTION_EMAIL = "email"; - - private String[] mDumpFiles; - private String mAction; - - public static DebugSmsMmsFromDumpFileDialogFragment newInstance(final String[] dumpFiles, - final String action) { - final DebugSmsMmsFromDumpFileDialogFragment frag = - new DebugSmsMmsFromDumpFileDialogFragment(); - final Bundle args = new Bundle(); - args.putSerializable(KEY_DUMP_FILES, dumpFiles); - args.putString(KEY_ACTION, action); - frag.setArguments(args); - return frag; - } - - @Override - public Dialog onCreateDialog(final Bundle savedInstanceState) { - final Bundle args = getArguments(); - mDumpFiles = (String[]) args.getSerializable(KEY_DUMP_FILES); - mAction = args.getString(KEY_ACTION); - - final LayoutInflater inflater = getActivity().getLayoutInflater(); - final View layout = inflater.inflate( - R.layout.debug_sms_mms_from_dump_file_dialog, null/*root*/); - final ListView list = (ListView) layout.findViewById(R.id.dump_file_list); - list.setAdapter(new DumpFileListAdapter(getActivity(), mDumpFiles)); - final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - final Resources resources = getResources(); - if (ACTION_LOAD.equals(mAction)) { - builder.setTitle(resources.getString( - R.string.load_sms_mms_from_dump_file_dialog_title)); - } else if (ACTION_EMAIL.equals(mAction)) { - builder.setTitle(resources.getString( - R.string.email_sms_mms_from_dump_file_dialog_title)); - } - builder.setView(layout); - return builder.create(); - } - - private class DumpFileListAdapter extends ArrayAdapter { - public DumpFileListAdapter(final Context context, final String[] dumpFiles) { - super(context, R.layout.sms_mms_dump_file_list_item, dumpFiles); - } - - @Override - public View getView(final int position, final View view, final ViewGroup parent) { - TextView actionItemView; - if (view == null || !(view instanceof TextView)) { - final LayoutInflater inflater = LayoutInflater.from(getContext()); - actionItemView = (TextView) inflater.inflate( - R.layout.sms_mms_dump_file_list_item, parent, false); - } else { - actionItemView = (TextView) view; - } - - final String file = getItem(position); - actionItemView.setText(file); - actionItemView.setOnClickListener(new OnClickListener() { - @Override - public void onClick(final View view) { - dismiss(); - if (ACTION_LOAD.equals(mAction)) { - receiveFromDumpFile(file); - } else if (ACTION_EMAIL.equals(mAction)) { - emailDumpFile(file); - } - } - }); - return actionItemView; - } - } - - /** - * Load MMS/SMS from the dump file - */ - private void receiveFromDumpFile(final String dumpFileName) { - if (dumpFileName.startsWith(MmsUtils.SMS_DUMP_PREFIX)) { - final SmsMessage[] messages = DebugUtils.retrieveSmsFromDumpFile(dumpFileName); - if (messages != null) { - SmsReceiver.deliverSmsMessages(getActivity(), ParticipantData.DEFAULT_SELF_SUB_ID, - 0, messages); - } else { - LogUtil.e(LogUtil.BUGLE_TAG, - "receiveFromDumpFile: invalid sms dump file " + dumpFileName); - } - } else if (dumpFileName.startsWith(MmsUtils.MMS_DUMP_PREFIX)) { - final byte[] data = MmsUtils.createDebugNotificationInd(dumpFileName); - if (data != null) { - final ReceiveMmsMessageAction action = new ReceiveMmsMessageAction( - ParticipantData.DEFAULT_SELF_SUB_ID, data); - action.start(); - } else { - LogUtil.e(LogUtil.BUGLE_TAG, - "receiveFromDumpFile: invalid mms dump file " + dumpFileName); - } - } else { - LogUtil.e(LogUtil.BUGLE_TAG, - "receiveFromDumpFile: invalid dump file name " + dumpFileName); - } - } - - /** - * Launch email app to send the dump file - */ - private void emailDumpFile(final String file) { - final Resources resources = getResources(); - final String fileLocation = "file://" - + Environment.getExternalStorageDirectory() + "/" + file; - final Intent sharingIntent = new Intent(Intent.ACTION_SEND); - sharingIntent.setType(APPLICATION_OCTET_STREAM); - sharingIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fileLocation)); - sharingIntent.putExtra(Intent.EXTRA_SUBJECT, - resources.getString(R.string.email_sms_mms_dump_file_subject)); - getActivity().startActivity(Intent.createChooser(sharingIntent, - resources.getString(R.string.email_sms_mms_dump_file_chooser_title))); - } -} diff --git a/src/com/android/messaging/util/DebugUtils.java b/src/com/android/messaging/util/DebugUtils.java index 83f9fc994..cccb473bb 100644 --- a/src/com/android/messaging/util/DebugUtils.java +++ b/src/com/android/messaging/util/DebugUtils.java @@ -30,21 +30,20 @@ import android.widget.Toast; import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; import com.android.messaging.Factory; import com.android.messaging.R; import com.android.messaging.datamodel.SyncManager; import com.android.messaging.datamodel.action.DumpDatabaseAction; import com.android.messaging.datamodel.action.LogTelephonyDatabaseAction; +import com.android.messaging.datamodel.action.ReceiveMmsMessageAction; +import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.debug.DebugSimEmulationMode; import com.android.messaging.debug.DebugSimEmulationStore; import com.android.messaging.debug.TestDataSeeder; +import com.android.messaging.receiver.SmsReceiver; import com.android.messaging.sms.MmsUtils; import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.debug.DebugSmsMmsFromDumpFileDialogFragment; import java.io.BufferedInputStream; import java.io.DataInputStream; @@ -114,7 +113,7 @@ public String toString() { public abstract void run(); } - public static void showDebugOptions(final AppCompatActivity host) { + public static void showDebugOptions(final Activity host) { final AlertDialog.Builder builder = new AlertDialog.Builder(host); final ArrayAdapter arrayAdapter = new ArrayAdapter( @@ -161,7 +160,7 @@ public void run() { @Override public void run() { new DebugSmsMmsDumpTask(host, - DebugSmsMmsFromDumpFileDialogFragment.ACTION_LOAD).executeOnThreadPool(); + DebugSmsMmsDumpTask.ACTION_LOAD).executeOnThreadPool(); } }); @@ -169,7 +168,7 @@ public void run() { @Override public void run() { new DebugSmsMmsDumpTask(host, - DebugSmsMmsFromDumpFileDialogFragment.ACTION_EMAIL).executeOnThreadPool(); + DebugSmsMmsDumpTask.ACTION_EMAIL).executeOnThreadPool(); } }); @@ -237,7 +236,7 @@ public void onClick(final DialogInterface arg0, final int pos) { builder.create().show(); } - private static void showSimEmulationModeDialog(final AppCompatActivity host) { + private static void showSimEmulationModeDialog(final Activity host) { final DebugSimEmulationMode[] modes = DebugSimEmulationMode.values(); final String[] labels = new String[modes.length]; int checkedIndex = 0; @@ -280,10 +279,14 @@ private static String describeSimEmulationMode(final DebugSimEmulationMode mode) * Task to list all the dump files and perform an action on it */ private static class DebugSmsMmsDumpTask extends SafeAsyncTask { + static final String ACTION_LOAD = "load"; + static final String ACTION_EMAIL = "email"; + private static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + private final String mAction; - private final AppCompatActivity mHost; + private final Activity mHost; - public DebugSmsMmsDumpTask(final AppCompatActivity host, final String action) { + public DebugSmsMmsDumpTask(final Activity host, final String action) { mHost = host; mAction = action; } @@ -293,11 +296,56 @@ protected void onPostExecute(final String[] result) { if (result == null || result.length < 1) { return; } - final FragmentManager fragmentManager = mHost.getSupportFragmentManager(); - final FragmentTransaction ft = fragmentManager.beginTransaction(); - final DebugSmsMmsFromDumpFileDialogFragment dialog = - DebugSmsMmsFromDumpFileDialogFragment.newInstance(result, mAction); - dialog.show(fragmentManager, ""/*tag*/); + final int titleResId = ACTION_LOAD.equals(mAction) + ? R.string.load_sms_mms_from_dump_file_dialog_title + : R.string.email_sms_mms_from_dump_file_dialog_title; + new AlertDialog.Builder(mHost) + .setTitle(titleResId) + .setItems(result, (dialog, which) -> { + final String file = result[which]; + if (ACTION_LOAD.equals(mAction)) { + receiveFromDumpFile(file); + } else if (ACTION_EMAIL.equals(mAction)) { + emailDumpFile(file); + } + }) + .show(); + } + + private void receiveFromDumpFile(final String dumpFileName) { + if (dumpFileName.startsWith(MmsUtils.SMS_DUMP_PREFIX)) { + final SmsMessage[] messages = DebugUtils.retreiveSmsFromDumpFile(dumpFileName); + if (messages != null) { + SmsReceiver.deliverSmsMessages(mHost, ParticipantData.DEFAULT_SELF_SUB_ID, + 0, messages); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid sms dump file " + dumpFileName); + } + } else if (dumpFileName.startsWith(MmsUtils.MMS_DUMP_PREFIX)) { + final byte[] data = MmsUtils.createDebugNotificationInd(dumpFileName); + if (data != null) { + new ReceiveMmsMessageAction(ParticipantData.DEFAULT_SELF_SUB_ID, data).start(); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid mms dump file " + dumpFileName); + } + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid dump file name " + dumpFileName); + } + } + + private void emailDumpFile(final String file) { + final String fileLocation = "file://" + + Environment.getExternalStorageDirectory() + "/" + file; + final Intent sharingIntent = new Intent(Intent.ACTION_SEND); + sharingIntent.setType(APPLICATION_OCTET_STREAM); + sharingIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fileLocation)); + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, + mHost.getString(R.string.email_sms_mms_dump_file_subject)); + mHost.startActivity(Intent.createChooser(sharingIntent, + mHost.getString(R.string.email_sms_mms_dump_file_chooser_title))); } @Override @@ -307,7 +355,7 @@ protected String[] doInBackgroundTimed(final Void... params) { @Override public boolean accept(final File dir, final String filename) { return filename != null - && ((mAction == DebugSmsMmsFromDumpFileDialogFragment.ACTION_EMAIL + && ((ACTION_EMAIL.equals(mAction) && filename.equals(DumpDatabaseAction.DUMP_NAME)) || filename.startsWith(MmsUtils.MMS_DUMP_PREFIX) || filename.startsWith(MmsUtils.SMS_DUMP_PREFIX)); From f6bcc66125b41490bac31f45c2f3da3593883abe Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 11 Jun 2026 01:08:27 +0200 Subject: [PATCH 10/39] Switch conversation list to Compose --- .../data/debug/DebugFeaturesProvider.kt | 15 ++ .../messaging/di/core/CoreBindsModule.kt | 20 +++ .../ConversationListActivity.java | 150 ------------------ .../ConversationListActivity.kt | 34 ++++ .../ConversationListActivityEffectHandler.kt | 61 +++++++ .../redesign/ConversationListViewModel.kt | 21 ++- .../ConversationListActionsDelegate.kt | 18 +++ .../mapper/ConversationListUiStateMapper.kt | 3 + .../redesign/model/ConversationListAction.kt | 9 ++ .../redesign/model/ConversationListEffect.kt | 1 + .../redesign/model/ConversationListUiState.kt | 1 + .../redesign/ui/ConversationListScreen.kt | 115 ++++++++++---- 12 files changed, 266 insertions(+), 182 deletions(-) create mode 100644 src/com/android/messaging/data/debug/DebugFeaturesProvider.kt create mode 100644 src/com/android/messaging/di/core/CoreBindsModule.kt delete mode 100644 src/com/android/messaging/ui/conversationlist/ConversationListActivity.java create mode 100644 src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt create mode 100644 src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt diff --git a/src/com/android/messaging/data/debug/DebugFeaturesProvider.kt b/src/com/android/messaging/data/debug/DebugFeaturesProvider.kt new file mode 100644 index 000000000..57704023f --- /dev/null +++ b/src/com/android/messaging/data/debug/DebugFeaturesProvider.kt @@ -0,0 +1,15 @@ +package com.android.messaging.data.debug + +import com.android.messaging.util.DebugUtils +import javax.inject.Inject + +internal fun interface DebugFeaturesProvider { + fun isEnabled(): Boolean +} + +internal class DebugFeaturesProviderImpl @Inject constructor() : DebugFeaturesProvider { + + override fun isEnabled(): Boolean { + return DebugUtils.isDebugEnabled() + } +} diff --git a/src/com/android/messaging/di/core/CoreBindsModule.kt b/src/com/android/messaging/di/core/CoreBindsModule.kt new file mode 100644 index 000000000..d02c121c1 --- /dev/null +++ b/src/com/android/messaging/di/core/CoreBindsModule.kt @@ -0,0 +1,20 @@ +package com.android.messaging.di.core + +import com.android.messaging.data.debug.DebugFeaturesProvider +import com.android.messaging.data.debug.DebugFeaturesProviderImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class CoreBindsModule { + + @Binds + @Reusable + abstract fun bindDebugFeaturesProvider( + impl: DebugFeaturesProviderImpl, + ): DebugFeaturesProvider +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java deleted file mode 100644 index c91e1d7ea..000000000 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.ui.conversationlist; - -import android.graphics.drawable.ColorDrawable; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; - -import com.android.messaging.R; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.util.DebugUtils; -import com.android.messaging.util.Trace; - -import androidx.appcompat.app.ActionBar; - -public class ConversationListActivity extends AbstractConversationListActivity { - @Override - protected void onCreate(final Bundle savedInstanceState) { - Trace.beginSection("ConversationListActivity.onCreate"); - setTheme(R.style.BugleTheme_ConversationListActivity); - super.onCreate(savedInstanceState); - setContentView(R.layout.conversation_list_activity); - Trace.endSection(); - invalidateActionBar(); - } - - @Override - protected void updateActionBar(final ActionBar actionBar) { - actionBar.setTitle(getString(R.string.app_name)); - actionBar.setDisplayShowTitleEnabled(true); - actionBar.setDisplayHomeAsUpEnabled(false); - actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); - actionBar.setBackgroundDrawable(new ColorDrawable( - getResources().getColor(R.color.action_bar_background_color))); - actionBar.show(); - super.updateActionBar(actionBar); - } - - @Override - public void onResume() { - super.onResume(); - // Invalidate the menu as items that are based on settings may have changed - // while not in the app (e.g. Talkback enabled/disable affects new conversation - // button) - supportInvalidateOptionsMenu(); - } - - @Override - public void onBackPressed() { - if (isInConversationListSelectMode()) { - exitMultiSelectState(); - } else { - super.onBackPressed(); - } - } - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - if (super.onCreateOptionsMenu(menu)) { - return true; - } - getMenuInflater().inflate(R.menu.conversation_list_fragment_menu, menu); - final MenuItem item = menu.findItem(R.id.action_debug_options); - if (item != null) { - final boolean enableDebugItems = DebugUtils.isDebugEnabled(); - item.setVisible(enableDebugItems).setEnabled(enableDebugItems); - } - return true; - } - - @Override - public boolean onOptionsItemSelected(final MenuItem menuItem) { - int itemId = menuItem.getItemId(); - if (itemId == R.id.action_start_new_conversation) { - onActionBarStartNewConversation(); - return true; - } - if (itemId == R.id.action_settings) { - onActionBarSettings(); - return true; - } - if (itemId == R.id.action_debug_options) { - onActionBarDebug(); - return true; - } - if (itemId == R.id.action_show_archived) { - onActionBarArchived(); - return true; - } - if (itemId == R.id.action_show_blocked_contacts) { - onActionBarBlockedParticipants(); - return true; - } - return super.onOptionsItemSelected(menuItem); - } - - @Override - public void onActionBarHome() { - exitMultiSelectState(); - } - - public void onActionBarStartNewConversation() { - UIIntents.get().launchCreateNewConversationActivity(this, null); - } - - public void onActionBarSettings() { - UIIntents.get().launchSettingsActivity(this); - } - - public void onActionBarBlockedParticipants() { - UIIntents.get().launchBlockedParticipantsActivity(this); - } - - public void onActionBarArchived() { - UIIntents.get().launchArchivedConversationsActivity(this); - } - - @Override - public boolean isSwipeAnimatable() { - return !isInConversationListSelectMode(); - } - - @Override - public void onWindowFocusChanged(final boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - final ConversationListFragment conversationListFragment = - (ConversationListFragment) getSupportFragmentManager().findFragmentById( - R.id.conversation_list_fragment); - // When the screen is turned on, the last used activity gets resumed, but it gets - // window focus only after the lock screen is unlocked. - if (hasFocus && conversationListFragment != null) { - conversationListFragment.setScrolledToNewestConversationIfNeeded(); - } - } -} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt new file mode 100644 index 000000000..b999e3bf4 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt @@ -0,0 +1,34 @@ +package com.android.messaging.ui.conversationlist + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import com.android.messaging.ui.conversationlist.redesign.ui.ConversationListScreen +import com.android.messaging.ui.core.AppTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ConversationListActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + val effectHandler = ConversationListActivityEffectHandler( + activity = this, + ) + + setContent { + AppTheme { + ConversationListScreen( + effectHandler = effectHandler, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt new file mode 100644 index 000000000..dd78ce4fb --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt @@ -0,0 +1,61 @@ +package com.android.messaging.ui.conversationlist + +import androidx.activity.ComponentActivity +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversationlist.redesign.ConversationListEffectHandler +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect +import com.android.messaging.util.DebugUtils + +internal class ConversationListActivityEffectHandler( + private val activity: ComponentActivity, +) : ConversationListEffectHandler { + + override fun handle(effect: Effect) { + when (effect) { + Effect.StartChat -> { + UIIntents.get().launchCreateNewConversationActivity( + activity, + null + ) + } + + is Effect.OpenConversation -> { + UIIntents.get().launchConversationActivity( + activity, + effect.conversationId, + null, + ) + } + + Effect.OpenArchivedConversations -> { + UIIntents.get().launchArchivedConversationsActivity(activity) + } + + Effect.OpenBlockedParticipants -> { + UIIntents.get().launchBlockedParticipantsActivity(activity) + } + + Effect.OpenSettings -> { + UIIntents.get().launchSettingsActivity(activity) + } + + Effect.OpenDebugOptions -> { + DebugUtils.showDebugOptions(activity) + } + + is Effect.OpenAddContact -> { + UIIntents.get().launchAddContactActivity( + activity, + effect.destination, + ) + } + + is Effect.ConversationBlocked, + is Effect.ConfirmAddContact, + is Effect.ConfirmBlock, + is Effect.ConversationsArchived, + Effect.ScrollToTop, + -> Unit + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index 4d31c6126..6371a3751 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegate import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegate import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper @@ -37,10 +38,12 @@ internal class ConversationListViewModel @Inject constructor( private val uiStateMapper: ConversationListUiStateMapper, private val selectionDelegate: ConversationListSelectionDelegate, private val actionsDelegate: ConversationListActionsDelegate, + private val debugFeaturesProvider: DebugFeaturesProvider, ) : ViewModel(), ConversationListScreenModel { private val isScrollUpVisible = MutableStateFlow(false) + private val isDebugEnabled = MutableStateFlow(debugFeaturesProvider.isEnabled()) private val snapshot: StateFlow = repository .observeInboxSnapshot() @@ -60,11 +63,13 @@ internal class ConversationListViewModel @Inject constructor( snapshot.filterNotNull(), selectionDelegate.selectedIds, isScrollUpVisible, - ) { snapshot, selectedIds, isScrollUpVisible -> + isDebugEnabled, + ) { snapshot, selectedIds, isScrollUpVisible, isDebugEnabled -> uiStateMapper.map( snapshot = snapshot, selectedConversationIds = selectedIds, isScrollUpVisible = isScrollUpVisible, + isDebugEnabled = isDebugEnabled, ) }.stateIn( scope = viewModelScope, @@ -85,6 +90,7 @@ internal class ConversationListViewModel @Inject constructor( override fun onAction(action: Action) { when (action) { is Action.DialogAction -> onDialogAction(action) + is Action.LifecycleAction -> onLifecycleAction(action) is Action.ListAction -> onListAction(action) is Action.NavigationAction -> onNavigationAction(action) is Action.SelectionAction -> onSelectionAction(action) @@ -101,6 +107,19 @@ internal class ConversationListViewModel @Inject constructor( conversationIds = action.conversationIds, isArchived = action.isArchived, ) + + is Action.BlockUndoClicked -> actionsDelegate.unblock( + conversationId = action.conversationId, + destination = action.destination, + ) + } + } + + private fun onLifecycleAction(action: Action.LifecycleAction) { + when (action) { + Action.ScreenResumed -> { + isDebugEnabled.value = debugFeaturesProvider.isEnabled() + } } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt index e757a5935..ad07aeab4 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt @@ -21,8 +21,10 @@ internal interface ConversationListActionsDelegate { isArchived: Boolean, shouldShowSnackbar: Boolean, ) + fun delete(items: List) fun block(item: ConversationListItem) + fun unblock(conversationId: String, destination: String) } internal class ConversationListActionsDelegateImpl @Inject constructor( @@ -101,10 +103,26 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( _effects.emit( ConversationListEffect.ConversationBlocked( + conversationId = item.conversationId, destination = destination, success = success, ), ) } } + + override fun unblock( + conversationId: String, + destination: String, + ) { + val resolvedDestination = destination.takeIf(String::isNotBlank) ?: return + + boundScope?.launch { + blockedParticipantsRepository.setDestinationBlocked( + destination = resolvedDestination, + conversationId = conversationId.takeIf(String::isNotBlank), + isBlocked = false, + ) + } + } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index 3201e0798..8a3823f86 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -24,6 +24,7 @@ internal interface ConversationListUiStateMapper { snapshot: ConversationListSnapshot, selectedConversationIds: ImmutableSet, isScrollUpVisible: Boolean, + isDebugEnabled: Boolean, ): ConversationListUiState } @@ -34,6 +35,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : snapshot: ConversationListSnapshot, selectedConversationIds: ImmutableSet, isScrollUpVisible: Boolean, + isDebugEnabled: Boolean, ): ConversationListUiState { val items = snapshot.items .map { item -> @@ -71,6 +73,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : selection = selection, isScrollUpVisible = isScrollUpVisible, hasBlockedParticipants = snapshot.blockedDestinations.isNotEmpty(), + isDebugEnabled = isDebugEnabled, ) } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt index 08c416f6d..f23343cb8 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt @@ -6,6 +6,8 @@ internal sealed interface ConversationListAction { sealed interface DialogAction : ConversationListAction + sealed interface LifecycleAction : ConversationListAction + sealed interface ListAction : ConversationListAction sealed interface NavigationAction : ConversationListAction @@ -33,6 +35,11 @@ internal sealed interface ConversationListAction { val isArchived: Boolean, ) : DialogAction + data class BlockUndoClicked( + val conversationId: String, + val destination: String, + ) : DialogAction + data object AddContactClicked : SelectionAction data object ArchiveClicked : SelectionAction data object BlockClicked : SelectionAction @@ -48,4 +55,6 @@ internal sealed interface ConversationListAction { data object BlockConfirmed : DialogAction data object DeleteConfirmed : DialogAction + + data object ScreenResumed : LifecycleAction } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt index 2aa56eee5..f9b1d5de5 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt @@ -34,6 +34,7 @@ internal sealed interface ConversationListEffect { ) : ConversationListEffect data class ConversationBlocked( + val conversationId: String, val destination: String, val success: Boolean, ) : ConversationListEffect diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt index 30e214b32..abc9032bb 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt @@ -9,6 +9,7 @@ internal data class ConversationListUiState( val selection: ConversationListSelectionUiState = ConversationListSelectionUiState(), val isScrollUpVisible: Boolean = false, val hasBlockedParticipants: Boolean = false, + val isDebugEnabled: Boolean = false, ) @Immutable diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt index c2c6a3f76..2eaf1ea90 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversationlist.redesign.ui +import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -45,6 +46,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.android.messaging.R @@ -56,6 +59,7 @@ import com.android.messaging.ui.conversationlist.redesign.model.ConversationList import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State import com.android.messaging.ui.core.MessagingPreviewTheme +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -66,7 +70,6 @@ private val StartChatIconSpacing = 8.dp @Composable internal fun ConversationListScreen( effectHandler: ConversationListEffectHandler, - isDebugEnabled: Boolean, modifier: Modifier = Modifier, screenModel: ConversationListScreenModel = viewModel(), ) { @@ -90,7 +93,6 @@ internal fun ConversationListScreen( ConversationListScaffold( uiState = uiState, - isDebugEnabled = isDebugEnabled, listState = listState, snackbarHostState = snackbarHostState, onAction = screenModel::onAction, @@ -152,6 +154,10 @@ private fun ConversationListEffects( val currentOnConfirmAddContact by rememberUpdatedState(onConfirmAddContact) val currentOnConfirmBlock by rememberUpdatedState(onConfirmBlock) + LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { + screenModel.onAction(Action.ScreenResumed) + } + LaunchedEffect(screenModel) { screenModel.effects.collect { effect -> when (effect) { @@ -164,24 +170,23 @@ private fun ConversationListEffects( } is Effect.ConversationsArchived -> { - snackbarScope.launch { - showArchivedSnackbar( - snackbarHostState = snackbarHostState, - message = currentContext.getString( - archivedSnackbarMessageResId(isArchived = effect.isArchived), - effect.count, - ), - undoLabel = currentUndoLabel, - onUndo = { - screenModel.onAction( - Action.ArchiveUndoClicked( - conversationIds = effect.conversationIds, - isArchived = effect.isArchived, - ), - ) - }, - ) - } + snackbarScope.launchArchivedSnackbar( + snackbarHostState = snackbarHostState, + context = currentContext, + undoLabel = currentUndoLabel, + effect = effect, + onAction = screenModel::onAction, + ) + } + + is Effect.ConversationBlocked -> { + snackbarScope.launchBlockedSnackbar( + snackbarHostState = snackbarHostState, + context = currentContext, + undoLabel = currentUndoLabel, + effect = effect, + onAction = screenModel::onAction, + ) } Effect.ScrollToTop -> { @@ -194,14 +199,64 @@ private fun ConversationListEffects( } } -private fun archivedSnackbarMessageResId(isArchived: Boolean): Int { - return when { - isArchived -> R.string.archived_toast_message +private fun CoroutineScope.launchArchivedSnackbar( + snackbarHostState: SnackbarHostState, + context: Context, + undoLabel: String, + effect: Effect.ConversationsArchived, + onAction: (Action) -> Unit, +) { + val messageResId = when { + effect.isArchived -> R.string.archived_toast_message else -> R.string.unarchived_toast_message } + + launch { + showUndoableSnackbar( + snackbarHostState = snackbarHostState, + message = context.getString(messageResId, effect.count), + undoLabel = undoLabel, + onUndo = { + onAction( + Action.ArchiveUndoClicked( + conversationIds = effect.conversationIds, + isArchived = effect.isArchived, + ), + ) + }, + ) + } } -private suspend fun showArchivedSnackbar( +private fun CoroutineScope.launchBlockedSnackbar( + snackbarHostState: SnackbarHostState, + context: Context, + undoLabel: String, + effect: Effect.ConversationBlocked, + onAction: (Action) -> Unit, +) { + if (!effect.success) { + return + } + + launch { + showUndoableSnackbar( + snackbarHostState = snackbarHostState, + message = context.getString(R.string.update_destination_blocked), + undoLabel = undoLabel, + onUndo = { + onAction( + Action.BlockUndoClicked( + conversationId = effect.conversationId, + destination = effect.destination, + ), + ) + }, + ) + } +} + +private suspend fun showUndoableSnackbar( snackbarHostState: SnackbarHostState, message: String, undoLabel: String, @@ -220,7 +275,6 @@ private suspend fun showArchivedSnackbar( @Composable private fun ConversationListScaffold( uiState: State, - isDebugEnabled: Boolean, listState: LazyListState, snackbarHostState: SnackbarHostState, onAction: (Action) -> Unit, @@ -245,7 +299,6 @@ private fun ConversationListScaffold( ConversationListTopBar( uiState = uiState, isSelectionMode = isSelectionMode, - isDebugEnabled = isDebugEnabled, onAction = onAction, onDeleteClick = onDeleteClick, ) @@ -283,7 +336,6 @@ private fun ConversationListScaffold( private fun ConversationListTopBar( uiState: State, isSelectionMode: Boolean, - isDebugEnabled: Boolean, onAction: (Action) -> Unit, onDeleteClick: () -> Unit, ) { @@ -300,7 +352,7 @@ private fun ConversationListTopBar( else -> { ConversationListTopAppBar( hasBlockedParticipants = uiState.hasBlockedParticipants, - isDebugEnabled = isDebugEnabled, + isDebugEnabled = uiState.isDebugEnabled, onAction = onAction, ) } @@ -388,7 +440,6 @@ private fun ConversationListScaffoldItemsPreview() { items = previewConversationListItems(), ), ), - isDebugEnabled = false, listState = rememberLazyListState(), snackbarHostState = remember { SnackbarHostState() }, onAction = {}, @@ -403,8 +454,10 @@ private fun ConversationListScaffoldItemsPreview() { private fun ConversationListScaffoldEmptyPreview() { MessagingPreviewTheme { ConversationListScaffold( - uiState = State(content = ConversationListContentUiState.Empty), - isDebugEnabled = true, + uiState = State( + content = ConversationListContentUiState.Empty, + isDebugEnabled = true, + ), listState = rememberLazyListState(), snackbarHostState = remember { SnackbarHostState() }, onAction = {}, From 44432832f2a8fce52bde31f30e8266ca75af3ad6 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Tue, 16 Jun 2026 17:31:17 +0200 Subject: [PATCH 11/39] Migrate BlockedParticipantsDelegate to repository.setDestinationBlocked --- .../BlockedParticipantsBindsModule.kt | 8 -------- .../delegate/BlockedParticipantsDelegate.kt | 16 ++++++++++------ .../ui/common/components/ParticipantAvatar.kt | 0 3 files changed, 10 insertions(+), 14 deletions(-) delete mode 100644 src/com/android/messaging/ui/common/components/ParticipantAvatar.kt diff --git a/src/com/android/messaging/di/blockedparticipants/BlockedParticipantsBindsModule.kt b/src/com/android/messaging/di/blockedparticipants/BlockedParticipantsBindsModule.kt index 075e6b9b9..d92765434 100644 --- a/src/com/android/messaging/di/blockedparticipants/BlockedParticipantsBindsModule.kt +++ b/src/com/android/messaging/di/blockedparticipants/BlockedParticipantsBindsModule.kt @@ -4,8 +4,6 @@ import com.android.messaging.data.blockedparticipants.repository.BlockedParticip import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepositoryImpl import com.android.messaging.domain.blockedparticipants.usecase.DeleteDirectChats import com.android.messaging.domain.blockedparticipants.usecase.DeleteDirectChatsImpl -import com.android.messaging.domain.blockedparticipants.usecase.SetDestinationBlocked -import com.android.messaging.domain.blockedparticipants.usecase.SetDestinationBlockedImpl import com.android.messaging.ui.blockedparticipants.screen.mapper.BlockedParticipantsUiStateMapper import com.android.messaging.ui.blockedparticipants.screen.mapper.BlockedParticipantsUiStateMapperImpl import dagger.Binds @@ -30,12 +28,6 @@ internal abstract class BlockedParticipantsBindsModule { impl: BlockedParticipantsUiStateMapperImpl, ): BlockedParticipantsUiStateMapper - @Binds - @Reusable - abstract fun bindSetDestinationBlocked( - impl: SetDestinationBlockedImpl, - ): SetDestinationBlocked - @Binds @Reusable abstract fun bindDeleteDirectChats( diff --git a/src/com/android/messaging/ui/blockedparticipants/screen/delegate/BlockedParticipantsDelegate.kt b/src/com/android/messaging/ui/blockedparticipants/screen/delegate/BlockedParticipantsDelegate.kt index d8d7af622..ec1083dc7 100644 --- a/src/com/android/messaging/ui/blockedparticipants/screen/delegate/BlockedParticipantsDelegate.kt +++ b/src/com/android/messaging/ui/blockedparticipants/screen/delegate/BlockedParticipantsDelegate.kt @@ -1,9 +1,9 @@ package com.android.messaging.ui.blockedparticipants.screen.delegate import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.di.core.ApplicationCoroutineScope import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.blockedparticipants.usecase.DeleteDirectChats -import com.android.messaging.domain.blockedparticipants.usecase.SetDestinationBlocked import com.android.messaging.ui.blockedparticipants.common.BlockedParticipantsScreenDelegate import com.android.messaging.ui.blockedparticipants.screen.mapper.BlockedParticipantsUiStateMapper import com.android.messaging.ui.blockedparticipants.screen.model.BlockedParticipantUiState @@ -31,10 +31,11 @@ internal interface BlockedParticipantsDelegate : BlockedParticipantsScreenDelega internal class BlockedParticipantsDelegateImpl @Inject constructor( private val repository: BlockedParticipantsRepository, private val mapper: BlockedParticipantsUiStateMapper, - private val setDestinationBlocked: SetDestinationBlocked, private val deleteDirectChats: DeleteDirectChats, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @param:ApplicationCoroutineScope + private val applicationScope: CoroutineScope, ) : BlockedParticipantsDelegate { private val _state = MutableStateFlow(State()) @@ -78,10 +79,13 @@ internal class BlockedParticipantsDelegateImpl @Inject constructor( } override fun unblock(normalizedDestination: String) { - setDestinationBlocked( - normalizedDestination = normalizedDestination, - blocked = false, - ) + applicationScope.launch { + repository.setDestinationBlocked( + destination = normalizedDestination, + conversationId = null, + isBlocked = false, + ) + } } override fun deleteSelectedChats() { diff --git a/src/com/android/messaging/ui/common/components/ParticipantAvatar.kt b/src/com/android/messaging/ui/common/components/ParticipantAvatar.kt deleted file mode 100644 index e69de29bb..000000000 From 099182b1b983eef8de1719c78395e635b39a3595 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 18 Jun 2026 13:19:23 +0200 Subject: [PATCH 12/39] Polish conversation list FABs, toolbar and corners --- .../redesign/ui/ConversationListContent.kt | 6 +- .../redesign/ui/ConversationListDialogs.kt | 46 +++++ .../redesign/ui/ConversationListScreen.kt | 190 +++++++++--------- .../ui/ConversationListSelectionTopAppBar.kt | 3 + .../redesign/ui/ConversationListTopAppBar.kt | 20 +- 5 files changed, 172 insertions(+), 93 deletions(-) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt index 310573f43..435fc928c 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action @@ -42,6 +43,7 @@ internal fun ConversationListContent( onAction: (Action) -> Unit, contentPadding: PaddingValues, modifier: Modifier = Modifier, + bottomReserve: Dp = 0.dp, ) { Box(modifier = modifier.fillMaxSize()) { when (content) { @@ -67,6 +69,7 @@ internal fun ConversationListContent( listState = listState, onAction = onAction, contentPadding = contentPadding, + bottomReserve = bottomReserve, ) } } @@ -79,6 +82,7 @@ private fun ConversationListItems( listState: LazyListState, onAction: (Action) -> Unit, contentPadding: PaddingValues, + bottomReserve: Dp, ) { LazyColumn( modifier = Modifier @@ -89,7 +93,7 @@ private fun ConversationListItems( start = ListContentPadding, end = ListContentPadding, top = ListContentPadding, - bottom = contentPadding.calculateBottomPadding() + ListContentPadding, + bottom = contentPadding.calculateBottomPadding() + ListContentPadding + bottomReserve, ), verticalArrangement = Arrangement.spacedBy(ListVerticalSpacing), ) { diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt index 6588b1525..fa2e28480 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt @@ -8,8 +8,54 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.messaging.R +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action import com.android.messaging.ui.core.MessagingPreviewTheme +@Composable +internal fun ConversationListDialogs( + selectedCount: Int, + addContactDestination: String?, + isDeleteVisible: Boolean, + blockDestination: String?, + onAction: (Action) -> Unit, + onDismissAddContact: () -> Unit, + onDismissDelete: () -> Unit, + onDismissBlock: () -> Unit, +) { + addContactDestination?.let { destination -> + ConversationListAddContactDialog( + destination = destination, + onConfirm = { + onDismissAddContact() + onAction(Action.AddContactConfirmed(destination)) + }, + onDismiss = onDismissAddContact, + ) + } + + if (isDeleteVisible) { + ConversationListDeleteDialog( + selectedCount = selectedCount, + onConfirm = { + onDismissDelete() + onAction(Action.DeleteConfirmed) + }, + onDismiss = onDismissDelete, + ) + } + + blockDestination?.let { destination -> + ConversationListBlockDialog( + destination = destination, + onConfirm = { + onDismissBlock() + onAction(Action.BlockConfirmed) + }, + onDismiss = onDismissBlock, + ) + } +} + @Composable internal fun ConversationListAddContactDialog( destination: String, diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt index 2eaf1ea90..7093ebb1e 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt @@ -7,21 +7,21 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material3.Button +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -42,6 +42,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -64,8 +66,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch private val FabSpacing = 16.dp -private val StartChatButtonHeight = 56.dp -private val StartChatIconSpacing = 8.dp +private val FabBottomReserve = 72.dp +private val FabShape = RoundedCornerShape(16.dp) +private val ContentCornerShape = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, +) @Composable internal fun ConversationListScreen( @@ -101,38 +107,16 @@ internal fun ConversationListScreen( modifier = modifier, ) - pendingAddContactDestination?.let { destination -> - ConversationListAddContactDialog( - destination = destination, - onConfirm = { - pendingAddContactDestination = null - screenModel.onAction(Action.AddContactConfirmed(destination)) - }, - onDismiss = { pendingAddContactDestination = null }, - ) - } - - if (pendingDelete) { - ConversationListDeleteDialog( - selectedCount = uiState.selection.selectedConversations.size, - onConfirm = { - pendingDelete = false - screenModel.onAction(Action.DeleteConfirmed) - }, - onDismiss = { pendingDelete = false }, - ) - } - - pendingBlockDestination?.let { destination -> - ConversationListBlockDialog( - destination = destination, - onConfirm = { - pendingBlockDestination = null - screenModel.onAction(Action.BlockConfirmed) - }, - onDismiss = { pendingBlockDestination = null }, - ) - } + ConversationListDialogs( + selectedCount = uiState.selection.selectedConversations.size, + addContactDestination = pendingAddContactDestination, + isDeleteVisible = pendingDelete, + blockDestination = pendingBlockDestination, + onAction = screenModel::onAction, + onDismissAddContact = { pendingAddContactDestination = null }, + onDismissDelete = { pendingDelete = false }, + onDismissBlock = { pendingBlockDestination = null }, + ) } @Composable @@ -283,6 +267,7 @@ private fun ConversationListScaffold( modifier: Modifier = Modifier, ) { val isSelectionMode = uiState.selection.isActive + val backdropColor = conversationListBackdropColor(isSelectionMode) BackHandler(enabled = isSelectionMode) { onAction(Action.SelectionCleared) @@ -295,6 +280,7 @@ private fun ConversationListScaffold( Scaffold( modifier = modifier, + containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { ConversationListTopBar( uiState = uiState, @@ -310,28 +296,49 @@ private fun ConversationListScaffold( Box( modifier = Modifier .fillMaxSize() - .padding(top = contentPadding.calculateTopPadding()), + .padding(top = contentPadding.calculateTopPadding()) + .background(backdropColor) + .clip(ContentCornerShape) + .background(MaterialTheme.colorScheme.background), ) { ConversationListContent( content = uiState.content, listState = listState, onAction = onAction, contentPadding = contentPadding, + bottomReserve = FabBottomReserve, + ) + + ScrollToTopFab( + visible = uiState.isScrollUpVisible, + onClick = onScrollToTop, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = FabSpacing), ) - ConversationListFabs( - isStartChatVisible = !isSelectionMode, - isScrollUpVisible = uiState.isScrollUpVisible, - onStartChatClick = { onAction(Action.StartChatClicked) }, - onScrollToTopClick = onScrollToTop, + StartChatFab( + visible = !isSelectionMode, + expanded = !uiState.isScrollUpVisible, + onClick = { onAction(Action.StartChatClicked) }, modifier = Modifier .align(Alignment.BottomEnd) + .windowInsetsPadding(WindowInsets.navigationBars) .padding(FabSpacing), ) } } } +@Composable +private fun conversationListBackdropColor(isSelectionMode: Boolean): Color { + return when { + isSelectionMode -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceContainer + } +} + @Composable private fun ConversationListTopBar( uiState: State, @@ -377,56 +384,57 @@ private fun ConversationListScrollReporter( } @Composable -private fun ConversationListFabs( - isStartChatVisible: Boolean, - isScrollUpVisible: Boolean, - onStartChatClick: () -> Unit, - onScrollToTopClick: () -> Unit, +private fun ScrollToTopFab( + visible: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Column( + AnimatedVisibility( modifier = modifier, - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(space = FabSpacing), + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), ) { - AnimatedVisibility( - visible = isScrollUpVisible, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), + SmallFloatingActionButton( + onClick = onClick, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.secondaryContainer, ) { - SmallFloatingActionButton( - onClick = onScrollToTopClick, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ) { - Icon( - imageVector = Icons.Rounded.ArrowUpward, - contentDescription = stringResource(R.string.conversation_list_scroll_to_top), - ) - } + Icon( + imageVector = Icons.Rounded.ArrowUpward, + contentDescription = stringResource(R.string.conversation_list_scroll_to_top), + ) } + } +} - AnimatedVisibility( - visible = isStartChatVisible, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - ) { - Button( - modifier = Modifier.height(StartChatButtonHeight), - onClick = onStartChatClick, - shape = MaterialTheme.shapes.small, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = null, - ) - Spacer(modifier = Modifier.size(StartChatIconSpacing)) - Text(text = stringResource(R.string.conversation_list_start_chat)) - } - } - } +@Composable +private fun StartChatFab( + visible: Boolean, + expanded: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + ExtendedFloatingActionButton( + onClick = onClick, + expanded = expanded, + shape = FabShape, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Chat, + contentDescription = stringResource(R.string.conversation_list_start_chat), + ) + }, + text = { + Text(text = stringResource(R.string.conversation_list_start_chat)) + }, + ) } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt index a4ecfd70e..ee6f55fa3 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.messaging.R import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action @@ -66,6 +67,8 @@ private fun ConversationListSelectionTitle(selectedCount: Int) { count = selectedCount, selectedCount, ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt index bc9ef1475..fa839e8cc 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.conversationlist.redesign.ui +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.DropdownMenu @@ -7,8 +9,10 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -16,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.messaging.R import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action @@ -31,8 +36,21 @@ internal fun ConversationListTopAppBar( ) { TopAppBar( modifier = modifier, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), title = { - Text(text = stringResource(R.string.app_name)) + Text( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + onAction(Action.ScrollUpClicked) + }, + text = stringResource(R.string.app_name), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) }, actions = { ConversationListOverflowMenu( From 7e64becefa093d068ea85bcfbb3b97fbd331f07c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 18 Jun 2026 14:20:28 +0200 Subject: [PATCH 13/39] Extract shared primary button and snackbar helpers --- .../common/components/PrimaryActionButton.kt | 133 +++++++++++++++++ .../ui/common/components/SnackbarActions.kt | 19 +++ .../screen/ConversationScreenEffects.kt | 6 +- .../redesign/ui/ConversationListScreen.kt | 78 ++++------ .../RecipientSelectionContactsContent.kt | 6 +- .../RecipientSelectionPrimaryActionButton.kt | 139 ------------------ 6 files changed, 186 insertions(+), 195 deletions(-) create mode 100644 src/com/android/messaging/ui/common/components/PrimaryActionButton.kt create mode 100644 src/com/android/messaging/ui/common/components/SnackbarActions.kt delete mode 100644 src/com/android/messaging/ui/recipientselection/component/RecipientSelectionPrimaryActionButton.kt diff --git a/src/com/android/messaging/ui/common/components/PrimaryActionButton.kt b/src/com/android/messaging/ui/common/components/PrimaryActionButton.kt new file mode 100644 index 000000000..a578cff0d --- /dev/null +++ b/src/com/android/messaging/ui/common/components/PrimaryActionButton.kt @@ -0,0 +1,133 @@ +package com.android.messaging.ui.common.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.automirrored.rounded.Chat +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.core.MessagingPreviewColumn + +private val PrimaryActionButtonShape = RoundedCornerShape(size = 16.dp) + +private val IconLabelSpacing = 8.dp + +private val LoadingIndicatorSize = 24.dp + +private const val DISABLED_CONTAINER_ALPHA = 0.1f + +private const val DISABLED_CONTENT_ALPHA = 0.4f + +@Composable +internal fun PrimaryActionButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + expanded: Boolean = true, + enabled: Boolean = true, + isLoading: Boolean = false, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, + testTag: String? = null, + shape: Shape = PrimaryActionButtonShape, +) { + val colorScheme = MaterialTheme.colorScheme + val containerColor = when { + enabled -> colorScheme.primaryContainer + else -> colorScheme.onSurface.copy(alpha = DISABLED_CONTAINER_ALPHA) + } + val contentColor = when { + enabled -> colorScheme.onPrimaryContainer + else -> colorScheme.onSurface.copy(alpha = DISABLED_CONTENT_ALPHA) + } + + ExtendedFloatingActionButton( + modifier = modifier.optionalTestTag(testTag), + onClick = { + if (enabled && !isLoading) { + onClick() + } + }, + expanded = expanded, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + icon = { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(size = LoadingIndicatorSize), + color = contentColor, + strokeWidth = 2.dp, + ) + } + + leadingIcon != null -> { + Icon( + imageVector = leadingIcon, + contentDescription = if (expanded) null else text, + ) + } + } + }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = text) + + trailingIcon?.let { icon -> + Spacer(modifier = Modifier.size(size = IconLabelSpacing)) + + Icon( + imageVector = icon, + contentDescription = null, + ) + } + } + }, + ) +} + +@PreviewLightDark +@Composable +private fun PrimaryActionButtonPreview() { + MessagingPreviewColumn { + Row(verticalAlignment = Alignment.CenterVertically) { + PrimaryActionButton( + text = "Start chat", + onClick = {}, + leadingIcon = Icons.AutoMirrored.Rounded.Chat, + ) + + Spacer(modifier = Modifier.size(size = 12.dp)) + + PrimaryActionButton( + text = "Start chat", + onClick = {}, + isLoading = true, + expanded = false, + leadingIcon = Icons.AutoMirrored.Rounded.Chat, + ) + + Spacer(modifier = Modifier.size(size = 12.dp)) + + PrimaryActionButton( + text = "Start chat", + onClick = {}, + trailingIcon = Icons.AutoMirrored.Rounded.ArrowForward, + ) + } + } +} diff --git a/src/com/android/messaging/ui/common/components/SnackbarActions.kt b/src/com/android/messaging/ui/common/components/SnackbarActions.kt new file mode 100644 index 000000000..dbbd963dc --- /dev/null +++ b/src/com/android/messaging/ui/common/components/SnackbarActions.kt @@ -0,0 +1,19 @@ +package com.android.messaging.ui.common.components + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult + +internal suspend fun SnackbarHostState.showActionSnackbar( + message: String, + actionLabel: String, + duration: SnackbarDuration = SnackbarDuration.Short, +): Boolean { + val result = showSnackbar( + message = message, + actionLabel = actionLabel, + duration = duration, + ) + + return result == SnackbarResult.ActionPerformed +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt index 8d025ba2d..b64a1b67b 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State @@ -31,6 +30,7 @@ import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.common.components.showActionSnackbar import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import com.android.messaging.util.BuglePrefs import com.android.messaging.util.ContactUtil @@ -234,13 +234,13 @@ private suspend fun requestDefaultSmsRole( else -> R.string.requires_default_sms_app } - val snackbarResult = snackbarHostState.showSnackbar( + val actionClicked = snackbarHostState.showActionSnackbar( message = context.getString(messageResId), actionLabel = context.getString(R.string.requires_default_sms_change_button), duration = SnackbarDuration.Indefinite, ) - if (snackbarResult == SnackbarResult.ActionPerformed) { + if (actionClicked) { onActionClick() } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt index 7093ebb1e..47c112919 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt @@ -21,15 +21,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -53,6 +50,8 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.android.messaging.R +import com.android.messaging.ui.common.components.PrimaryActionButton +import com.android.messaging.ui.common.components.showActionSnackbar import com.android.messaging.ui.conversationlist.redesign.ConversationListEffectHandler import com.android.messaging.ui.conversationlist.redesign.ConversationListScreenModel import com.android.messaging.ui.conversationlist.redesign.ConversationListViewModel @@ -67,7 +66,6 @@ import kotlinx.coroutines.launch private val FabSpacing = 16.dp private val FabBottomReserve = 72.dp -private val FabShape = RoundedCornerShape(16.dp) private val ContentCornerShape = RoundedCornerShape( topStart = 28.dp, topEnd = 28.dp, @@ -196,19 +194,19 @@ private fun CoroutineScope.launchArchivedSnackbar( } launch { - showUndoableSnackbar( - snackbarHostState = snackbarHostState, + val undoClicked = snackbarHostState.showActionSnackbar( message = context.getString(messageResId, effect.count), - undoLabel = undoLabel, - onUndo = { - onAction( - Action.ArchiveUndoClicked( - conversationIds = effect.conversationIds, - isArchived = effect.isArchived, - ), - ) - }, + actionLabel = undoLabel, ) + + if (undoClicked) { + onAction( + Action.ArchiveUndoClicked( + conversationIds = effect.conversationIds, + isArchived = effect.isArchived, + ), + ) + } } } @@ -224,35 +222,19 @@ private fun CoroutineScope.launchBlockedSnackbar( } launch { - showUndoableSnackbar( - snackbarHostState = snackbarHostState, + val undoClicked = snackbarHostState.showActionSnackbar( message = context.getString(R.string.update_destination_blocked), - undoLabel = undoLabel, - onUndo = { - onAction( - Action.BlockUndoClicked( - conversationId = effect.conversationId, - destination = effect.destination, - ), - ) - }, + actionLabel = undoLabel, ) - } -} - -private suspend fun showUndoableSnackbar( - snackbarHostState: SnackbarHostState, - message: String, - undoLabel: String, - onUndo: () -> Unit, -) { - val snackbarResult = snackbarHostState.showSnackbar( - message = message, - actionLabel = undoLabel, - ) - if (snackbarResult == SnackbarResult.ActionPerformed) { - onUndo() + if (undoClicked) { + onAction( + Action.BlockUndoClicked( + conversationId = effect.conversationId, + destination = effect.destination, + ), + ) + } } } @@ -421,19 +403,11 @@ private fun StartChatFab( enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut(), ) { - ExtendedFloatingActionButton( + PrimaryActionButton( + text = stringResource(R.string.conversation_list_start_chat), onClick = onClick, expanded = expanded, - shape = FabShape, - icon = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Chat, - contentDescription = stringResource(R.string.conversation_list_start_chat), - ) - }, - text = { - Text(text = stringResource(R.string.conversation_list_start_chat)) - }, + leadingIcon = Icons.AutoMirrored.Rounded.Chat, ) } } diff --git a/src/com/android/messaging/ui/recipientselection/component/RecipientSelectionContactsContent.kt b/src/com/android/messaging/ui/recipientselection/component/RecipientSelectionContactsContent.kt index f2fed1886..83e85ede7 100644 --- a/src/com/android/messaging/ui/recipientselection/component/RecipientSelectionContactsContent.kt +++ b/src/com/android/messaging/ui/recipientselection/component/RecipientSelectionContactsContent.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -32,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import com.android.messaging.ui.common.components.PrimaryActionButton import com.android.messaging.ui.common.components.selection.SelectionListContent import com.android.messaging.ui.core.MessagingPreviewColumn import com.android.messaging.ui.recipientselection.model.section.RecipientContactListEntry @@ -87,13 +90,14 @@ internal fun RecipientSelectionContactsContent( floatingActionEnterTransition = recipientSelectionPrimaryActionEnterTransition(), floatingActionExitTransition = recipientSelectionPrimaryActionExitTransition(), floatingActionContent = { - RecipientSelectionPrimaryActionButton( + PrimaryActionButton( modifier = Modifier .navigationBarsPadding() .padding(end = 8.dp, bottom = 8.dp), enabled = primaryAction?.isEnabled ?: false, isLoading = primaryAction?.isLoading ?: false, text = primaryAction?.text.orEmpty(), + trailingIcon = Icons.AutoMirrored.Rounded.ArrowForward, testTag = primaryAction?.testTag, onClick = onPrimaryActionClick, ) diff --git a/src/com/android/messaging/ui/recipientselection/component/RecipientSelectionPrimaryActionButton.kt b/src/com/android/messaging/ui/recipientselection/component/RecipientSelectionPrimaryActionButton.kt deleted file mode 100644 index c14642798..000000000 --- a/src/com/android/messaging/ui/recipientselection/component/RecipientSelectionPrimaryActionButton.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.android.messaging.ui.recipientselection.component - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowForward -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp -import com.android.messaging.ui.common.components.optionalTestTag -import com.android.messaging.ui.core.MessagingPreviewColumn - -@Composable -internal fun RecipientSelectionPrimaryActionButton( - enabled: Boolean, - isLoading: Boolean, - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - testTag: String? = null, -) { - Button( - modifier = modifier - .optionalTestTag(testTag) - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - ), - onClick = onClick, - enabled = enabled, - shape = MaterialTheme.shapes.small, - ) { - AnimatedContent( - targetState = isLoading, - transitionSpec = { - recipientSelectionPrimaryActionContentTransform() - }, - label = "recipientSelectionPrimaryActionButtonContent", - ) { isButtonLoading -> - when { - isButtonLoading -> { - CircularProgressIndicator( - modifier = Modifier.size(size = 18.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - } - - else -> { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = text) - - Spacer(modifier = Modifier.size(size = 8.dp)) - - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowForward, - contentDescription = null, - ) - } - } - } - } - } -} - -private fun recipientSelectionPrimaryActionContentTransform(): ContentTransform { - return ( - fadeIn( - animationSpec = tween(durationMillis = 200), - ) + scaleIn( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - initialScale = 0.9f, - ) - ).togetherWith( - fadeOut( - animationSpec = tween(durationMillis = 150), - ) + scaleOut( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - targetScale = 0.9f, - ), - ) -} - -@PreviewLightDark -@Composable -private fun RecipientSelectionPrimaryActionButtonPreview() { - MessagingPreviewColumn { - Row(horizontalArrangement = Arrangement.spacedBy(space = 12.dp)) { - RecipientSelectionPrimaryActionButton( - enabled = true, - isLoading = false, - text = "Start chat", - onClick = {}, - ) - RecipientSelectionPrimaryActionButton( - enabled = false, - isLoading = false, - text = "Start chat", - onClick = {}, - ) - RecipientSelectionPrimaryActionButton( - enabled = true, - isLoading = true, - text = "Start chat", - onClick = {}, - ) - } - } -} From bb48b90b53706a2fbbb603030a1ed8d8b27076b4 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Fri, 19 Jun 2026 15:26:19 +0200 Subject: [PATCH 14/39] Move ConversationListItemAvatar to separate file --- res/values/strings.xml | 4 ++ .../ConversationListActivity.kt | 8 +-- .../ConversationListActivityEffectHandler.kt | 6 +- .../mapper/ConversationListUiStateMapper.kt | 20 ++++--- .../redesign/ui/ConversationListContent.kt | 5 ++ .../redesign/ui/ConversationListItemAvatar.kt | 49 ++++++++++++++++ .../redesign/ui/ConversationListItemRow.kt | 56 ++++--------------- .../redesign/ui/ConversationListScreen.kt | 51 +++++++++++------ .../redesign/ui/ConversationListTokens.kt | 2 +- 9 files changed, 123 insertions(+), 78 deletions(-) create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index e52bc2c0c..37952cf22 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -761,6 +761,10 @@ Start chat Scroll to top + + Not sent + + Not downloaded Picture diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt index b999e3bf4..f95297df6 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt @@ -18,12 +18,12 @@ class ConversationListActivity : ComponentActivity() { enableEdgeToEdge() - val effectHandler = ConversationListActivityEffectHandler( - activity = this, - ) - setContent { AppTheme { + val effectHandler = ConversationListActivityEffectHandler( + activity = this, + ) + ConversationListScreen( effectHandler = effectHandler, modifier = Modifier.fillMaxSize(), diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt index dd78ce4fb..f6d950981 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt @@ -1,13 +1,13 @@ package com.android.messaging.ui.conversationlist -import androidx.activity.ComponentActivity +import android.app.Activity import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversationlist.redesign.ConversationListEffectHandler import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect import com.android.messaging.util.DebugUtils internal class ConversationListActivityEffectHandler( - private val activity: ComponentActivity, + private val activity: Activity, ) : ConversationListEffectHandler { override fun handle(effect: Effect) { @@ -15,7 +15,7 @@ internal class ConversationListActivityEffectHandler( Effect.StartChat -> { UIIntents.get().launchCreateNewConversationActivity( activity, - null + null, ) } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index 8a3823f86..e1786fa4b 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -88,13 +88,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : return ConversationListItemUiModel( conversationId = item.conversationId, title = item.title, - avatar = ConversationListAvatarUiModel( - uri = resolveAvatarUri(item.icon), - contactId = item.participant.contactId, - lookupKey = item.participant.lookupKey, - normalizedDestination = item.participant.otherNormalizedDestination, - isGroup = item.participant.isGroup, - ), + avatar = item.toAvatar(), snippet = ConversationListSnippetUiModel( text = item.activeSnippetText(), senderName = item.latestMessage.senderName, @@ -114,6 +108,18 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : ) } + private fun ConversationListItem.toAvatar(): ConversationListAvatarUiModel { + val destination = participant.otherNormalizedDestination?.takeIf(String::isNotBlank) + + return ConversationListAvatarUiModel( + uri = resolveAvatarUri(icon), + contactId = participant.contactId, + lookupKey = participant.lookupKey, + normalizedDestination = destination, + isGroup = participant.isGroup, + ) + } + private fun mapSelectionState( items: List, selectedConversationIds: ImmutableSet, diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt index 435fc928c..780c723bc 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt @@ -43,6 +43,7 @@ internal fun ConversationListContent( onAction: (Action) -> Unit, contentPadding: PaddingValues, modifier: Modifier = Modifier, + isSelectionMode: Boolean = false, bottomReserve: Dp = 0.dp, ) { Box(modifier = modifier.fillMaxSize()) { @@ -69,6 +70,7 @@ internal fun ConversationListContent( listState = listState, onAction = onAction, contentPadding = contentPadding, + isSelectionMode = isSelectionMode, bottomReserve = bottomReserve, ) } @@ -82,6 +84,7 @@ private fun ConversationListItems( listState: LazyListState, onAction: (Action) -> Unit, contentPadding: PaddingValues, + isSelectionMode: Boolean, bottomReserve: Dp, ) { LazyColumn( @@ -110,6 +113,8 @@ private fun ConversationListItems( onLongClick = { onAction(Action.ConversationLongClicked(item.conversationId)) }, + modifier = Modifier.animateItem(), + isSelectionMode = isSelectionMode, ) } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt new file mode 100644 index 000000000..ec16a574e --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt @@ -0,0 +1,49 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.android.messaging.ui.common.components.participant.ParticipantAvatar +import com.android.messaging.ui.common.components.participant.participantAvatarLabel +import com.android.messaging.ui.common.components.participant.participantColorSeed +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel + +@Composable +internal fun ConversationListItemAvatar( + item: ConversationListItemUiModel, + isSelectionMode: Boolean, + onToggleSelection: () -> Unit, +) { + val fallbackIcon = when { + item.avatar.isGroup -> Icons.Default.Group + else -> Icons.Default.Person + } + + Box(modifier = Modifier.size(ItemAvatarSize)) { + ParticipantAvatar( + avatarUri = item.avatar.uri, + size = ItemAvatarSize, + fallbackLabel = participantAvatarLabel(source = item.title), + colorSeedCode = participantColorSeed( + normalizedDestination = item.avatar.normalizedDestination, + ), + fallbackSize = ItemAvatarFallbackSize, + fallbackIcon = fallbackIcon, + isSelected = item.isSelected, + modifier = Modifier + .clip(CircleShape) + .clickable { + if (isSelectionMode) { + onToggleSelection() + } + }, + ) + } +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt index 0a388f054..45bd4239a 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt @@ -8,9 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.NotificationsOff -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Work import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,13 +30,8 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus -import com.android.messaging.datamodel.data.MessageData -import com.android.messaging.sms.MmsUtils import com.android.messaging.ui.common.components.TwoLineListItem import com.android.messaging.ui.common.components.attachment.MediaThumbnail -import com.android.messaging.ui.common.components.participant.ParticipantAvatar -import com.android.messaging.ui.common.components.participant.participantAvatarLabel -import com.android.messaging.ui.common.components.participant.participantColorSeed import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel import com.android.messaging.ui.core.MessagingPreviewColumn @@ -50,11 +43,16 @@ internal fun ConversationListItemRow( onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, + isSelectionMode: Boolean = false, ) { TwoLineListItem( onClick = onClick, leadingContent = { - ConversationListItemAvatar(item) + ConversationListItemAvatar( + item = item, + isSelectionMode = isSelectionMode, + onToggleSelection = onClick, + ) }, titleContent = { ConversationListItemHeader(item) @@ -73,26 +71,6 @@ internal fun ConversationListItemRow( ) } -@Composable -private fun ConversationListItemAvatar(item: ConversationListItemUiModel) { - val fallbackIcon = when { - item.avatar.isGroup -> Icons.Default.Group - else -> Icons.Default.Person - } - - ParticipantAvatar( - avatarUri = item.avatar.uri, - size = ItemAvatarSize, - fallbackLabel = participantAvatarLabel(source = item.title), - colorSeedCode = participantColorSeed( - normalizedDestination = item.avatar.normalizedDestination, - ), - fallbackSize = ItemAvatarFallbackSize, - fallbackIcon = fallbackIcon, - isSelected = item.isSelected, - ) -} - @Composable private fun ConversationListItemHeader(item: ConversationListItemUiModel) { Row( @@ -221,12 +199,7 @@ private fun ConversationListItemStatusLabel(item: ConversationListItemUiModel) { } is ConversationListMessageStatus.Failed -> { - stringResource( - itemFailedStatusResId( - item = item, - status = status, - ), - ) + stringResource(itemFailedStatusResId(item)) } ConversationListMessageStatus.Normal -> { @@ -250,19 +223,10 @@ private fun ConversationListItemStatusLabel(item: ConversationListItemUiModel) { ) } -private fun itemFailedStatusResId( - item: ConversationListItemUiModel, - status: ConversationListMessageStatus.Failed, -): Int { +private fun itemFailedStatusResId(item: ConversationListItemUiModel): Int { return when { - item.isOutgoing -> { - MmsUtils.mapRawStatusToErrorResourceId( - MessageData.BUGLE_STATUS_OUTGOING_FAILED, - status.rawTelephonyStatus, - ) - } - - else -> R.string.message_status_download_failed + item.isOutgoing -> R.string.conversation_list_status_not_sent + else -> R.string.conversation_list_status_not_downloaded } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt index 47c112919..2a353f135 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBars @@ -288,31 +289,47 @@ private fun ConversationListScaffold( listState = listState, onAction = onAction, contentPadding = contentPadding, + isSelectionMode = isSelectionMode, bottomReserve = FabBottomReserve, ) - ScrollToTopFab( - visible = uiState.isScrollUpVisible, - onClick = onScrollToTop, - modifier = Modifier - .align(Alignment.BottomCenter) - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(bottom = FabSpacing), - ) - - StartChatFab( - visible = !isSelectionMode, - expanded = !uiState.isScrollUpVisible, - onClick = { onAction(Action.StartChatClicked) }, - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(FabSpacing), + ConversationListFabs( + uiState = uiState, + isSelectionMode = isSelectionMode, + onAction = onAction, + onScrollToTop = onScrollToTop, ) } } } +@Composable +private fun BoxScope.ConversationListFabs( + uiState: State, + isSelectionMode: Boolean, + onAction: (Action) -> Unit, + onScrollToTop: () -> Unit, +) { + ScrollToTopFab( + visible = uiState.isScrollUpVisible, + onClick = onScrollToTop, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = FabSpacing), + ) + + StartChatFab( + visible = !isSelectionMode, + expanded = !uiState.isScrollUpVisible, + onClick = { onAction(Action.StartChatClicked) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(FabSpacing), + ) +} + @Composable private fun conversationListBackdropColor(isSelectionMode: Boolean): Color { return when { diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt index bcdff6095..e3357ab2b 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt @@ -11,7 +11,7 @@ internal val ItemHeaderSpacing = 8.dp internal val ItemBadgeIconSize = 16.dp -internal val ItemUnreadDotSize = 8.dp +internal val ItemUnreadDotSize = 12.dp internal val ItemPreviewThumbnailSize = 44.dp From 23ec0b5a78b14898f2da7008e8a1d48eba20960c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Fri, 19 Jun 2026 21:43:50 +0200 Subject: [PATCH 15/39] Add avatar quick actions to conversation list --- .../model/ConversationSettingsData.kt | 1 - .../ConversationSettingsRepository.kt | 3 - .../conversation/ConversationBindsModule.kt | 40 ++++++ .../usecase/avatar/ResolveAvatarUri.kt | 22 +++ .../usecase/participant/CanAddContact.kt | 26 ++++ .../participant/CanShowOrAddContact.kt | 37 +++++ .../usecase/participant/IsContactSaved.kt | 17 +++ .../usecase/telephony/CanPlacePhoneCall.kt | 22 +++ .../common/BlockedParticipantItem.kt | 1 + .../screen/BlockedParticipantsScreen.kt | 4 +- .../BlockedParticipantsUiStateMapper.kt | 29 ++-- .../model/BlockedParticipantsUiState.kt | 1 + .../screen/ConversationViewModel.kt | 28 ++-- .../ConversationListActivity.kt | 12 +- .../ConversationListActivityEffectHandler.kt | 23 ++++ .../redesign/ConversationListViewModel.kt | 24 ++++ .../mapper/ConversationListUiStateMapper.kt | 129 ++++++++++-------- .../redesign/model/ConversationListAction.kt | 12 ++ .../redesign/model/ConversationListEffect.kt | 11 ++ .../model/ConversationListItemUiModel.kt | 4 + .../redesign/ui/ConversationListContent.kt | 13 ++ .../redesign/ui/ConversationListItemAvatar.kt | 57 ++++++++ .../redesign/ui/ConversationListItemRow.kt | 6 + .../ui/ConversationListPreviewSupport.kt | 8 +- .../mapper/TargetUiStateMapper.kt | 14 +- .../ConversationSettingsUiStateMapper.kt | 49 ++++--- 26 files changed, 465 insertions(+), 128 deletions(-) create mode 100644 src/com/android/messaging/domain/conversation/usecase/avatar/ResolveAvatarUri.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/participant/CanAddContact.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/participant/CanShowOrAddContact.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/participant/IsContactSaved.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/telephony/CanPlacePhoneCall.kt diff --git a/src/com/android/messaging/data/conversationsettings/model/ConversationSettingsData.kt b/src/com/android/messaging/data/conversationsettings/model/ConversationSettingsData.kt index c5a1491ed..3255553df 100644 --- a/src/com/android/messaging/data/conversationsettings/model/ConversationSettingsData.kt +++ b/src/com/android/messaging/data/conversationsettings/model/ConversationSettingsData.kt @@ -9,7 +9,6 @@ internal data class ConversationSettingsData( val conversationTitle: String = "", val isArchived: Boolean = false, val isSnoozed: Boolean = false, - val isVoiceCapable: Boolean = false, val participants: ImmutableList = persistentListOf(), val dbSelfParticipantId: String = "", ) diff --git a/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt b/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt index 397542ddd..aaa7bc151 100644 --- a/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt +++ b/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt @@ -11,7 +11,6 @@ import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.ConversationParticipantsData import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.di.core.MessagingDbDispatcher -import com.android.messaging.util.PhoneUtils import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher @@ -80,7 +79,6 @@ internal class ConversationSettingsRepositoryImpl @Inject constructor( private suspend fun loadConversationSettings( conversationId: String, ): ConversationSettingsData { - val phoneUtils = PhoneUtils.getDefault() val participants = queryOtherParticipants(conversationId) val metadata = conversationsRepository.getConversationMetadataSnapshot( conversationId = conversationId, @@ -91,7 +89,6 @@ internal class ConversationSettingsRepositoryImpl @Inject constructor( conversationTitle = metadata?.conversationName.orEmpty(), isArchived = metadata?.isArchived ?: false, isSnoozed = notificationRepository.isSnoozed(conversationId), - isVoiceCapable = phoneUtils.isVoiceCapable, participants = participants.toImmutableList(), dbSelfParticipantId = metadata?.selfParticipantId.orEmpty(), ) diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index ecdec69af..9b8688009 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -40,6 +40,8 @@ import com.android.messaging.domain.conversation.usecase.action.CheckConversatio import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirementsImpl import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequestImpl +import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri +import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUriImpl import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocol import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocolImpl import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft @@ -48,12 +50,20 @@ import com.android.messaging.domain.conversation.usecase.forward.CreateForwarded import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessageImpl import com.android.messaging.domain.conversation.usecase.forward.ForwardedMessageSubjectFormatter import com.android.messaging.domain.conversation.usecase.forward.ForwardedMessageSubjectFormatterImpl +import com.android.messaging.domain.conversation.usecase.participant.CanAddContact +import com.android.messaging.domain.conversation.usecase.participant.CanAddContactImpl import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipantsImpl +import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact +import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContactImpl +import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved +import com.android.messaging.domain.conversation.usecase.participant.IsContactSavedImpl import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceededImpl import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationIdImpl +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCallImpl import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapableImpl import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber @@ -139,6 +149,18 @@ internal abstract class ConversationBindsModule { impl: CanAddMoreConversationParticipantsImpl, ): CanAddMoreConversationParticipants + @Binds + @Reusable + abstract fun bindCanAddContact( + impl: CanAddContactImpl, + ): CanAddContact + + @Binds + @Reusable + abstract fun bindCanShowOrAddContact( + impl: CanShowOrAddContactImpl, + ): CanShowOrAddContact + @Binds @Reusable abstract fun bindContactDestinationFormatter( @@ -181,6 +203,24 @@ internal abstract class ConversationBindsModule { impl: IsEmergencyPhoneNumberImpl, ): IsEmergencyPhoneNumber + @Binds + @Reusable + abstract fun bindCanPlacePhoneCall( + impl: CanPlacePhoneCallImpl, + ): CanPlacePhoneCall + + @Binds + @Reusable + abstract fun bindIsContactSaved( + impl: IsContactSavedImpl, + ): IsContactSaved + + @Binds + @Reusable + abstract fun bindResolveAvatarUri( + impl: ResolveAvatarUriImpl, + ): ResolveAvatarUri + @Binds @Reusable abstract fun bindCreateForwardedMessage( diff --git a/src/com/android/messaging/domain/conversation/usecase/avatar/ResolveAvatarUri.kt b/src/com/android/messaging/domain/conversation/usecase/avatar/ResolveAvatarUri.kt new file mode 100644 index 000000000..d5e433480 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/avatar/ResolveAvatarUri.kt @@ -0,0 +1,22 @@ +package com.android.messaging.domain.conversation.usecase.avatar + +import androidx.core.net.toUri +import com.android.messaging.util.AvatarUriUtil +import javax.inject.Inject + +internal fun interface ResolveAvatarUri { + operator fun invoke(icon: String?): String? +} + +internal class ResolveAvatarUriImpl @Inject constructor() : ResolveAvatarUri { + + override operator fun invoke(icon: String?): String? { + val iconUriString = icon?.takeIf(String::isNotBlank) ?: return null + val iconUri = iconUriString.toUri() + + return when { + AvatarUriUtil.isAvatarUri(iconUri) -> AvatarUriUtil.getPrimaryUri(iconUri)?.toString() + else -> iconUriString + } + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/participant/CanAddContact.kt b/src/com/android/messaging/domain/conversation/usecase/participant/CanAddContact.kt new file mode 100644 index 000000000..a29fdfd7b --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/participant/CanAddContact.kt @@ -0,0 +1,26 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import com.android.messaging.datamodel.data.ParticipantData +import javax.inject.Inject + +internal fun interface CanAddContact { + operator fun invoke( + isGroup: Boolean, + lookupKey: String?, + destination: String?, + ): Boolean +} + +internal class CanAddContactImpl @Inject constructor() : CanAddContact { + + override operator fun invoke( + isGroup: Boolean, + lookupKey: String?, + destination: String?, + ): Boolean { + return !isGroup && + lookupKey.isNullOrBlank() && + !destination.isNullOrBlank() && + destination != ParticipantData.getUnknownSenderDestination() + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/participant/CanShowOrAddContact.kt b/src/com/android/messaging/domain/conversation/usecase/participant/CanShowOrAddContact.kt new file mode 100644 index 000000000..8d62b099a --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/participant/CanShowOrAddContact.kt @@ -0,0 +1,37 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import javax.inject.Inject + +internal fun interface CanShowOrAddContact { + operator fun invoke( + isGroup: Boolean, + contactId: Long, + lookupKey: String?, + destination: String?, + ): Boolean +} + +internal class CanShowOrAddContactImpl @Inject constructor( + private val canAddContact: CanAddContact, + private val isContactSaved: IsContactSaved, +) : CanShowOrAddContact { + + override operator fun invoke( + isGroup: Boolean, + contactId: Long, + lookupKey: String?, + destination: String?, + ): Boolean { + val isContactSaved = isContactSaved( + contactId = contactId, + lookupKey = lookupKey, + ) + val canAddContact = canAddContact( + isGroup = isGroup, + lookupKey = lookupKey, + destination = destination, + ) + + return !isGroup && (isContactSaved || canAddContact) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/participant/IsContactSaved.kt b/src/com/android/messaging/domain/conversation/usecase/participant/IsContactSaved.kt new file mode 100644 index 000000000..86de705b7 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/participant/IsContactSaved.kt @@ -0,0 +1,17 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import javax.inject.Inject + +internal fun interface IsContactSaved { + operator fun invoke(contactId: Long, lookupKey: String?): Boolean +} + +internal class IsContactSavedImpl @Inject constructor() : IsContactSaved { + + override operator fun invoke( + contactId: Long, + lookupKey: String?, + ): Boolean { + return contactId > 0 && !lookupKey.isNullOrBlank() + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/telephony/CanPlacePhoneCall.kt b/src/com/android/messaging/domain/conversation/usecase/telephony/CanPlacePhoneCall.kt new file mode 100644 index 000000000..56f4279c4 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/telephony/CanPlacePhoneCall.kt @@ -0,0 +1,22 @@ +package com.android.messaging.domain.conversation.usecase.telephony + +import android.telephony.PhoneNumberUtils +import javax.inject.Inject + +internal fun interface CanPlacePhoneCall { + operator fun invoke(destination: String?): Boolean +} + +internal class CanPlacePhoneCallImpl @Inject constructor( + private val isDeviceVoiceCapable: IsDeviceVoiceCapable, + private val isEmergencyPhoneNumber: IsEmergencyPhoneNumber, +) : CanPlacePhoneCall { + + override operator fun invoke(destination: String?): Boolean { + val phoneNumber = destination?.takeIf(String::isNotBlank) ?: return false + + return isDeviceVoiceCapable() && + PhoneNumberUtils.isWellFormedSmsAddress(phoneNumber) && + !isEmergencyPhoneNumber(phoneNumber) + } +} diff --git a/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt b/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt index c254b5ebb..bd9bf27b6 100644 --- a/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt +++ b/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt @@ -219,6 +219,7 @@ private fun BlockedParticipantItemPreview() { lookupKey = null, normalizedDestination = "+31612345678", canCall = true, + canShowContact = true, isContactSaved = true, ), isSelected = false, diff --git a/src/com/android/messaging/ui/blockedparticipants/screen/BlockedParticipantsScreen.kt b/src/com/android/messaging/ui/blockedparticipants/screen/BlockedParticipantsScreen.kt index 3caa35f4c..30ed3a960 100644 --- a/src/com/android/messaging/ui/blockedparticipants/screen/BlockedParticipantsScreen.kt +++ b/src/com/android/messaging/ui/blockedparticipants/screen/BlockedParticipantsScreen.kt @@ -201,7 +201,7 @@ private fun BlockedParticipantsList( }.takeIf { participant.canCall }, onContactClick = { onAction(Action.ParticipantContactInfoClicked(participant)) - }.takeIf { hasDestination }, + }.takeIf { participant.canShowContact }, ) } } @@ -257,6 +257,7 @@ private fun BlockedParticipantsContentPreview() { lookupKey = null, normalizedDestination = "+31612345678", canCall = true, + canShowContact = true, isContactSaved = true, ), BlockedParticipantUiState( @@ -269,6 +270,7 @@ private fun BlockedParticipantsContentPreview() { lookupKey = null, normalizedDestination = "+31600001111", canCall = true, + canShowContact = true, isContactSaved = false, ), ), diff --git a/src/com/android/messaging/ui/blockedparticipants/screen/mapper/BlockedParticipantsUiStateMapper.kt b/src/com/android/messaging/ui/blockedparticipants/screen/mapper/BlockedParticipantsUiStateMapper.kt index d4ec182a2..f551a62f1 100644 --- a/src/com/android/messaging/ui/blockedparticipants/screen/mapper/BlockedParticipantsUiStateMapper.kt +++ b/src/com/android/messaging/ui/blockedparticipants/screen/mapper/BlockedParticipantsUiStateMapper.kt @@ -1,11 +1,12 @@ package com.android.messaging.ui.blockedparticipants.screen.mapper -import android.telephony.PhoneNumberUtils import androidx.core.text.BidiFormatter import androidx.core.text.TextDirectionHeuristicsCompat.LTR import com.android.messaging.data.blockedparticipants.model.BlockedDirectChat +import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact +import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall import com.android.messaging.ui.blockedparticipants.screen.model.BlockedParticipantUiState -import com.android.messaging.util.PhoneUtils import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -16,8 +17,11 @@ internal interface BlockedParticipantsUiStateMapper { ): ImmutableList } -internal class BlockedParticipantsUiStateMapperImpl @Inject constructor() : - BlockedParticipantsUiStateMapper { +internal class BlockedParticipantsUiStateMapperImpl @Inject constructor( + private val canPlacePhoneCall: CanPlacePhoneCall, + private val canShowOrAddContact: CanShowOrAddContact, + private val isContactSavedUseCase: IsContactSaved, +) : BlockedParticipantsUiStateMapper { override fun map( chats: ImmutableList, @@ -40,11 +44,19 @@ internal class BlockedParticipantsUiStateMapperImpl @Inject constructor() : contactName != null && !participant.isUnknownSender -> sendDestination else -> null } + val normalizedDestination = participant.normalizedDestination - val canCall = !normalizedDestination.isNullOrBlank() && - PhoneNumberUtils.isWellFormedSmsAddress(normalizedDestination) && - PhoneUtils.getDefault().isVoiceCapable - val isContactSaved = participant.contactId > 0 && !participant.lookupKey.isNullOrBlank() + val canCall = canPlacePhoneCall(normalizedDestination) + val canShowContact = canShowOrAddContact( + isGroup = false, + contactId = participant.contactId, + lookupKey = participant.lookupKey, + destination = normalizedDestination, + ) + val isContactSaved = isContactSavedUseCase( + contactId = participant.contactId, + lookupKey = participant.lookupKey, + ) return BlockedParticipantUiState( participantId = participant.id, @@ -56,6 +68,7 @@ internal class BlockedParticipantsUiStateMapperImpl @Inject constructor() : lookupKey = participant.lookupKey, normalizedDestination = normalizedDestination, canCall = canCall, + canShowContact = canShowContact, isContactSaved = isContactSaved, ) } diff --git a/src/com/android/messaging/ui/blockedparticipants/screen/model/BlockedParticipantsUiState.kt b/src/com/android/messaging/ui/blockedparticipants/screen/model/BlockedParticipantsUiState.kt index 4e8000a0e..6f0b9f492 100644 --- a/src/com/android/messaging/ui/blockedparticipants/screen/model/BlockedParticipantsUiState.kt +++ b/src/com/android/messaging/ui/blockedparticipants/screen/model/BlockedParticipantsUiState.kt @@ -24,5 +24,6 @@ internal data class BlockedParticipantUiState( val lookupKey: String?, val normalizedDestination: String?, val canCall: Boolean, + val canShowContact: Boolean, val isContactSaved: Boolean, ) diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index ae9fcdd8b..66fb28ef8 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -11,9 +11,9 @@ import com.android.messaging.data.subscription.repository.ConversationSimSelecti import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest +import com.android.messaging.domain.conversation.usecase.participant.CanAddContact import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants -import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable -import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegate import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegate import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate @@ -145,9 +145,9 @@ internal class ConversationViewModel @Inject constructor( private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, private val simSelectionRepository: ConversationSimSelectionRepository, private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, + private val canAddContact: CanAddContact, + private val canPlacePhoneCall: CanPlacePhoneCall, private val createDefaultSmsRoleRequest: CreateDefaultSmsRoleRequest, - private val isDeviceVoiceCapable: IsDeviceVoiceCapable, - private val isEmergencyPhoneNumber: IsEmergencyPhoneNumber, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, @@ -268,7 +268,7 @@ internal class ConversationViewModel @Inject constructor( canCall = canCall(metadataState = metadataState), canArchive = isPresent && presentMetadata?.isArchived == false, canUnarchive = isPresent && presentMetadata?.isArchived == true, - canAddContact = canAddContact(metadataState = metadataState), + canAddContact = isAddContactAvailable(metadataState = metadataState), canDeleteConversation = isPresent, canEditSubject = isPresent, attachmentLimitWarning = attachmentLimitWarning, @@ -391,22 +391,20 @@ internal class ConversationViewModel @Inject constructor( return when { metadataState !is ConversationMetadataUiState.Present -> false metadataState.participantCount != 1 -> false - metadataState.otherParticipantPhoneNumber == null -> false - !isDeviceVoiceCapable() -> false - isEmergencyPhoneNumber(metadataState.otherParticipantPhoneNumber) -> false - else -> true + else -> canPlacePhoneCall(metadataState.otherParticipantPhoneNumber) } } - private fun canAddContact( + private fun isAddContactAvailable( metadataState: ConversationMetadataUiState, ): Boolean { return when { metadataState !is ConversationMetadataUiState.Present -> false - metadataState.participantCount != 1 -> false - metadataState.otherParticipantPhoneNumber.isNullOrBlank() -> false - !metadataState.otherParticipantContactLookupKey.isNullOrBlank() -> false - else -> true + else -> canAddContact( + isGroup = metadataState.participantCount != 1, + lookupKey = metadataState.otherParticipantContactLookupKey, + destination = metadataState.otherParticipantPhoneNumber, + ) } } @@ -532,7 +530,7 @@ internal class ConversationViewModel @Inject constructor( ConversationMetadataUiState.Present ) ?.otherParticipantPhoneNumber - ?.takeUnless(isEmergencyPhoneNumber::invoke) + ?.takeIf(canPlacePhoneCall::invoke) ?: return viewModelScope.launch(defaultDispatcher) { diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt index f95297df6..8aab3212f 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt @@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView import com.android.messaging.ui.conversationlist.redesign.ui.ConversationListScreen import com.android.messaging.ui.core.AppTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,9 +22,13 @@ class ConversationListActivity : ComponentActivity() { setContent { AppTheme { - val effectHandler = ConversationListActivityEffectHandler( - activity = this, - ) + val hostView = LocalView.current + val effectHandler = remember(hostView) { + ConversationListActivityEffectHandler( + activity = this, + hostView = hostView, + ) + } ConversationListScreen( effectHandler = effectHandler, diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt index f6d950981..38121ef21 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt @@ -1,13 +1,18 @@ package com.android.messaging.ui.conversationlist import android.app.Activity +import android.graphics.Point +import android.view.View +import androidx.core.net.toUri import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversationlist.redesign.ConversationListEffectHandler import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect +import com.android.messaging.util.ContactUtil import com.android.messaging.util.DebugUtils internal class ConversationListActivityEffectHandler( private val activity: Activity, + private val hostView: View, ) : ConversationListEffectHandler { override fun handle(effect: Effect) { @@ -50,6 +55,24 @@ internal class ConversationListActivityEffectHandler( ) } + is Effect.PlaceCall -> { + UIIntents.get().launchPhoneCallActivity( + activity, + effect.destination, + Point(0, 0), + ) + } + + is Effect.ShowOrAddContact -> { + ContactUtil.showOrAddContact( + hostView, + effect.contactId, + effect.lookupKey, + effect.avatarUri?.toUri(), + effect.destination, + ) + } + is Effect.ConversationBlocked, is Effect.ConfirmAddContact, is Effect.ConfirmBlock, diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index 6371a3751..46ad8b0f8 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -10,6 +10,7 @@ import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationL import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegate import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State import dagger.hilt.android.lifecycle.HiltViewModel @@ -136,9 +137,32 @@ internal class ConversationListViewModel @Inject constructor( is Action.NewestConversationVisibilityChanged -> { onNewestConversationVisibilityChanged(action.isVisible) } + + is Action.AvatarMessageClicked -> { + _effects.tryEmit(Effect.OpenConversation(action.conversationId)) + } + + is Action.AvatarCallClicked -> { + _effects.tryEmit(Effect.PlaceCall(action.destination)) + } + + is Action.AvatarContactClicked -> { + onAvatarContactClick(action.avatar) + } } } + private fun onAvatarContactClick(avatar: ConversationListAvatarUiModel) { + _effects.tryEmit( + Effect.ShowOrAddContact( + contactId = avatar.contactId, + lookupKey = avatar.lookupKey, + avatarUri = avatar.uri, + destination = avatar.normalizedDestination, + ), + ) + } + private fun onNavigationAction(action: Action.NavigationAction) { when (action) { Action.ArchivedConversationsClicked -> { diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index e1786fa4b..5818d5f8d 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -1,9 +1,13 @@ package com.android.messaging.ui.conversationlist.redesign.mapper -import androidx.core.net.toUri import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri +import com.android.messaging.domain.conversation.usecase.participant.CanAddContact +import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact +import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel @@ -13,7 +17,6 @@ import com.android.messaging.ui.conversationlist.redesign.model.ConversationList import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversationUiModel import com.android.messaging.ui.conversationlist.redesign.model.SelectionActionsUiState -import com.android.messaging.util.AvatarUriUtil import com.android.messaging.util.ContentType import javax.inject.Inject import kotlinx.collections.immutable.ImmutableSet @@ -28,8 +31,13 @@ internal interface ConversationListUiStateMapper { ): ConversationListUiState } -internal class ConversationListUiStateMapperImpl @Inject constructor() : - ConversationListUiStateMapper { +internal class ConversationListUiStateMapperImpl @Inject constructor( + private val canAddContact: CanAddContact, + private val canPlacePhoneCall: CanPlacePhoneCall, + private val canShowOrAddContact: CanShowOrAddContact, + private val isContactSavedUseCase: IsContactSaved, + private val resolveAvatarUri: ResolveAvatarUri, +) : ConversationListUiStateMapper { override fun map( snapshot: ConversationListSnapshot, @@ -40,11 +48,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : val items = snapshot.items .map { item -> val isSelected = item.conversationId in selectedConversationIds - - mapItem( - item = item, - isSelected = isSelected, - ) + item.toConversationListUiState(isSelected) } .toImmutableList() @@ -77,39 +81,48 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : ) } - private fun mapItem( - item: ConversationListItem, + private fun ConversationListItem.toConversationListUiState( isSelected: Boolean, ): ConversationListItemUiModel { - val preview = item.activePreview() - val isDraft = item.draft.isVisible - val isOutgoing = isDraft || !item.latestMessage.isIncoming + val isDraft = draft.isVisible + val isOutgoing = isDraft || !latestMessage.isIncoming return ConversationListItemUiModel( - conversationId = item.conversationId, - title = item.title, - avatar = item.toAvatar(), + conversationId = conversationId, + title = title, + avatar = toAvatar(), snippet = ConversationListSnippetUiModel( - text = item.activeSnippetText(), - senderName = item.latestMessage.senderName, - preview = preview, + text = activeSnippetText(), + senderName = latestMessage.senderName, + preview = activePreview(), isDraft = isDraft, ), - subject = item.activeSubject(), - timestampMillis = item.latestMessage.timestamp, - status = mapStatus(item), + subject = activeSubject(), + timestampMillis = latestMessage.timestamp, + status = toStatus(), isOutgoing = isOutgoing, - isUnread = !item.latestMessage.isRead && !isDraft, - isGroup = item.participant.isGroup, - isEnterprise = item.participant.isEnterprise, - isMuted = !item.notification.isEnabled, - isArchived = item.isArchived, + isUnread = !latestMessage.isRead && !isDraft, + isGroup = participant.isGroup, + isEnterprise = participant.isEnterprise, + isMuted = !notification.isEnabled, + isArchived = isArchived, isSelected = isSelected, ) } private fun ConversationListItem.toAvatar(): ConversationListAvatarUiModel { + val isOneOnOne = !participant.isGroup val destination = participant.otherNormalizedDestination?.takeIf(String::isNotBlank) + val canShowContact = canShowOrAddContact( + isGroup = participant.isGroup, + contactId = participant.contactId, + lookupKey = participant.lookupKey, + destination = destination, + ) + val isContactSaved = isContactSavedUseCase( + contactId = participant.contactId, + lookupKey = participant.lookupKey, + ) return ConversationListAvatarUiModel( uri = resolveAvatarUri(icon), @@ -117,9 +130,20 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : lookupKey = participant.lookupKey, normalizedDestination = destination, isGroup = participant.isGroup, + details = destination.takeIf { isOneOnOne }, + canCall = isOneOnOne && canPlacePhoneCall(destination), + canShowContact = canShowContact, + isContactSaved = isContactSaved, ) } + private fun ConversationListItem.toStatus(): ConversationListMessageStatus { + return when { + draft.isVisible -> ConversationListMessageStatus.Draft + else -> latestMessage.status + } + } + private fun mapSelectionState( items: List, selectedConversationIds: ImmutableSet, @@ -161,26 +185,36 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : blockedDestinations: ImmutableSet, ): SelectionActionsUiState { val singleSelection = selectedConversations.singleOrNull() + val canAddContact = singleSelection?.let { conversation -> + canAddContact( + isGroup = conversation.isGroup, + lookupKey = conversation.participantLookupKey, + destination = conversation.normalizedDestination, + ) + } + val canBlock = singleSelection?.let { conversation -> + canBlock( + destination = conversation.normalizedDestination, + blockedDestinations = blockedDestinations, + ) + } return SelectionActionsUiState( canArchive = selectedConversations.any { !it.isArchived }, canUnarchive = selectedConversations.any { it.isArchived }, canDelete = selectedConversations.isNotEmpty(), - canAddContact = singleSelection?.canAddContact() == true, - canBlock = singleSelection?.canBlock(blockedDestinations) == true, + canAddContact = canAddContact == true, + canBlock = canBlock == true, ) } - private fun SelectedConversationUiModel.canAddContact(): Boolean { - return !isGroup && participantLookupKey.isNullOrBlank() - } - - private fun SelectedConversationUiModel.canBlock( + private fun canBlock( + destination: String?, blockedDestinations: ImmutableSet, ): Boolean { - val destination = normalizedDestination?.takeIf(String::isNotBlank) ?: return false + val resolvedDestination = destination?.takeIf(String::isNotBlank) ?: return false - return destination !in blockedDestinations + return resolvedDestination !in blockedDestinations } private fun ConversationListItem.activeSnippetText(): String? { @@ -261,25 +295,4 @@ internal class ConversationListUiStateMapperImpl @Inject constructor() : } } } - - private fun mapStatus( - item: ConversationListItem, - ): ConversationListMessageStatus { - return when { - item.draft.isVisible -> ConversationListMessageStatus.Draft - else -> item.latestMessage.status - } - } - - private fun resolveAvatarUri( - icon: String?, - ): String? { - val iconUriString = icon?.takeIf(String::isNotBlank) ?: return null - val iconUri = iconUriString.toUri() - - return when { - AvatarUriUtil.isAvatarUri(iconUri) -> AvatarUriUtil.getPrimaryUri(iconUri)?.toString() - else -> iconUriString - } - } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt index f23343cb8..7fc156068 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt @@ -26,6 +26,18 @@ internal sealed interface ConversationListAction { val isVisible: Boolean, ) : ListAction + data class AvatarMessageClicked( + val conversationId: String, + ) : ListAction + + data class AvatarCallClicked( + val destination: String, + ) : ListAction + + data class AvatarContactClicked( + val avatar: ConversationListAvatarUiModel, + ) : ListAction + data class AddContactConfirmed( val destination: String, ) : DialogAction diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt index f9b1d5de5..fa28bc645 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt @@ -20,6 +20,17 @@ internal sealed interface ConversationListEffect { val conversationId: String, ) : ConversationListEffect + data class PlaceCall( + val destination: String, + ) : ConversationListEffect + + data class ShowOrAddContact( + val contactId: Long, + val lookupKey: String?, + val avatarUri: String?, + val destination: String?, + ) : ConversationListEffect + data class ConfirmAddContact( val destination: String, ) : ConversationListEffect diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt index 56b0e71c1..9b09c26e7 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt @@ -28,6 +28,10 @@ internal data class ConversationListAvatarUiModel( val lookupKey: String?, val normalizedDestination: String?, val isGroup: Boolean, + val details: String?, + val canCall: Boolean, + val canShowContact: Boolean, + val isContactSaved: Boolean, ) @Immutable diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt index 780c723bc..660bf40c0 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt @@ -105,6 +105,8 @@ private fun ConversationListItems( key = { item -> item.conversationId }, contentType = { CONVERSATION_ROW_CONTENT_TYPE }, ) { item -> + val destination = item.avatar.normalizedDestination + ConversationListItemRow( item = item, onClick = { @@ -115,6 +117,17 @@ private fun ConversationListItems( }, modifier = Modifier.animateItem(), isSelectionMode = isSelectionMode, + onAvatarMessageClick = { + onAction(Action.AvatarMessageClicked(item.conversationId)) + }, + onAvatarCallClick = { + if (destination != null) { + onAction(Action.AvatarCallClicked(destination)) + } + }.takeIf { item.avatar.canCall }, + onAvatarContactClick = { + onAction(Action.AvatarContactClicked(item.avatar)) + }.takeIf { item.avatar.canShowContact }, ) } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt index ec16a574e..0dadb4393 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt @@ -8,9 +8,15 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Person import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import com.android.messaging.ui.common.components.participant.ParticipantAvatar +import com.android.messaging.ui.common.components.participant.ParticipantQuickActionsPopup import com.android.messaging.ui.common.components.participant.participantAvatarLabel import com.android.messaging.ui.common.components.participant.participantColorSeed import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel @@ -20,12 +26,17 @@ internal fun ConversationListItemAvatar( item: ConversationListItemUiModel, isSelectionMode: Boolean, onToggleSelection: () -> Unit, + onMessageClick: () -> Unit, + onCallClick: (() -> Unit)?, + onContactClick: (() -> Unit)?, ) { val fallbackIcon = when { item.avatar.isGroup -> Icons.Default.Group else -> Icons.Default.Person } + var showQuickActions by remember { mutableStateOf(false) } + Box(modifier = Modifier.size(ItemAvatarSize)) { ParticipantAvatar( avatarUri = item.avatar.uri, @@ -42,8 +53,54 @@ internal fun ConversationListItemAvatar( .clickable { if (isSelectionMode) { onToggleSelection() + } else { + showQuickActions = true } }, ) + + ConversationListAvatarQuickActions( + item = item, + visible = showQuickActions && !isSelectionMode, + fallbackIcon = fallbackIcon, + onDismiss = { showQuickActions = false }, + onMessageClick = onMessageClick, + onCallClick = onCallClick, + onContactClick = onContactClick, + ) } } + +@Composable +private fun ConversationListAvatarQuickActions( + item: ConversationListItemUiModel, + visible: Boolean, + fallbackIcon: ImageVector, + onDismiss: () -> Unit, + onMessageClick: () -> Unit, + onCallClick: (() -> Unit)?, + onContactClick: (() -> Unit)?, +) { + ParticipantQuickActionsPopup( + visible = visible, + avatarUri = item.avatar.uri, + displayName = item.title.orEmpty(), + subtitle = item.avatar.details, + fallbackIcon = fallbackIcon, + fallbackLabel = participantAvatarLabel(source = item.title), + onDismiss = onDismiss, + onMessageClick = { + onMessageClick() + onDismiss() + }, + onCallClick = { + onCallClick?.invoke() + onDismiss() + }.takeIf { item.avatar.canCall && onCallClick != null }, + onContactClick = { + onContactClick?.invoke() + onDismiss() + }.takeIf { item.avatar.canShowContact && onContactClick != null }, + isContactSaved = item.avatar.isContactSaved, + ) +} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt index 45bd4239a..6dacc6f4b 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt @@ -44,6 +44,9 @@ internal fun ConversationListItemRow( onLongClick: () -> Unit, modifier: Modifier = Modifier, isSelectionMode: Boolean = false, + onAvatarMessageClick: () -> Unit = {}, + onAvatarCallClick: (() -> Unit)? = null, + onAvatarContactClick: (() -> Unit)? = null, ) { TwoLineListItem( onClick = onClick, @@ -52,6 +55,9 @@ internal fun ConversationListItemRow( item = item, isSelectionMode = isSelectionMode, onToggleSelection = onClick, + onMessageClick = onAvatarMessageClick, + onCallClick = onAvatarCallClick, + onContactClick = onAvatarContactClick, ) }, titleContent = { diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt index ad88b82ce..4a694baee 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt @@ -25,6 +25,8 @@ internal fun previewConversationListItem( isDraft: Boolean = false, isSelected: Boolean = false, ): ConversationListItemUiModel { + val normalizedDestination = "+3161234$conversationId".takeUnless { isGroup } + return ConversationListItemUiModel( conversationId = conversationId, title = title, @@ -32,8 +34,12 @@ internal fun previewConversationListItem( uri = null, contactId = -1L, lookupKey = null, - normalizedDestination = "+3161234$conversationId", + normalizedDestination = normalizedDestination, isGroup = isGroup, + details = normalizedDestination, + canCall = !isGroup, + canShowContact = !isGroup, + isContactSaved = false, ), snippet = ConversationListSnippetUiModel( text = snippetText, diff --git a/src/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapper.kt b/src/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapper.kt index 54c678006..05eab99e6 100644 --- a/src/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapper.kt @@ -1,11 +1,10 @@ package com.android.messaging.ui.conversationpicker.mapper -import androidx.core.net.toUri import com.android.messaging.data.contact.formatter.ContactDestinationFormatter import com.android.messaging.data.conversationpicker.model.TargetConversation +import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri import com.android.messaging.ui.conversationpicker.formatter.TargetTextFormatter import com.android.messaging.ui.conversationpicker.model.TargetUiState -import com.android.messaging.util.AvatarUriUtil import com.android.messaging.util.PhoneUtils import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList @@ -19,6 +18,7 @@ internal interface TargetUiStateMapper { internal class TargetUiStateMapperImpl @Inject constructor( private val contactDestinationFormatter: ContactDestinationFormatter, + private val resolveAvatarUri: ResolveAvatarUri, private val textFormatter: TargetTextFormatter, ) : TargetUiStateMapper { @@ -57,14 +57,4 @@ internal class TargetUiStateMapperImpl @Inject constructor( isGroup = conversation.isGroup, ) } - - private fun resolveAvatarUri(icon: String?): String? { - val iconUriString = icon?.takeIf(String::isNotBlank) ?: return null - val iconUri = iconUriString.toUri() - - return when { - AvatarUriUtil.isAvatarUri(iconUri) -> AvatarUriUtil.getPrimaryUri(iconUri)?.toString() - else -> iconUriString - } - } } diff --git a/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt b/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt index 1e76eeef7..26f7f3a76 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt @@ -1,12 +1,13 @@ package com.android.messaging.ui.conversationsettings.screen.mapper -import android.telephony.PhoneNumberUtils -import android.telephony.TelephonyManager import androidx.core.text.BidiFormatter import androidx.core.text.TextDirectionHeuristicsCompat.LTR import com.android.messaging.data.conversationsettings.model.ConversationSettingsData import com.android.messaging.data.subscription.model.Subscription import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact +import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved +import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall import com.android.messaging.ui.conversationsettings.screen.model.ConversationSettingsUiState import com.android.messaging.ui.conversationsettings.screen.model.ParticipantUiState import javax.inject.Inject @@ -23,7 +24,9 @@ internal interface ConversationSettingsUiStateMapper { } internal class ConversationSettingsUiStateMapperImpl @Inject constructor( - private val telephonyManager: TelephonyManager, + private val canPlacePhoneCall: CanPlacePhoneCall, + private val canShowOrAddContact: CanShowOrAddContact, + private val isContactSavedUseCase: IsContactSaved, ) : ConversationSettingsUiStateMapper { override fun map( @@ -32,12 +35,7 @@ internal class ConversationSettingsUiStateMapperImpl @Inject constructor( selfIdOverride: String?, ): ConversationSettingsUiState { val participants = data.participants - .map { participant -> - toParticipantUiState( - participant = participant, - isVoiceCapable = data.isVoiceCapable, - ) - } + .map(::toParticipantUiState) .toImmutableList() val otherParticipant = participants.singleOrNull() @@ -49,6 +47,15 @@ internal class ConversationSettingsUiStateMapperImpl @Inject constructor( .firstOrNull { it.selfParticipantId == effectiveSelfId } ?: subscriptions.firstOrNull() + val canShowContact = otherParticipant?.let { participant -> + canShowOrAddContact( + isGroup = false, + contactId = participant.contactId, + lookupKey = participant.lookupKey, + destination = participant.normalizedDestination, + ) + } + return ConversationSettingsUiState( conversationId = data.conversationId, conversationTitle = data.conversationTitle, @@ -61,24 +68,13 @@ internal class ConversationSettingsUiStateMapperImpl @Inject constructor( selectedSubscription = selectedSubscription, isSimSwitchAvailable = subscriptions.size > 1, canCall = otherParticipant?.canCall == true, - canShowContact = !otherParticipant?.normalizedDestination.isNullOrBlank(), + canShowContact = canShowContact == true, isContactSaved = otherParticipant?.isContactSaved == true, ) } - private fun canCall( - destination: String?, - isVoiceCapable: Boolean, - ): Boolean { - return isVoiceCapable && - !destination.isNullOrBlank() && - PhoneNumberUtils.isWellFormedSmsAddress(destination) && - !telephonyManager.isEmergencyNumber(destination) - } - private fun toParticipantUiState( participant: ParticipantData, - isVoiceCapable: Boolean, ): ParticipantUiState { val bidiFormatter = BidiFormatter.getInstance() val fullName = participant.fullName @@ -94,7 +90,11 @@ internal class ConversationSettingsUiStateMapperImpl @Inject constructor( bidiFormatter.unicodeWrap(it, LTR) } } - val isContactSaved = participant.contactId > 0 && !participant.lookupKey.isNullOrBlank() + val canCall = canPlacePhoneCall(participant.normalizedDestination) + val isContactSaved = isContactSavedUseCase( + contactId = participant.contactId, + lookupKey = participant.lookupKey, + ) return ParticipantUiState( id = participant.id, @@ -108,10 +108,7 @@ internal class ConversationSettingsUiStateMapperImpl @Inject constructor( displayDestination = participant.displayDestination?.let { bidiFormatter.unicodeWrap(it, LTR) }, - canCall = canCall( - destination = participant.normalizedDestination, - isVoiceCapable = isVoiceCapable, - ), + canCall = canCall, isContactSaved = isContactSaved, ) } From b453fdd485ce5b470002c3cda0118f5de61c2a56 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 20 Jun 2026 15:09:18 +0200 Subject: [PATCH 16/39] Add chat snooze and polish conversation list rows --- .../model/ConversationListItem.kt | 10 +- .../repository/ConversationListRepository.kt | 75 ++++++- .../ConversationNotificationRepository.kt | 35 +++- .../common/BlockedParticipantItem.kt | 9 + .../ui/common/components/SnoozeChatDialog.kt | 186 ++++++++++++++++++ .../ParticipantQuickActionsPopup.kt | 7 + .../redesign/ConversationListViewModel.kt | 32 +++ .../mapper/ConversationListUiStateMapper.kt | 4 + .../redesign/model/ConversationListAction.kt | 6 + .../model/ConversationListItemUiModel.kt | 1 + .../model/ConversationListSelectionUiState.kt | 3 + .../redesign/ui/ConversationListDialogs.kt | 13 ++ .../redesign/ui/ConversationListItemAvatar.kt | 22 ++- .../redesign/ui/ConversationListItemRow.kt | 119 +++++++---- .../ui/ConversationListPreviewSupport.kt | 2 + .../redesign/ui/ConversationListScreen.kt | 10 + .../ui/ConversationListSelectionTopAppBar.kt | 23 +++ .../redesign/ui/ConversationListTokens.kt | 7 +- .../common/ConversationSettingsComponents.kt | 12 ++ .../common/ConversationSettingsTopAppBar.kt | 4 + .../screen/ConversationSettingsDialogs.kt | 178 +---------------- 21 files changed, 531 insertions(+), 227 deletions(-) create mode 100644 src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt index 4ed71d632..7fed8de9a 100644 --- a/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt +++ b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt @@ -44,4 +44,12 @@ internal data class ConversationListDraft( internal data class ConversationListNotification( val isEnabled: Boolean, -) + val snoozedUntilMillis: Long = SNOOZE_NOT_SET, +) { + val isSnoozed: Boolean + get() = snoozedUntilMillis > System.currentTimeMillis() + + internal companion object { + const val SNOOZE_NOT_SET = 0L + } +} diff --git a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt index 9aad19cd9..2cf846724 100644 --- a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt +++ b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt @@ -11,6 +11,8 @@ import com.android.messaging.data.conversationlist.model.ConversationListNotific import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.store.ConversationListStatusStore +import com.android.messaging.data.conversationsettings.model.SnoozeOption +import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepository import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.ConversationListData @@ -26,30 +28,48 @@ import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge internal interface ConversationListRepository { fun observeInboxSnapshot(): Flow fun setNewestConversationVisible(isVisible: Boolean) + fun snooze(conversationId: String, option: SnoozeOption) + fun clearSnooze(conversationId: String) + fun refresh() } internal class ConversationListRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, private val statusStore: ConversationListStatusStore, + private val notificationRepository: ConversationNotificationRepository, @param:MessagingDbDispatcher private val messagingDbDispatcher: CoroutineDispatcher, ) : ConversationListRepository { + private val manualRefresh = MutableSharedFlow(extraBufferCapacity = 1) + + @OptIn(ExperimentalCoroutinesApi::class) override fun observeInboxSnapshot(): Flow { - val itemsFlow = observeUri(MessagingContentProvider.CONVERSATIONS_URI) + val itemsFlow = merge( + observeUri(MessagingContentProvider.CONVERSATIONS_URI), + notificationRepository.observeSnoozeChanges(), + manualRefresh, + ) .conflate() .map { queryInboxConversations() } + .flatMapLatest(::observeSnoozeState) val blockedDestinationsFlow = observeUri(MessagingContentProvider.PARTICIPANTS_URI) .conflate() @@ -71,6 +91,57 @@ internal class ConversationListRepositoryImpl @Inject constructor( statusStore.setNewestConversationVisible(isVisible) } + override fun snooze( + conversationId: String, + option: SnoozeOption, + ) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + notificationRepository.snooze(resolvedConversationId, option) + } + + override fun clearSnooze(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + notificationRepository.clearSnooze(resolvedConversationId) + } + + override fun refresh() { + manualRefresh.tryEmit(Unit) + } + + private fun observeSnoozeState( + items: ImmutableList, + ): Flow> { + return flow { + while (true) { + val now = System.currentTimeMillis() + val resolved = items + .map { item -> + val snoozedUntilMillis = notificationRepository + .getSnoozeUntilMillis(item.conversationId) + .takeIf { it > now } + ?: ConversationListNotification.SNOOZE_NOT_SET + + item.copy( + notification = item.notification.copy( + snoozedUntilMillis = snoozedUntilMillis, + ), + ) + } + .toImmutableList() + + emit(resolved) + + val nextExpiryMillis = resolved + .map { it.notification.snoozedUntilMillis } + .filter { expiry -> expiry > now && expiry != SNOOZE_NEVER_EXPIRES } + .minOrNull() + ?: break + + delay(nextExpiryMillis - now) + } + } + } + private fun observeUri(uri: Uri): Flow { return callbackFlow { val observer = object : ContentObserver(null) { @@ -223,6 +294,8 @@ internal class ConversationListRepositoryImpl @Inject constructor( } private companion object { + private const val SNOOZE_NEVER_EXPIRES = Long.MAX_VALUE + private val BLOCKED_PARTICIPANTS_PROJECTION = arrayOf( ParticipantColumns._ID, ParticipantColumns.NORMALIZED_DESTINATION, diff --git a/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt b/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt index 9ce41b28c..80fb87119 100644 --- a/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt +++ b/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt @@ -1,13 +1,22 @@ package com.android.messaging.data.conversationsettings.repository +import android.content.Context +import android.content.SharedPreferences import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.util.BuglePrefs import dagger.hilt.EntryPoint import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow internal interface ConversationNotificationRepository { + + fun observeSnoozeChanges(): Flow + fun getSnoozeUntilMillis(conversationId: String): Long fun isSnoozed(conversationId: String): Boolean @@ -23,8 +32,30 @@ internal interface ConversationNotificationRepository { } } -internal class ConversationNotificationRepositoryImpl @Inject constructor() : - ConversationNotificationRepository { +internal class ConversationNotificationRepositoryImpl @Inject constructor( + @param:ApplicationContext private val context: Context, +) : ConversationNotificationRepository { + + override fun observeSnoozeChanges(): Flow { + return callbackFlow { + val prefs = context.getSharedPreferences( + BuglePrefs.SHARED_PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null || key.startsWith(SNOOZE_KEY_PREFIX)) { + trySend(Unit) + } + } + + prefs.registerOnSharedPreferenceChangeListener(listener) + + awaitClose { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } + } + } override fun getSnoozeUntilMillis(conversationId: String): Long { val prefs = BuglePrefs.getApplicationPrefs() diff --git a/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt b/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt index bd9bf27b6..76bfd63ba 100644 --- a/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt +++ b/src/com/android/messaging/ui/blockedparticipants/common/BlockedParticipantItem.kt @@ -27,6 +27,7 @@ import com.android.messaging.ui.common.components.TwoLineListItem import com.android.messaging.ui.common.components.participant.ParticipantAvatar import com.android.messaging.ui.common.components.participant.ParticipantQuickActionsPopup import com.android.messaging.ui.common.components.participant.participantAvatarLabel +import com.android.messaging.ui.common.components.participant.participantColorSeed import com.android.messaging.ui.core.MessagingPreviewColumn @Composable @@ -124,6 +125,7 @@ private fun BlockedParticipantQuickActions( visible: Boolean, participant: BlockedParticipantUiState, fallbackIcon: ImageVector, + colorSeedCode: String?, onDismiss: () -> Unit, onMessageClick: () -> Unit, onCallClick: (() -> Unit)?, @@ -136,6 +138,7 @@ private fun BlockedParticipantQuickActions( subtitle = participant.details, fallbackIcon = fallbackIcon, fallbackLabel = participantAvatarLabel(source = participant.displayName), + colorSeedCode = colorSeedCode, onDismiss = onDismiss, onMessageClick = { onMessageClick() @@ -166,12 +169,17 @@ private fun BlockedParticipantAvatarWithQuickActions( onCallClick: (() -> Unit)?, onContactClick: (() -> Unit)?, ) { + val colorSeedCode = participantColorSeed( + normalizedDestination = participant.normalizedDestination, + ) + Box(modifier = Modifier.size(48.dp)) { ParticipantAvatar( avatarUri = participant.avatarUri, size = 48.dp, fallbackLabel = participantAvatarLabel(source = participant.displayName), fallbackIcon = fallbackIcon, + colorSeedCode = colorSeedCode, isSelected = isSelected, modifier = Modifier .clip(CircleShape) @@ -185,6 +193,7 @@ private fun BlockedParticipantAvatarWithQuickActions( visible = quickActionsVisible && !inSelectionMode, participant = participant, fallbackIcon = fallbackIcon, + colorSeedCode = colorSeedCode, onDismiss = onDismissQuickActions, onMessageClick = onMessageClick, onCallClick = onCallClick, diff --git a/src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt b/src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt new file mode 100644 index 000000000..0b5e724c8 --- /dev/null +++ b/src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt @@ -0,0 +1,186 @@ +package com.android.messaging.ui.common.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Snooze +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.conversationsettings.model.SnoozeOption +import com.android.messaging.ui.core.MessagingPreviewTheme + +private val DialogHorizontalPadding = 24.dp + +private val SnoozeOption.labelRes: Int + get() = when (this) { + SnoozeOption.OneHour -> R.string.snooze_chat_option_one_hour + SnoozeOption.EightHours -> R.string.snooze_chat_option_eight_hours + SnoozeOption.TwentyFourHours -> R.string.snooze_chat_option_twenty_four_hours + SnoozeOption.Always -> R.string.snooze_chat_option_always + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SnoozeChatDialog( + onDismiss: () -> Unit, + onConfirm: (SnoozeOption) -> Unit, +) { + var selectedOption by rememberSaveable { mutableStateOf(SnoozeOption.OneHour) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.padding(vertical = DialogHorizontalPadding)) { + SnoozeChatDialogHeader() + + Column(modifier = Modifier.selectableGroup()) { + SnoozeOption.entries.forEach { option -> + SnoozeOptionRow( + text = stringResource(option.labelRes), + selected = option == selectedOption, + onClick = { selectedOption = option }, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + SnoozeChatDialogButtons( + onDismiss = onDismiss, + onConfirm = { onConfirm(selectedOption) }, + ) + } + } + } +} + +@Composable +private fun SnoozeChatDialogHeader() { + Icon( + imageVector = Icons.Default.Snooze, + contentDescription = stringResource(R.string.snooze_chat_setting_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.snooze_chat_dialog_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DialogHorizontalPadding), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.snooze_chat_dialog_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = DialogHorizontalPadding), + ) + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Composable +private fun SnoozeChatDialogButtons( + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DialogHorizontalPadding), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.snooze_chat_dialog_confirm)) + } + } +} + +@Composable +private fun SnoozeOptionRow( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + ) + .padding( + horizontal = DialogHorizontalPadding, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@PreviewLightDark +@Composable +private fun SnoozeChatDialogPreview() { + MessagingPreviewTheme { + SnoozeChatDialog( + onDismiss = {}, + onConfirm = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt b/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt index 68e8f9948..8c424d071 100644 --- a/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt +++ b/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt @@ -76,6 +76,7 @@ internal fun ParticipantQuickActionsPopup( onMessageClick: (() -> Unit)?, onCallClick: (() -> Unit)?, onContactClick: (() -> Unit)?, + colorSeedCode: String?, isContactSaved: Boolean = true, ) { val transitionState = remember { MutableTransitionState(false) } @@ -127,6 +128,7 @@ internal fun ParticipantQuickActionsPopup( subtitle = subtitle, fallbackIcon = fallbackIcon, fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, onMessageClick = onMessageClick, onCallClick = onCallClick, onContactClick = onContactClick, @@ -143,6 +145,7 @@ private fun QuickActionsCard( subtitle: String?, fallbackIcon: ImageVector, fallbackLabel: String?, + colorSeedCode: String?, onMessageClick: (() -> Unit)?, onCallClick: (() -> Unit)?, onContactClick: (() -> Unit)?, @@ -165,6 +168,7 @@ private fun QuickActionsCard( avatarUri = avatarUri, fallbackIcon = fallbackIcon, fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, fadeColor = cardColor, ) @@ -205,6 +209,7 @@ private fun AvatarHeader( avatarUri: String?, fallbackIcon: ImageVector, fallbackLabel: String?, + colorSeedCode: String?, fadeColor: Color, ) { Box( @@ -217,6 +222,7 @@ private fun AvatarHeader( fallbackIcon = fallbackIcon, fallbackSize = AvatarFallbackSize, fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, shape = RectangleShape, modifier = Modifier.fillMaxSize(), ) @@ -321,6 +327,7 @@ private fun ParticipantQuickActionsPopupPreview() { subtitle = "+1 555 000 0000000 (Mobile, USA)", fallbackIcon = Icons.Default.Person, fallbackLabel = "T", + colorSeedCode = null, onMessageClick = {}, onCallClick = {}, onContactClick = {}, diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index 46ad8b0f8..83a9a9f01 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegate import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegate @@ -120,6 +121,7 @@ internal class ConversationListViewModel @Inject constructor( when (action) { Action.ScreenResumed -> { isDebugEnabled.value = debugFeaturesProvider.isEnabled() + repository.refresh() } } } @@ -198,9 +200,39 @@ internal class ConversationListViewModel @Inject constructor( Action.UnarchiveClicked -> onArchiveClick(isArchived = false) Action.BlockClicked -> onBlockClick() Action.SelectionCleared -> selectionDelegate.clear() + Action.UnsnoozeClicked -> onUnsnoozeClick() + is Action.SnoozeOptionSelected -> onSnoozeOptionSelected(action.option) } } + private fun onUnsnoozeClick() { + val selectedItems = selectionDelegate.currentSelectedItems() + + if (selectedItems.isEmpty()) { + return + } + + selectedItems.forEach { item -> + repository.clearSnooze(item.conversationId) + } + + selectionDelegate.clear() + } + + private fun onSnoozeOptionSelected(option: SnoozeOption) { + val selectedItems = selectionDelegate.currentSelectedItems() + + if (selectedItems.isEmpty()) { + return + } + + selectedItems.forEach { item -> + repository.snooze(item.conversationId, option) + } + + selectionDelegate.clear() + } + private fun onConversationClick(conversationId: String) { val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index 5818d5f8d..b9814b0e6 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -105,6 +105,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( isGroup = participant.isGroup, isEnterprise = participant.isEnterprise, isMuted = !notification.isEnabled, + isSnoozed = notification.isSnoozed, isArchived = isArchived, isSelected = isSelected, ) @@ -177,6 +178,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( participantLookupKey = item.participant.lookupKey, isGroup = item.participant.isGroup, isArchived = item.isArchived, + isSnoozed = item.notification.isSnoozed, ) } @@ -205,6 +207,8 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( canDelete = selectedConversations.isNotEmpty(), canAddContact = canAddContact == true, canBlock = canBlock == true, + canSnooze = selectedConversations.any { !it.isSnoozed }, + canUnsnooze = selectedConversations.any { it.isSnoozed }, ) } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt index 7fc156068..d5399bee4 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversationlist.redesign.model +import com.android.messaging.data.conversationsettings.model.SnoozeOption import kotlinx.collections.immutable.ImmutableList internal sealed interface ConversationListAction { @@ -52,11 +53,16 @@ internal sealed interface ConversationListAction { val destination: String, ) : DialogAction + data class SnoozeOptionSelected( + val option: SnoozeOption, + ) : SelectionAction + data object AddContactClicked : SelectionAction data object ArchiveClicked : SelectionAction data object BlockClicked : SelectionAction data object SelectionCleared : SelectionAction data object UnarchiveClicked : SelectionAction + data object UnsnoozeClicked : SelectionAction data object ArchivedConversationsClicked : NavigationAction data object BlockedParticipantsClicked : NavigationAction diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt index 9b09c26e7..e3f5c18e4 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt @@ -17,6 +17,7 @@ internal data class ConversationListItemUiModel( val isGroup: Boolean, val isEnterprise: Boolean, val isMuted: Boolean, + val isSnoozed: Boolean, val isArchived: Boolean, val isSelected: Boolean, ) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt index 49ead01b0..de7480795 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt @@ -18,6 +18,7 @@ internal data class SelectedConversationUiModel( val participantLookupKey: String?, val isGroup: Boolean, val isArchived: Boolean, + val isSnoozed: Boolean, ) @Immutable @@ -27,4 +28,6 @@ internal data class SelectionActionsUiState( val canDelete: Boolean = false, val canAddContact: Boolean = false, val canBlock: Boolean = false, + val canSnooze: Boolean = false, + val canUnsnooze: Boolean = false, ) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt index fa2e28480..5af16e333 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.messaging.R +import com.android.messaging.ui.common.components.SnoozeChatDialog import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action import com.android.messaging.ui.core.MessagingPreviewTheme @@ -17,10 +18,12 @@ internal fun ConversationListDialogs( addContactDestination: String?, isDeleteVisible: Boolean, blockDestination: String?, + isSnoozeVisible: Boolean, onAction: (Action) -> Unit, onDismissAddContact: () -> Unit, onDismissDelete: () -> Unit, onDismissBlock: () -> Unit, + onDismissSnooze: () -> Unit, ) { addContactDestination?.let { destination -> ConversationListAddContactDialog( @@ -54,6 +57,16 @@ internal fun ConversationListDialogs( onDismiss = onDismissBlock, ) } + + if (isSnoozeVisible) { + SnoozeChatDialog( + onDismiss = onDismissSnooze, + onConfirm = { option -> + onAction(Action.SnoozeOptionSelected(option)) + onDismissSnooze() + }, + ) + } } @Composable diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt index 0dadb4393..488863ca8 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt @@ -35,16 +35,23 @@ internal fun ConversationListItemAvatar( else -> Icons.Default.Person } + val fallbackLabel = when { + item.avatar.isGroup -> null + else -> participantAvatarLabel(source = item.title) + } + + val colorSeedCode = participantColorSeed( + normalizedDestination = item.avatar.normalizedDestination, + ) + var showQuickActions by remember { mutableStateOf(false) } Box(modifier = Modifier.size(ItemAvatarSize)) { ParticipantAvatar( avatarUri = item.avatar.uri, size = ItemAvatarSize, - fallbackLabel = participantAvatarLabel(source = item.title), - colorSeedCode = participantColorSeed( - normalizedDestination = item.avatar.normalizedDestination, - ), + fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, fallbackSize = ItemAvatarFallbackSize, fallbackIcon = fallbackIcon, isSelected = item.isSelected, @@ -63,6 +70,8 @@ internal fun ConversationListItemAvatar( item = item, visible = showQuickActions && !isSelectionMode, fallbackIcon = fallbackIcon, + fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, onDismiss = { showQuickActions = false }, onMessageClick = onMessageClick, onCallClick = onCallClick, @@ -76,6 +85,8 @@ private fun ConversationListAvatarQuickActions( item: ConversationListItemUiModel, visible: Boolean, fallbackIcon: ImageVector, + fallbackLabel: String?, + colorSeedCode: String?, onDismiss: () -> Unit, onMessageClick: () -> Unit, onCallClick: (() -> Unit)?, @@ -87,7 +98,8 @@ private fun ConversationListAvatarQuickActions( displayName = item.title.orEmpty(), subtitle = item.avatar.details, fallbackIcon = fallbackIcon, - fallbackLabel = participantAvatarLabel(source = item.title), + fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, onDismiss = onDismiss, onMessageClick = { onMessageClick() diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt index 6dacc6f4b..66fe0951b 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt @@ -5,10 +5,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.NotificationsPaused import androidx.compose.material.icons.filled.Work import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -23,6 +25,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -83,22 +88,32 @@ private fun ConversationListItemHeader(item: ConversationListItemUiModel) { horizontalArrangement = Arrangement.spacedBy(ItemHeaderSpacing), verticalAlignment = Alignment.CenterVertically, ) { - Text( + Row( modifier = Modifier.weight(1f), - text = item.title.orEmpty(), - style = MaterialTheme.typography.bodyLarge, - fontWeight = itemUnreadFontWeight(item), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + horizontalArrangement = Arrangement.spacedBy(ItemHeaderSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight( + weight = 1f, + fill = false, + ), + text = item.title.orEmpty(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = itemUnreadFontWeight(item), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - if (item.isEnterprise) { - ConversationListItemBadgeIcon(Icons.Default.Work) - } + if (item.isEnterprise) { + ConversationListItemBadgeIcon(Icons.Default.Work) + } - if (item.isMuted) { - ConversationListItemBadgeIcon(Icons.Default.NotificationsOff) + when { + item.isMuted -> ConversationListItemBadgeIcon(Icons.Default.NotificationsOff) + item.isSnoozed -> ConversationListItemBadgeIcon(Icons.Default.NotificationsPaused) + } } ConversationListItemStatusLabel(item) @@ -122,6 +137,7 @@ private fun ConversationListItemBody(item: ConversationListItemUiModel) { text = snippetText, style = MaterialTheme.typography.bodyMedium, fontWeight = itemUnreadFontWeight(item), + fontStyle = FontStyle.Italic.takeIf { item.snippet.isDraft }, color = itemSnippetColor(item), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -132,34 +148,59 @@ private fun ConversationListItemBody(item: ConversationListItemUiModel) { @Composable private fun ConversationListItemTrailing(item: ConversationListItemUiModel) { Row( + modifier = Modifier.padding(end = ItemTrailingEndPadding), horizontalArrangement = Arrangement.spacedBy(ItemHeaderSpacing), verticalAlignment = Alignment.CenterVertically, ) { - if (item.isUnread) { - ConversationListItemUnreadDot() - } - ConversationListItemPreviewThumbnail(item.snippet.preview) + + when { + item.status is ConversationListMessageStatus.Failed -> { + ConversationListItemFailedDot(item) + } + + item.isUnread -> { + ConversationListItemUnreadDot() + } + } } } @Composable -private fun ConversationListItemBadgeIcon(icon: ImageVector) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(ItemBadgeIconSize), - tint = MaterialTheme.colorScheme.onSurfaceVariant, +private fun ConversationListItemFailedDot(item: ConversationListItemUiModel) { + val description = stringResource(itemFailedStatusResId(item)) + + ConversationListItemStatusDot( + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { contentDescription = description }, ) } @Composable private fun ConversationListItemUnreadDot() { + ConversationListItemStatusDot(color = MaterialTheme.colorScheme.primary) +} + +@Composable +private fun ConversationListItemStatusDot( + color: Color, + modifier: Modifier = Modifier, +) { Box( - modifier = Modifier - .size(ItemUnreadDotSize) + modifier = modifier + .size(ItemStatusDotSize) .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), + .background(color), + ) +} + +@Composable +private fun ConversationListItemBadgeIcon(icon: ImageVector) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(ItemBadgeIconSize), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -179,7 +220,7 @@ private fun ConversationListItemPreviewThumbnail(preview: ConversationListPrevie MediaThumbnail( modifier = Modifier .size(ItemPreviewThumbnailSize) - .clip(ItemPreviewThumbnailShape), + .clip(MaterialTheme.shapes.extraSmall), contentUri = preview.contentUri, contentType = preview.contentType, size = IntSize( @@ -191,9 +232,7 @@ private fun ConversationListItemPreviewThumbnail(preview: ConversationListPrevie @Composable private fun ConversationListItemStatusLabel(item: ConversationListItemUiModel) { - val status = item.status - - val text = when (status) { + val text = when (item.status) { ConversationListMessageStatus.Draft, ConversationListMessageStatus.Unknown, -> { @@ -204,11 +243,7 @@ private fun ConversationListItemStatusLabel(item: ConversationListItemUiModel) { stringResource(R.string.message_status_sending) } - is ConversationListMessageStatus.Failed -> { - stringResource(itemFailedStatusResId(item)) - } - - ConversationListMessageStatus.Normal -> { + else -> { remember(item.timestampMillis) { Dates.getConversationTimeString(item.timestampMillis).toString() } @@ -216,7 +251,6 @@ private fun ConversationListItemStatusLabel(item: ConversationListItemUiModel) { } val color = when { - status is ConversationListMessageStatus.Failed -> MaterialTheme.colorScheme.error item.isUnread -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurfaceVariant } @@ -224,6 +258,7 @@ private fun ConversationListItemStatusLabel(item: ConversationListItemUiModel) { Text( text = text, style = MaterialTheme.typography.labelMedium, + fontStyle = FontStyle.Italic.takeIf { item.snippet.isDraft }, color = color, maxLines = 1, ) @@ -277,7 +312,7 @@ private fun itemContainerColor(item: ConversationListItemUiModel): Color { private fun itemUnreadFontWeight(item: ConversationListItemUiModel): FontWeight { return when { - item.isUnread -> FontWeight.Bold + item.isUnread -> FontWeight.Medium else -> FontWeight.Normal } } @@ -368,6 +403,16 @@ private fun ConversationListItemRowBadgesPreview() { onClick = {}, onLongClick = {}, ) + ConversationListItemRow( + item = previewConversationListItem( + conversationId = "snoozed", + title = "Liam Carter", + snippetText = "Let's catch up later", + isSnoozed = true, + ), + onClick = {}, + onLongClick = {}, + ) ConversationListItemRow( item = previewConversationListItem( conversationId = "enterprise", diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt index 4a694baee..50544672e 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt @@ -22,6 +22,7 @@ internal fun previewConversationListItem( isGroup: Boolean = false, isEnterprise: Boolean = false, isMuted: Boolean = false, + isSnoozed: Boolean = false, isDraft: Boolean = false, isSelected: Boolean = false, ): ConversationListItemUiModel { @@ -55,6 +56,7 @@ internal fun previewConversationListItem( isGroup = isGroup, isEnterprise = isEnterprise, isMuted = isMuted, + isSnoozed = isSnoozed, isArchived = false, isSelected = isSelected, ) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt index 2a353f135..5eb327d61 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt @@ -86,6 +86,7 @@ internal fun ConversationListScreen( var pendingAddContactDestination by rememberSaveable { mutableStateOf(null) } var pendingDelete by rememberSaveable { mutableStateOf(false) } var pendingBlockDestination by rememberSaveable { mutableStateOf(null) } + var pendingSnooze by rememberSaveable { mutableStateOf(false) } ConversationListEffects( screenModel = screenModel, @@ -102,6 +103,7 @@ internal fun ConversationListScreen( snackbarHostState = snackbarHostState, onAction = screenModel::onAction, onDeleteClick = { pendingDelete = true }, + onSnoozeClick = { pendingSnooze = true }, onScrollToTop = { screenModel.onAction(Action.ScrollUpClicked) }, modifier = modifier, ) @@ -111,10 +113,12 @@ internal fun ConversationListScreen( addContactDestination = pendingAddContactDestination, isDeleteVisible = pendingDelete, blockDestination = pendingBlockDestination, + isSnoozeVisible = pendingSnooze, onAction = screenModel::onAction, onDismissAddContact = { pendingAddContactDestination = null }, onDismissDelete = { pendingDelete = false }, onDismissBlock = { pendingBlockDestination = null }, + onDismissSnooze = { pendingSnooze = false }, ) } @@ -246,6 +250,7 @@ private fun ConversationListScaffold( snackbarHostState: SnackbarHostState, onAction: (Action) -> Unit, onDeleteClick: () -> Unit, + onSnoozeClick: () -> Unit, onScrollToTop: () -> Unit, modifier: Modifier = Modifier, ) { @@ -270,6 +275,7 @@ private fun ConversationListScaffold( isSelectionMode = isSelectionMode, onAction = onAction, onDeleteClick = onDeleteClick, + onSnoozeClick = onSnoozeClick, ) }, snackbarHost = { @@ -344,6 +350,7 @@ private fun ConversationListTopBar( isSelectionMode: Boolean, onAction: (Action) -> Unit, onDeleteClick: () -> Unit, + onSnoozeClick: () -> Unit, ) { when { isSelectionMode -> { @@ -352,6 +359,7 @@ private fun ConversationListTopBar( actions = uiState.selection.actions, onAction = onAction, onDeleteClick = onDeleteClick, + onSnoozeClick = onSnoozeClick, ) } @@ -443,6 +451,7 @@ private fun ConversationListScaffoldItemsPreview() { snackbarHostState = remember { SnackbarHostState() }, onAction = {}, onDeleteClick = {}, + onSnoozeClick = {}, onScrollToTop = {}, ) } @@ -461,6 +470,7 @@ private fun ConversationListScaffoldEmptyPreview() { snackbarHostState = remember { SnackbarHostState() }, onAction = {}, onDeleteClick = {}, + onSnoozeClick = {}, onScrollToTop = {}, ) } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt index ee6f55fa3..6e4063d36 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt @@ -4,7 +4,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Snooze import androidx.compose.material.icons.filled.Unarchive import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,6 +35,7 @@ internal fun ConversationListSelectionTopAppBar( actions: SelectionActionsUiState, onAction: (Action) -> Unit, onDeleteClick: () -> Unit, + onSnoozeClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( @@ -54,6 +57,7 @@ internal fun ConversationListSelectionTopAppBar( actions = actions, onAction = onAction, onDeleteClick = onDeleteClick, + onSnoozeClick = onSnoozeClick, ) }, ) @@ -77,7 +81,24 @@ private fun ConversationListSelectionActions( actions: SelectionActionsUiState, onAction: (Action) -> Unit, onDeleteClick: () -> Unit, + onSnoozeClick: () -> Unit, ) { + if (actions.canSnooze) { + SelectionActionButton( + imageVector = Icons.Default.Snooze, + labelResId = R.string.snooze_chat_setting_title, + onClick = onSnoozeClick, + ) + } + + if (actions.canUnsnooze) { + SelectionActionButton( + imageVector = Icons.Default.NotificationsActive, + labelResId = R.string.unsnooze_chat_setting_title, + onClick = { onAction(Action.UnsnoozeClicked) }, + ) + } + if (actions.canArchive) { SelectionActionButton( imageVector = Icons.Default.Archive, @@ -154,9 +175,11 @@ private fun ConversationListSelectionTopAppBarPreview() { canDelete = true, canAddContact = true, canBlock = true, + canSnooze = true, ), onAction = {}, onDeleteClick = {}, + onSnoozeClick = {}, ) } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt index e3357ab2b..d3c513c94 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt @@ -1,6 +1,5 @@ package com.android.messaging.ui.conversationlist.redesign.ui -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.dp internal val ItemAvatarSize = 48.dp @@ -11,8 +10,8 @@ internal val ItemHeaderSpacing = 8.dp internal val ItemBadgeIconSize = 16.dp -internal val ItemUnreadDotSize = 12.dp +internal val ItemStatusDotSize = 12.dp -internal val ItemPreviewThumbnailSize = 44.dp +internal val ItemTrailingEndPadding = 4.dp -internal val ItemPreviewThumbnailShape = RoundedCornerShape(size = 8.dp) +internal val ItemPreviewThumbnailSize = 44.dp diff --git a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt index 62d519e7a..88c449375 100644 --- a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt +++ b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt @@ -41,6 +41,7 @@ import com.android.messaging.R import com.android.messaging.ui.common.components.participant.ParticipantAvatar import com.android.messaging.ui.common.components.participant.ParticipantQuickActionsPopup import com.android.messaging.ui.common.components.participant.participantAvatarLabel +import com.android.messaging.ui.common.components.participant.participantColorSeed import com.android.messaging.ui.conversationsettings.screen.model.ParticipantUiState import com.android.messaging.ui.core.MessagingPreviewColumn @@ -77,6 +78,9 @@ internal fun ConversationHeader( fallbackLabel = participantAvatarLabel( source = participant?.displayName ).takeUnless { isBlocked }, + colorSeedCode = participantColorSeed( + normalizedDestination = participant?.normalizedDestination, + ), ) if (title.isNotEmpty()) { @@ -239,6 +243,7 @@ private fun ParticipantQuickActions( avatarUri: String?, fallbackIcon: ImageVector, fallbackLabel: String?, + colorSeedCode: String?, onDismiss: () -> Unit, onMessageClick: () -> Unit, onCallClick: (() -> Unit)?, @@ -251,6 +256,7 @@ private fun ParticipantQuickActions( subtitle = participant.details, fallbackIcon = fallbackIcon, fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, onDismiss = onDismiss, onMessageClick = { onMessageClick() @@ -280,12 +286,17 @@ private fun ParticipantAvatarWithQuickActions( onCallClick: (() -> Unit)?, onContactClick: (() -> Unit)?, ) { + val colorSeedCode = participantColorSeed( + normalizedDestination = participant.normalizedDestination, + ) + Box(modifier = Modifier.size(40.dp)) { ParticipantAvatar( avatarUri = avatarUri, fallbackIcon = fallbackIcon, fallbackSize = 24.dp, fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, modifier = Modifier.matchParentSize(), ) @@ -295,6 +306,7 @@ private fun ParticipantAvatarWithQuickActions( avatarUri = avatarUri, fallbackIcon = fallbackIcon, fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, onDismiss = onDismissQuickActions, onMessageClick = onMessageClick, onCallClick = onCallClick, diff --git a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt index b4af421cd..3a7f73f44 100644 --- a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.common.components.participant.ParticipantAvatar import com.android.messaging.ui.common.components.participant.participantAvatarLabel +import com.android.messaging.ui.common.components.participant.participantColorSeed import com.android.messaging.ui.conversationsettings.screen.model.ParticipantUiState @OptIn(ExperimentalMaterial3Api::class) @@ -63,6 +64,9 @@ internal fun ConversationSettingsTopAppBar( fallbackLabel = participantAvatarLabel( source = participant?.displayName, ).takeUnless { isBlocked }, + colorSeedCode = participantColorSeed( + normalizedDestination = participant?.normalizedDestination, + ), ) Spacer(modifier = Modifier.width(12.dp)) diff --git a/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsDialogs.kt b/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsDialogs.kt index 5f2b75ac2..4f1737ee7 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsDialogs.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsDialogs.kt @@ -1,55 +1,17 @@ package com.android.messaging.ui.conversationsettings.screen -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Snooze import androidx.compose.material3.AlertDialog -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.data.conversationsettings.model.SnoozeOption +import com.android.messaging.ui.common.components.SnoozeChatDialog import com.android.messaging.ui.conversationsettings.screen.model.ConversationSettingsAction as Action import com.android.messaging.ui.conversationsettings.screen.model.ConversationSettingsUiState as State import com.android.messaging.ui.core.MessagingPreviewTheme -private val DialogHorizontalPadding = 24.dp - -private val SnoozeOption.labelRes: Int - get() = when (this) { - SnoozeOption.OneHour -> R.string.snooze_chat_option_one_hour - SnoozeOption.EightHours -> R.string.snooze_chat_option_eight_hours - SnoozeOption.TwentyFourHours -> R.string.snooze_chat_option_twenty_four_hours - SnoozeOption.Always -> R.string.snooze_chat_option_always - } - @Composable internal fun ConversationSettingsDialogs( uiState: State, @@ -110,133 +72,6 @@ private fun BlockConfirmationDialog( ) } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SnoozeChatDialog( - onDismiss: () -> Unit, - onConfirm: (SnoozeOption) -> Unit, -) { - var selectedOption by rememberSaveable { mutableStateOf(SnoozeOption.OneHour) } - - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Column(modifier = Modifier.padding(vertical = DialogHorizontalPadding)) { - SnoozeChatDialogHeader() - - Column(modifier = Modifier.selectableGroup()) { - SnoozeOption.entries.forEach { option -> - SnoozeOptionRow( - text = stringResource(option.labelRes), - selected = option == selectedOption, - onClick = { selectedOption = option }, - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - SnoozeChatDialogButtons( - onDismiss = onDismiss, - onConfirm = { onConfirm(selectedOption) }, - ) - } - } - } -} - -@Composable -private fun SnoozeChatDialogHeader() { - Icon( - imageVector = Icons.Default.Snooze, - contentDescription = stringResource(R.string.snooze_chat_setting_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.CenterHorizontally), - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.snooze_chat_dialog_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DialogHorizontalPadding), - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.snooze_chat_dialog_message), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = DialogHorizontalPadding), - ) - - Spacer(modifier = Modifier.height(16.dp)) -} - -@Composable -private fun SnoozeChatDialogButtons( - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DialogHorizontalPadding), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onDismiss) { - Text(text = stringResource(android.R.string.cancel)) - } - - TextButton(onClick = onConfirm) { - Text(text = stringResource(R.string.snooze_chat_dialog_confirm)) - } - } -} - -@Composable -private fun SnoozeOptionRow( - text: String, - selected: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selected, - onClick = onClick, - role = Role.RadioButton, - ) - .padding( - horizontal = DialogHorizontalPadding, - vertical = 12.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = selected, - onClick = null, - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = text, - style = MaterialTheme.typography.bodyLarge, - ) - } -} - @PreviewLightDark @Composable private fun BlockConfirmationDialogPreview() { @@ -248,14 +83,3 @@ private fun BlockConfirmationDialogPreview() { ) } } - -@PreviewLightDark -@Composable -private fun SnoozeChatDialogPreview() { - MessagingPreviewTheme { - SnoozeChatDialog( - onDismiss = {}, - onConfirm = {}, - ) - } -} From 404909a6d56ab97fb018ef5aa6439925b7e6a56a Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 20 Jun 2026 22:45:36 +0200 Subject: [PATCH 17/39] Add swipe actions to conversation list rows --- res/values/strings.xml | 1 + .../repository/ConversationsRepository.kt | 22 +++ .../store/ConversationReadStore.kt | 89 +++++++++++ .../conversation/ConversationBindsModule.kt | 8 + .../ConversationListActivityEffectHandler.kt | 2 +- .../redesign/ConversationListViewModel.kt | 46 ++++++ .../ConversationListActionsDelegate.kt | 18 +++ .../mapper/ConversationListUiStateMapper.kt | 2 +- .../redesign/model/ConversationListAction.kt | 8 + .../redesign/ui/ConversationListContent.kt | 47 +++--- .../redesign/ui/ConversationListScreen.kt | 5 +- .../ui/SwipeableConversationListItem.kt | 142 ++++++++++++++++++ 12 files changed, 369 insertions(+), 21 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/store/ConversationReadStore.kt create mode 100644 src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 37952cf22..5c18e9c92 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1160,4 +1160,5 @@ Alerts Mark as read + Mark as unread diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index c7ce28e66..7f404c291 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -10,6 +10,7 @@ import com.android.messaging.data.conversation.model.metadata.ConversationCompos import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.data.conversation.model.send.ConversationSendData import com.android.messaging.data.conversation.platform.MessageDetailsPlatformSource +import com.android.messaging.data.conversation.store.ConversationReadStore import com.android.messaging.data.conversation.store.ConversationSelfIdStore import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns @@ -68,6 +69,10 @@ internal interface ConversationsRepository { fun unarchiveConversation(conversationId: String) + suspend fun markConversationRead(conversationId: String) + + suspend fun markConversationUnread(conversationId: String) + fun deleteConversation(conversationId: String, cutoffTimestamp: Long) suspend fun setConversationSelfId(conversationId: String, selfId: String) @@ -78,6 +83,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( private val messageDetailsMapper: ConversationMessageDetailsMapper, private val messageDetailsPlatformSource: MessageDetailsPlatformSource, private val conversationSelfIdStore: ConversationSelfIdStore, + private val conversationReadStore: ConversationReadStore, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @param:MessagingDbDispatcher @@ -215,6 +221,22 @@ internal class ConversationsRepositoryImpl @Inject constructor( ?.let(UpdateConversationArchiveStatusAction::unarchiveConversation) } + override suspend fun markConversationRead(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + withContext(messagingDbDispatcher) { + conversationReadStore.markConversationRead(resolvedConversationId) + } + } + + override suspend fun markConversationUnread(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + withContext(messagingDbDispatcher) { + conversationReadStore.markConversationUnread(resolvedConversationId) + } + } + override fun deleteConversation(conversationId: String, cutoffTimestamp: Long) { if (conversationId.isBlank()) { return diff --git a/src/com/android/messaging/data/conversation/store/ConversationReadStore.kt b/src/com/android/messaging/data/conversation/store/ConversationReadStore.kt new file mode 100644 index 000000000..6b5e430ca --- /dev/null +++ b/src/com/android/messaging/data/conversation/store/ConversationReadStore.kt @@ -0,0 +1,89 @@ +package com.android.messaging.data.conversation.store + +import android.content.ContentValues +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseHelper +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.sms.MmsUtils +import com.android.messaging.util.PendingIntentConstants +import javax.inject.Inject + +internal interface ConversationReadStore { + fun markConversationRead(conversationId: String) + fun markConversationUnread(conversationId: String) +} + +internal class ConversationReadStoreImpl @Inject constructor() : ConversationReadStore { + + override fun markConversationRead(conversationId: String) { + val database = DataModel.get().database + + val threadId = BugleDatabaseOperations.getThreadId(database, conversationId) + if (threadId != -1L) { + MmsUtils.updateSmsReadStatus(threadId, Long.MAX_VALUE) + } + + database.beginTransaction() + try { + val values = ContentValues().apply { + put(MessageColumns.READ, 1) + put(MessageColumns.SEEN, 1) + } + + val selection = "(${MessageColumns.READ}!=1 OR ${MessageColumns.SEEN}!=1) AND " + + "${MessageColumns.CONVERSATION_ID}=?" + + val updatedRows = database.update( + DatabaseHelper.MESSAGES_TABLE, + values, + selection, + arrayOf(conversationId), + ) + + if (updatedRows > 0) { + MessagingContentProvider.notifyMessagesChanged(conversationId) + } + + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } + + BugleNotifications.cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, conversationId) + } + + override fun markConversationUnread(conversationId: String) { + val database = DataModel.get().database + + database.beginTransaction() + try { + val latestMessageId = BugleDatabaseOperations + .getQueryConversationsLatestMessageStatement(database, conversationId) + .simpleQueryForString() + + if (latestMessageId != null) { + val values = ContentValues().apply { + put(MessageColumns.READ, 0) + } + + val updatedRows = database.update( + DatabaseHelper.MESSAGES_TABLE, + values, + "${MessageColumns.READ}=1 AND ${MessageColumns._ID}=?", + arrayOf(latestMessageId), + ) + + if (updatedRows > 0) { + MessagingContentProvider.notifyMessagesChanged(conversationId) + } + } + + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } + } +} diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 9b8688009..bfe0a5dd3 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -24,6 +24,8 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl import com.android.messaging.data.conversation.store.ConversationDraftStore import com.android.messaging.data.conversation.store.ConversationDraftStoreImpl +import com.android.messaging.data.conversation.store.ConversationReadStore +import com.android.messaging.data.conversation.store.ConversationReadStoreImpl import com.android.messaging.data.conversation.store.ConversationSelfIdStore import com.android.messaging.data.conversation.store.ConversationSelfIdStoreImpl import com.android.messaging.data.media.repository.ConversationAttachmentsRepository @@ -125,6 +127,12 @@ internal abstract class ConversationBindsModule { impl: ConversationDraftStoreImpl, ): ConversationDraftStore + @Binds + @Reusable + abstract fun bindConversationReadStore( + impl: ConversationReadStoreImpl, + ): ConversationReadStore + @Binds @Reusable abstract fun bindConversationDraftsRepository( diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt index 38121ef21..6e447da1e 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt @@ -77,7 +77,7 @@ internal class ConversationListActivityEffectHandler( is Effect.ConfirmAddContact, is Effect.ConfirmBlock, is Effect.ConversationsArchived, - Effect.ScrollToTop, + is Effect.ScrollToTop, -> Unit } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index 83a9a9f01..e08a748af 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -24,8 +24,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch internal interface ConversationListScreenModel { val effects: Flow @@ -151,6 +153,14 @@ internal class ConversationListViewModel @Inject constructor( is Action.AvatarContactClicked -> { onAvatarContactClick(action.avatar) } + + is Action.ConversationSwipedToArchive -> { + onConversationSwipedToArchive(action.conversationId) + } + + is Action.ConversationSwipedToToggleRead -> { + onConversationSwipedToToggleRead(action.conversationId) + } } } @@ -165,6 +175,30 @@ internal class ConversationListViewModel @Inject constructor( ) } + private fun onConversationSwipedToArchive(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + actionsDelegate.setArchived( + conversationIds = listOf(resolvedConversationId), + isArchived = true, + shouldShowSnackbar = true, + ) + } + + private fun onConversationSwipedToToggleRead(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + val item = snapshot.value + ?.items + ?.firstOrNull { it.conversationId == resolvedConversationId } + ?: return + + when { + item.latestMessage.isRead -> actionsDelegate.markUnread(resolvedConversationId) + else -> actionsDelegate.markRead(resolvedConversationId) + } + } + private fun onNavigationAction(action: Action.NavigationAction) { when (action) { Action.ArchivedConversationsClicked -> { @@ -299,6 +333,18 @@ internal class ConversationListViewModel @Inject constructor( isArchived = !isArchived, shouldShowSnackbar = false, ) + + if (!isArchived || isScrollUpVisible.value) { + return + } + + viewModelScope.launch { + snapshot.filterNotNull().first { restoredSnapshot -> + restoredSnapshot.items.any { item -> item.conversationId in conversationIds } + } + + _effects.tryEmit(Effect.ScrollToTop) + } } private fun onBlockClick() { diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt index ad07aeab4..7b3b9736a 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt @@ -22,6 +22,8 @@ internal interface ConversationListActionsDelegate { shouldShowSnackbar: Boolean, ) + fun markRead(conversationId: String) + fun markUnread(conversationId: String) fun delete(items: List) fun block(item: ConversationListItem) fun unblock(conversationId: String, destination: String) @@ -78,6 +80,22 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( ) } + override fun markRead(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + boundScope?.launch { + conversationsRepository.markConversationRead(resolvedConversationId) + } + } + + override fun markUnread(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + + boundScope?.launch { + conversationsRepository.markConversationUnread(resolvedConversationId) + } + } + override fun delete(items: List) { items.forEach { item -> conversationsRepository.deleteConversation( diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index b9814b0e6..c2fc79e66 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -101,7 +101,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( timestampMillis = latestMessage.timestamp, status = toStatus(), isOutgoing = isOutgoing, - isUnread = !latestMessage.isRead && !isDraft, + isUnread = !latestMessage.isRead, isGroup = participant.isGroup, isEnterprise = participant.isEnterprise, isMuted = !notification.isEnabled, diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt index d5399bee4..5c585a598 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt @@ -39,6 +39,14 @@ internal sealed interface ConversationListAction { val avatar: ConversationListAvatarUiModel, ) : ListAction + data class ConversationSwipedToArchive( + val conversationId: String, + ) : ListAction + + data class ConversationSwipedToToggleRead( + val conversationId: String, + ) : ListAction + data class AddContactConfirmed( val destination: String, ) : DialogAction diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt index 660bf40c0..777ca6f4d 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt @@ -107,28 +107,39 @@ private fun ConversationListItems( ) { item -> val destination = item.avatar.normalizedDestination - ConversationListItemRow( + SwipeableConversationListItem( item = item, - onClick = { - onAction(Action.ConversationClicked(item.conversationId)) + isSelectionMode = isSelectionMode, + onArchive = { + onAction(Action.ConversationSwipedToArchive(item.conversationId)) }, - onLongClick = { - onAction(Action.ConversationLongClicked(item.conversationId)) + onToggleRead = { + onAction(Action.ConversationSwipedToToggleRead(item.conversationId)) }, modifier = Modifier.animateItem(), - isSelectionMode = isSelectionMode, - onAvatarMessageClick = { - onAction(Action.AvatarMessageClicked(item.conversationId)) - }, - onAvatarCallClick = { - if (destination != null) { - onAction(Action.AvatarCallClicked(destination)) - } - }.takeIf { item.avatar.canCall }, - onAvatarContactClick = { - onAction(Action.AvatarContactClicked(item.avatar)) - }.takeIf { item.avatar.canShowContact }, - ) + ) { + ConversationListItemRow( + item = item, + onClick = { + onAction(Action.ConversationClicked(item.conversationId)) + }, + onLongClick = { + onAction(Action.ConversationLongClicked(item.conversationId)) + }, + isSelectionMode = isSelectionMode, + onAvatarMessageClick = { + onAction(Action.AvatarMessageClicked(item.conversationId)) + }, + onAvatarCallClick = { + if (destination != null) { + onAction(Action.AvatarCallClicked(destination)) + } + }.takeIf { item.avatar.canCall }, + onAvatarContactClick = { + onAction(Action.AvatarContactClicked(item.avatar)) + }.takeIf { item.avatar.canShowContact }, + ) + } } } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt index 5eb327d61..abbf882dc 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt @@ -279,7 +279,10 @@ private fun ConversationListScaffold( ) }, snackbarHost = { - SnackbarHost(snackbarHostState) + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = FabBottomReserve), + ) }, ) { contentPadding -> Box( diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt new file mode 100644 index 000000000..650bfaa64 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt @@ -0,0 +1,142 @@ +package com.android.messaging.ui.conversationlist.redesign.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.MarkChatRead +import androidx.compose.material.icons.filled.MarkChatUnread +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxDefaults +import androidx.compose.material3.SwipeToDismissBoxState +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import kotlinx.coroutines.launch + +private val SwipeBackgroundShape = RoundedCornerShape(percent = 50) + +private val SwipeBackgroundHorizontalPadding = 24.dp + +@Composable +internal fun SwipeableConversationListItem( + item: ConversationListItemUiModel, + isSelectionMode: Boolean, + onArchive: () -> Unit, + onToggleRead: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val currentOnArchive by rememberUpdatedState(onArchive) + val currentOnToggleRead by rememberUpdatedState(onToggleRead) + val coroutineScope = rememberCoroutineScope() + + val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold + val dismissState = remember { + SwipeToDismissBoxState( + initialValue = SwipeToDismissBoxValue.Settled, + positionalThreshold = positionalThreshold, + ) + } + + val onDismiss = remember(dismissState, coroutineScope) { + { direction: SwipeToDismissBoxValue -> + when (direction) { + SwipeToDismissBoxValue.StartToEnd -> { + currentOnToggleRead() + coroutineScope.launch { dismissState.reset() } + Unit + } + + SwipeToDismissBoxValue.EndToStart -> currentOnArchive() + + SwipeToDismissBoxValue.Settled -> Unit + } + } + } + + SwipeToDismissBox( + state = dismissState, + modifier = modifier, + enableDismissFromStartToEnd = !isSelectionMode, + enableDismissFromEndToStart = !isSelectionMode, + onDismiss = onDismiss, + backgroundContent = { + ConversationListSwipeBackground( + direction = dismissState.dismissDirection, + isUnread = item.isUnread, + ) + }, + content = { content() }, + ) +} + +@Composable +private fun ConversationListSwipeBackground( + direction: SwipeToDismissBoxValue, + isUnread: Boolean, +) { + if (direction == SwipeToDismissBoxValue.Settled) { + Box(modifier = Modifier.fillMaxSize()) + return + } + + val isArchive = direction == SwipeToDismissBoxValue.EndToStart + + val containerColor = when { + isArchive -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.tertiaryContainer + } + + val contentColor = when { + isArchive -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onTertiaryContainer + } + + val alignment = when { + isArchive -> Alignment.CenterEnd + else -> Alignment.CenterStart + } + + val icon = when { + isArchive -> Icons.Filled.Archive + isUnread -> Icons.Filled.MarkChatRead + else -> Icons.Filled.MarkChatUnread + } + + val description = when { + isArchive -> stringResource(R.string.action_archive) + isUnread -> stringResource(R.string.mark_as_read) + else -> stringResource(R.string.mark_as_unread) + } + + Box( + modifier = Modifier + .fillMaxSize() + .clip(SwipeBackgroundShape) + .background(containerColor) + .padding(horizontal = SwipeBackgroundHorizontalPadding), + contentAlignment = alignment, + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = contentColor, + ) + } +} From 7d25fa7ea022d565313794048a7b8c5e272e8728 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sun, 21 Jun 2026 13:18:50 +0200 Subject: [PATCH 18/39] Rework conversation list selection toolbar --- res/values/strings.xml | 5 +- .../redesign/ConversationListViewModel.kt | 19 +++ .../ConversationListSelectionDelegate.kt | 15 +-- .../mapper/ConversationListUiStateMapper.kt | 20 +-- .../redesign/model/ConversationListAction.kt | 2 + .../model/ConversationListSelectionUiState.kt | 2 + .../ui/ConversationListSelectionTopAppBar.kt | 107 +++++++++++++--- .../ui/SwipeableConversationListItem.kt | 120 +++++++++++------- 8 files changed, 205 insertions(+), 85 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 5c18e9c92..9d818e4fe 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -57,10 +57,7 @@ Send to %s Choose media - - %d selected - %d selected - + %d Record audio Choose a contact Add (%1$d) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt index e08a748af..972aa3c95 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt @@ -233,12 +233,31 @@ internal class ConversationListViewModel @Inject constructor( Action.ArchiveClicked -> onArchiveClick(isArchived = true) Action.UnarchiveClicked -> onArchiveClick(isArchived = false) Action.BlockClicked -> onBlockClick() + Action.MarkReadClicked -> onMarkRead(isRead = true) + Action.MarkUnreadClicked -> onMarkRead(isRead = false) Action.SelectionCleared -> selectionDelegate.clear() Action.UnsnoozeClicked -> onUnsnoozeClick() is Action.SnoozeOptionSelected -> onSnoozeOptionSelected(action.option) } } + private fun onMarkRead(isRead: Boolean) { + val selectedItems = selectionDelegate.currentSelectedItems() + + if (selectedItems.isEmpty()) { + return + } + + selectedItems.forEach { item -> + when { + isRead -> actionsDelegate.markRead(item.conversationId) + else -> actionsDelegate.markUnread(item.conversationId) + } + } + + selectionDelegate.clear() + } + private fun onUnsnoozeClick() { val selectedItems = selectionDelegate.currentSelectedItems() diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt index 4e29c5246..d9bc6ab3f 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt @@ -4,11 +4,10 @@ import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentSet +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,7 +17,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch internal interface ConversationListSelectionDelegate { - val selectedIds: StateFlow> + val selectedIds: StateFlow> fun bind(scope: CoroutineScope, snapshotFlow: StateFlow) fun toggle(conversationId: String) @@ -30,8 +29,8 @@ internal interface ConversationListSelectionDelegate { internal class ConversationListSelectionDelegateImpl @Inject constructor() : ConversationListSelectionDelegate { - private val _selectedIds = MutableStateFlow>(persistentSetOf()) - override val selectedIds: StateFlow> = _selectedIds.asStateFlow() + private val _selectedIds = MutableStateFlow>(persistentListOf()) + override val selectedIds: StateFlow> = _selectedIds.asStateFlow() private var boundSnapshotFlow: StateFlow? = null @@ -64,7 +63,7 @@ internal class ConversationListSelectionDelegateImpl @Inject constructor() : } override fun clear() { - _selectedIds.value = persistentSetOf() + _selectedIds.value = persistentListOf() } override fun isSelectionActive(): Boolean { @@ -98,7 +97,7 @@ internal class ConversationListSelectionDelegateImpl @Inject constructor() : .filter { conversationId -> conversationId in visibleConversationIds } - .toPersistentSet() + .toPersistentList() } } } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt index c2fc79e66..8571677a1 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt @@ -19,13 +19,14 @@ import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversa import com.android.messaging.ui.conversationlist.redesign.model.SelectionActionsUiState import com.android.messaging.util.ContentType import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableList internal interface ConversationListUiStateMapper { fun map( snapshot: ConversationListSnapshot, - selectedConversationIds: ImmutableSet, + selectedConversationIds: ImmutableList, isScrollUpVisible: Boolean, isDebugEnabled: Boolean, ): ConversationListUiState @@ -41,7 +42,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( override fun map( snapshot: ConversationListSnapshot, - selectedConversationIds: ImmutableSet, + selectedConversationIds: ImmutableList, isScrollUpVisible: Boolean, isDebugEnabled: Boolean, ): ConversationListUiState { @@ -147,16 +148,14 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( private fun mapSelectionState( items: List, - selectedConversationIds: ImmutableSet, + selectedConversationIds: ImmutableList, blockedDestinations: ImmutableSet, ): ConversationListSelectionUiState { - val selectedConversations = items - .asSequence() - .filter { item -> - item.conversationId in selectedConversationIds + val itemsById = items.associateBy(ConversationListItem::conversationId) + val selectedConversations = selectedConversationIds + .mapNotNull { conversationId -> + itemsById[conversationId]?.let(::toSelectedConversation) } - .map(::toSelectedConversation) - .toList() .toImmutableList() return ConversationListSelectionUiState( @@ -179,6 +178,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( isGroup = item.participant.isGroup, isArchived = item.isArchived, isSnoozed = item.notification.isSnoozed, + isUnread = !item.latestMessage.isRead, ) } @@ -187,6 +187,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( blockedDestinations: ImmutableSet, ): SelectionActionsUiState { val singleSelection = selectedConversations.singleOrNull() + val firstSelected = selectedConversations.firstOrNull() val canAddContact = singleSelection?.let { conversation -> canAddContact( isGroup = conversation.isGroup, @@ -209,6 +210,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( canBlock = canBlock == true, canSnooze = selectedConversations.any { !it.isSnoozed }, canUnsnooze = selectedConversations.any { it.isSnoozed }, + isFirstSelectedUnread = firstSelected?.isUnread, ) } diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt index 5c585a598..ae0acd136 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt @@ -68,6 +68,8 @@ internal sealed interface ConversationListAction { data object AddContactClicked : SelectionAction data object ArchiveClicked : SelectionAction data object BlockClicked : SelectionAction + data object MarkReadClicked : SelectionAction + data object MarkUnreadClicked : SelectionAction data object SelectionCleared : SelectionAction data object UnarchiveClicked : SelectionAction data object UnsnoozeClicked : SelectionAction diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt index de7480795..61ee008db 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt @@ -19,6 +19,7 @@ internal data class SelectedConversationUiModel( val isGroup: Boolean, val isArchived: Boolean, val isSnoozed: Boolean, + val isUnread: Boolean, ) @Immutable @@ -30,4 +31,5 @@ internal data class SelectionActionsUiState( val canBlock: Boolean = false, val canSnooze: Boolean = false, val canUnsnooze: Boolean = false, + val isFirstSelectedUnread: Boolean? = null, ) diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt index 6e4063d36..a5ddd2307 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt @@ -2,13 +2,14 @@ package com.android.messaging.ui.conversationlist.redesign.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive -import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.NotificationsActive -import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Snooze import androidx.compose.material.icons.filled.Unarchive import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -18,8 +19,11 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -66,9 +70,8 @@ internal fun ConversationListSelectionTopAppBar( @Composable private fun ConversationListSelectionTitle(selectedCount: Int) { Text( - text = pluralStringResource( - id = R.plurals.conversation_message_selection_title, - count = selectedCount, + text = stringResource( + R.string.conversation_message_selection_title, selectedCount, ), maxLines = 1, @@ -115,29 +118,90 @@ private fun ConversationListSelectionActions( ) } - if (actions.canAddContact) { + if (actions.canDelete) { SelectionActionButton( - imageVector = Icons.Default.PersonAdd, - labelResId = R.string.action_add_contact, - onClick = { onAction(Action.AddContactClicked) }, + imageVector = Icons.Default.Delete, + labelResId = R.string.action_delete, + onClick = onDeleteClick, ) } - if (actions.canBlock) { - SelectionActionButton( - imageVector = Icons.Default.Block, - labelResId = R.string.action_block, - onClick = { onAction(Action.BlockClicked) }, - ) + SelectionOverflowMenu( + actions = actions, + onAction = onAction, + ) +} + +@Composable +private fun SelectionOverflowMenu( + actions: SelectionActionsUiState, + onAction: (Action) -> Unit, +) { + var isExpanded by remember { + mutableStateOf(value = false) } - if (actions.canDelete) { - SelectionActionButton( - imageVector = Icons.Default.Delete, - labelResId = R.string.action_delete, - onClick = onDeleteClick, + IconButton(onClick = { isExpanded = true }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options), ) } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + ) { + actions.isFirstSelectedUnread?.let { isUnread -> + SelectionMenuItem( + labelResId = when { + isUnread -> R.string.mark_as_read + else -> R.string.mark_as_unread + }, + onClick = { + val action = when { + isUnread -> Action.MarkReadClicked + else -> Action.MarkUnreadClicked + } + onAction(action) + isExpanded = false + }, + ) + } + + if (actions.canAddContact) { + SelectionMenuItem( + labelResId = R.string.action_add_contact, + onClick = { + onAction(Action.AddContactClicked) + isExpanded = false + }, + ) + } + + if (actions.canBlock) { + SelectionMenuItem( + labelResId = R.string.action_block, + onClick = { + onAction(Action.BlockClicked) + isExpanded = false + }, + ) + } + } +} + +@Composable +private fun SelectionMenuItem( + labelResId: Int, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text(text = stringResource(labelResId)) + }, + onClick = onClick, + ) } @Composable @@ -176,6 +240,7 @@ private fun ConversationListSelectionTopAppBarPreview() { canAddContact = true, canBlock = true, canSnooze = true, + isFirstSelectedUnread = true, ), onAction = {}, onDeleteClick = {}, diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt b/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt index 650bfaa64..2f57a622e 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt +++ b/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt @@ -1,8 +1,10 @@ package com.android.messaging.ui.conversationlist.redesign.ui +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -11,28 +13,38 @@ import androidx.compose.material.icons.filled.MarkChatRead import androidx.compose.material.icons.filled.MarkChatUnread import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxDefaults -import androidx.compose.material3.SwipeToDismissBoxState -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import kotlin.math.roundToInt import kotlinx.coroutines.launch private val SwipeBackgroundShape = RoundedCornerShape(percent = 50) private val SwipeBackgroundHorizontalPadding = 24.dp +private val SwipeActionThreshold = 88.dp + +private enum class ConversationSwipeAction { + Archive, + ToggleRead, + None, +} + @Composable internal fun SwipeableConversationListItem( item: ConversationListItemUiModel, @@ -42,61 +54,84 @@ internal fun SwipeableConversationListItem( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { + val density = LocalDensity.current val currentOnArchive by rememberUpdatedState(onArchive) val currentOnToggleRead by rememberUpdatedState(onToggleRead) val coroutineScope = rememberCoroutineScope() - val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold - val dismissState = remember { - SwipeToDismissBoxState( - initialValue = SwipeToDismissBoxValue.Settled, - positionalThreshold = positionalThreshold, - ) + val offsetX = remember { Animatable(0f) } + val draggedOffsetX = remember { mutableFloatStateOf(0f) } + val backgroundAction by remember { derivedStateOf { swipeAction(offsetX.value) } } + + val thresholdPx = with(density) { SwipeActionThreshold.toPx() } + + val gestureModifier = when { + isSelectionMode -> Modifier + + else -> Modifier.pointerInput(Unit) { + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + change.consume() + draggedOffsetX.floatValue += dragAmount + coroutineScope.launch { offsetX.snapTo(draggedOffsetX.floatValue) } + }, + onDragEnd = { + when { + draggedOffsetX.floatValue <= -thresholdPx -> currentOnArchive() + draggedOffsetX.floatValue >= thresholdPx -> currentOnToggleRead() + } + + draggedOffsetX.floatValue = 0f + coroutineScope.launch { offsetX.animateTo(0f) } + }, + onDragCancel = { + draggedOffsetX.floatValue = 0f + coroutineScope.launch { offsetX.animateTo(0f) } + }, + ) + } } - val onDismiss = remember(dismissState, coroutineScope) { - { direction: SwipeToDismissBoxValue -> - when (direction) { - SwipeToDismissBoxValue.StartToEnd -> { - currentOnToggleRead() - coroutineScope.launch { dismissState.reset() } - Unit - } - - SwipeToDismissBoxValue.EndToStart -> currentOnArchive() + Box(modifier = modifier.then(gestureModifier)) { + ConversationListSwipeBackground( + action = backgroundAction, + isUnread = item.isUnread, + modifier = Modifier.matchParentSize(), + ) - SwipeToDismissBoxValue.Settled -> Unit - } + Box( + modifier = Modifier.offset { + IntOffset( + x = offsetX.value.roundToInt(), + y = 0, + ) + }, + ) { + content() } } +} - SwipeToDismissBox( - state = dismissState, - modifier = modifier, - enableDismissFromStartToEnd = !isSelectionMode, - enableDismissFromEndToStart = !isSelectionMode, - onDismiss = onDismiss, - backgroundContent = { - ConversationListSwipeBackground( - direction = dismissState.dismissDirection, - isUnread = item.isUnread, - ) - }, - content = { content() }, - ) +private fun swipeAction(offset: Float): ConversationSwipeAction { + return when { + offset > 0f -> ConversationSwipeAction.ToggleRead + offset < 0f -> ConversationSwipeAction.Archive + else -> ConversationSwipeAction.None + } } @Composable private fun ConversationListSwipeBackground( - direction: SwipeToDismissBoxValue, + action: ConversationSwipeAction, isUnread: Boolean, + modifier: Modifier = Modifier, ) { - if (direction == SwipeToDismissBoxValue.Settled) { - Box(modifier = Modifier.fillMaxSize()) + if (action == ConversationSwipeAction.None) { + Box(modifier = modifier) return } - val isArchive = direction == SwipeToDismissBoxValue.EndToStart + val isArchive = action == ConversationSwipeAction.Archive val containerColor = when { isArchive -> MaterialTheme.colorScheme.secondaryContainer @@ -126,8 +161,7 @@ private fun ConversationListSwipeBackground( } Box( - modifier = Modifier - .fillMaxSize() + modifier = modifier .clip(SwipeBackgroundShape) .background(containerColor) .padding(horizontal = SwipeBackgroundHorizontalPadding), From 55e6c767b653793d920d0be9c86d6933f43499f4 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sun, 21 Jun 2026 14:49:22 +0200 Subject: [PATCH 19/39] Add pin conversation support to the conversation list --- res/values/strings.xml | 4 ++ res/values/versions.xml | 2 +- .../repository/ConversationsRepository.kt | 22 ++++++++ .../store/ConversationPinStore.kt | 50 +++++++++++++++++ .../model/ConversationListItem.kt | 1 + .../repository/ConversationListRepository.kt | 1 + .../datamodel/BugleDatabaseOperations.java | 10 ++++ .../messaging/datamodel/DatabaseHelper.java | 10 ++++ .../datamodel/DatabaseUpgradeHelper.java | 10 ++++ .../datamodel/data/ConversationListData.java | 3 +- .../data/ConversationListItemData.java | 11 ++++ .../conversation/ConversationBindsModule.kt | 8 +++ .../screen/ConversationSelectionTopAppBar.kt | 6 +-- .../redesign/ConversationListViewModel.kt | 23 ++++++-- .../ConversationListActionsDelegate.kt | 27 ++++++++++ .../mapper/ConversationListUiStateMapper.kt | 7 +-- .../redesign/model/ConversationListAction.kt | 3 +- .../model/ConversationListItemUiModel.kt | 1 + .../model/ConversationListSelectionUiState.kt | 6 +-- .../redesign/ui/ConversationListItemRow.kt | 5 ++ .../ui/ConversationListPreviewSupport.kt | 2 + .../ui/ConversationListSelectionTopAppBar.kt | 54 +++++++++++-------- 22 files changed, 227 insertions(+), 39 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/store/ConversationPinStore.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 9d818e4fe..188d6e347 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -268,6 +268,10 @@ Archive Unarchive + + Pin + + Unpin Turn off notifications diff --git a/res/values/versions.xml b/res/values/versions.xml index 14c6b4753..a02137223 100644 --- a/res/values/versions.xml +++ b/res/values/versions.xml @@ -16,7 +16,7 @@ --> - 2 + 3 - \ No newline at end of file diff --git a/res/menu/conversation_list_fragment_menu.xml b/res/menu/conversation_list_fragment_menu.xml deleted file mode 100644 index b6648464d..000000000 --- a/res/menu/conversation_list_fragment_menu.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt index 910d3e7cf..6ca5419cb 100644 --- a/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt +++ b/src/com/android/messaging/di/conversationlist/ConversationListBindsModule.kt @@ -4,8 +4,8 @@ import com.android.messaging.data.conversationlist.repository.ConversationListRe import com.android.messaging.data.conversationlist.repository.ConversationListRepositoryImpl import com.android.messaging.data.conversationlist.store.ConversationListStatusStore import com.android.messaging.data.conversationlist.store.ConversationListStatusStoreImpl -import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper -import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapperImpl +import com.android.messaging.ui.conversationlist.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.mapper.ConversationListUiStateMapperImpl import dagger.Binds import dagger.Module import dagger.Reusable diff --git a/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt b/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt index e63e90fe1..66dd1c3e7 100644 --- a/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversationlist/ConversationListViewModelBindsModule.kt @@ -1,11 +1,11 @@ package com.android.messaging.di.conversationlist -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegate -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegateImpl -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListOptimisticSnapshotDelegate -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListOptimisticSnapshotDelegateImpl -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegate -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegateImpl +import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegateImpl +import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegateImpl +import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegateImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt index 4e0d968eb..126884638 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import com.android.messaging.ui.BugleComponentActivity -import com.android.messaging.ui.conversationlist.redesign.ui.ConversationListScreen +import com.android.messaging.ui.conversationlist.ui.ConversationListScreen import com.android.messaging.ui.core.AppTheme import dagger.hilt.android.AndroidEntryPoint diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt index b586054bf..8fc00c735 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt @@ -5,8 +5,8 @@ import android.graphics.Point import android.view.View import androidx.core.net.toUri import com.android.messaging.ui.UIIntents -import com.android.messaging.ui.conversationlist.redesign.ConversationListEffectHandler -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect +import com.android.messaging.ui.conversationlist.ConversationListEffectHandler +import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect import com.android.messaging.util.ContactUtil import com.android.messaging.util.DebugUtils diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListEffectHandler.kt new file mode 100644 index 000000000..e170e23a0 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListEffectHandler.kt @@ -0,0 +1,7 @@ +package com.android.messaging.ui.conversationlist + +import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect + +internal interface ConversationListEffectHandler { + fun handle(effect: Effect) +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java index 30951df8c..61ee26dfe 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java +++ b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java @@ -16,7 +16,6 @@ package com.android.messaging.ui.conversationlist; import android.app.Activity; -import android.content.Context; import android.database.Cursor; import android.graphics.Rect; import android.net.Uri; @@ -29,15 +28,11 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.ViewGroup.MarginLayoutParams; import android.view.ViewPropertyAnimator; -import android.view.accessibility.AccessibilityManager; import android.widget.AbsListView; import android.widget.ImageView; @@ -71,9 +66,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi private static final String BUNDLE_ARCHIVED_MODE = "archived_mode"; private static final boolean VERBOSE = false; - private MenuItem mShowBlockedMenuItem; private boolean mArchiveMode; - private boolean mBlockedAvailable; public interface ConversationListFragmentHost { public void onConversationClick(final ConversationListData listData, @@ -233,7 +226,6 @@ public void onClick(final View clickView) { // show explode animation themselves, so we explicitly tag the root view to be a non-group. ViewGroupCompat.setTransitionGroup(rootView, false); - setHasOptionsMenu(true); return rootView; } @@ -287,49 +279,12 @@ public void onConversationListCursorUpdated(final ConversationListData data, @Override public void setBlockedParticipantsAvailable(final boolean blockedAvailable) { - mBlockedAvailable = blockedAvailable; - if (mShowBlockedMenuItem != null) { - mShowBlockedMenuItem.setVisible(blockedAvailable); - } } public void updateUi() { mAdapter.notifyDataSetChanged(); } - @Override - public void onPrepareOptionsMenu(final Menu menu) { - super.onPrepareOptionsMenu(menu); - final MenuItem startNewConversationMenuItem = - menu.findItem(R.id.action_start_new_conversation); - if (startNewConversationMenuItem != null) { - // It is recommended for the Floating Action button functionality to be duplicated as a - // menu - AccessibilityManager accessibilityManager = (AccessibilityManager) - getActivity().getSystemService(Context.ACCESSIBILITY_SERVICE); - startNewConversationMenuItem.setVisible(accessibilityManager - .isTouchExplorationEnabled()); - } - - final MenuItem archive = menu.findItem(R.id.action_show_archived); - if (archive != null) { - archive.setVisible(true); - } - } - - @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { - if (!isAdded()) { - // Guard against being called before we're added to the activity - return; - } - - mShowBlockedMenuItem = menu.findItem(R.id.action_show_blocked_contacts); - if (mShowBlockedMenuItem != null) { - mShowBlockedMenuItem.setVisible(mBlockedAvailable); - } - } - /** * {@inheritDoc} from ConversationListItemView.HostInterface */ diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt similarity index 94% rename from src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt rename to src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt index 89a99d717..f74ac609c 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign +package com.android.messaging.ui.conversationlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -7,14 +7,14 @@ import com.android.messaging.data.conversationlist.model.ConversationListSnapsho import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.debug.DebugFeaturesProvider -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListActionsDelegate -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListOptimisticSnapshotDelegate -import com.android.messaging.ui.conversationlist.redesign.delegate.ConversationListSelectionDelegate -import com.android.messaging.ui.conversationlist.redesign.mapper.ConversationListUiStateMapper -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State +import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate +import com.android.messaging.ui.conversationlist.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect +import com.android.messaging.ui.conversationlist.model.ConversationListUiState as State import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt similarity index 97% rename from src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt rename to src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt index 5df7374ba..f18f71329 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListActionsDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt @@ -1,11 +1,11 @@ -package com.android.messaging.ui.conversationlist.redesign.delegate +package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.conversationsettings.model.SnoozeOption -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect +import com.android.messaging.ui.conversationlist.model.ConversationListEffect import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticOverrides.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticOverrides.kt similarity index 93% rename from src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticOverrides.kt rename to src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticOverrides.kt index b800c26ea..b3948b7bb 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticOverrides.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticOverrides.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.delegate +package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem import kotlinx.collections.immutable.PersistentMap diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticReducer.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducer.kt similarity index 98% rename from src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticReducer.kt rename to src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducer.kt index cebf2c9bd..d255f73ff 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticReducer.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducer.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.delegate +package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem import dagger.Reusable diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticSnapshotDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt similarity index 98% rename from src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticSnapshotDelegate.kt rename to src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt index cedc538fb..83e1eb958 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListOptimisticSnapshotDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.delegate +package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot diff --git a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt similarity index 98% rename from src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt rename to src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt index d9bc6ab3f..27819b744 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/delegate/ConversationListSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.delegate +package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot diff --git a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt similarity index 92% rename from src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt rename to src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt index df0a91dcc..0bf567a33 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.mapper +package com.android.messaging.ui.conversationlist.mapper import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus @@ -8,15 +8,15 @@ import com.android.messaging.domain.conversation.usecase.participant.CanAddConta import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSelectionUiState -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSnippetUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState -import com.android.messaging.ui.conversationlist.redesign.model.SelectedConversationUiModel -import com.android.messaging.ui.conversationlist.redesign.model.SelectionActionsUiState +import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListPreviewUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListSelectionUiState +import com.android.messaging.ui.conversationlist.model.ConversationListSnippetUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListUiState +import com.android.messaging.ui.conversationlist.model.SelectedConversationUiModel +import com.android.messaging.ui.conversationlist.model.SelectionActionsUiState import com.android.messaging.util.ContentType import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt similarity index 97% rename from src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt rename to src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt index 73cdf9820..2771c7a19 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.model +package com.android.messaging.ui.conversationlist.model import com.android.messaging.data.conversationsettings.model.SnoozeOption import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListEffect.kt similarity index 96% rename from src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt rename to src/com/android/messaging/ui/conversationlist/model/ConversationListEffect.kt index a0d41359a..73709aac0 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListEffect.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListEffect.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.model +package com.android.messaging.ui.conversationlist.model import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt similarity index 97% rename from src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt rename to src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt index bee95eef4..01c026984 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListItemUiModel.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.model +package com.android.messaging.ui.conversationlist.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListSelectionUiState.kt similarity index 94% rename from src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt rename to src/com/android/messaging/ui/conversationlist/model/ConversationListSelectionUiState.kt index be1aa7d42..0dcc6a5df 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListSelectionUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListSelectionUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.model +package com.android.messaging.ui.conversationlist.model import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt similarity index 93% rename from src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt rename to src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt index abc9032bb..afc677494 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.model +package com.android.messaging.ui.conversationlist.model import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/redesign/ConversationListEffectHandler.kt deleted file mode 100644 index 0907dd8e2..000000000 --- a/src/com/android/messaging/ui/conversationlist/redesign/ConversationListEffectHandler.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.android.messaging.ui.conversationlist.redesign - -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect - -internal interface ConversationListEffectHandler { - fun handle(effect: Effect) -} diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt similarity index 97% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt index 1ea22ac7e..bac82386b 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold @@ -37,9 +37,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.android.messaging.R import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimationController -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import com.android.messaging.ui.core.MessagingPreviewTheme import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt similarity index 97% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt index 5af16e333..842315f8e 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListDialogs.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text @@ -9,7 +9,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.messaging.R import com.android.messaging.ui.common.components.SnoozeChatDialog -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action import com.android.messaging.ui.core.MessagingPreviewTheme @Composable diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemAvatar.kt similarity index 96% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListItemAvatar.kt index 08fa9b6ef..645cffc11 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemAvatar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemAvatar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -19,7 +19,7 @@ import com.android.messaging.ui.common.components.participant.ParticipantQuickAc import com.android.messaging.ui.common.components.participant.participantAvatarLabel import com.android.messaging.ui.common.components.participant.participantColorSeed import com.android.messaging.ui.common.components.selection.SelectionListAvatar -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel @Composable internal fun ConversationListItemAvatar( diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt similarity index 98% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt index 20d1d9232..48c370f4b 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -37,8 +37,8 @@ import com.android.messaging.R import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus import com.android.messaging.ui.common.components.TwoLineListItem import com.android.messaging.ui.common.components.attachment.MediaThumbnail -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListPreviewUiModel import com.android.messaging.ui.core.MessagingPreviewColumn import com.android.messaging.util.Dates diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListPreviewSupport.kt similarity index 88% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListPreviewSupport.kt index 5b1c52788..4b6bc36a0 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListPreviewSupport.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListPreviewSupport.kt @@ -1,10 +1,10 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAvatarUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListPreviewUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListSnippetUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListPreviewUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListSnippetUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt similarity index 95% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt index 7a04308b9..1ef9f781d 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import android.content.Context import androidx.activity.compose.BackHandler @@ -58,14 +58,14 @@ import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimatio import com.android.messaging.ui.common.components.reorder.rememberOverlayReorderAnimationController import com.android.messaging.ui.common.components.snackbar.MessagingSnackbarHost import com.android.messaging.ui.common.components.snackbar.showActionSnackbar -import com.android.messaging.ui.conversationlist.redesign.ConversationListEffectHandler -import com.android.messaging.ui.conversationlist.redesign.ConversationListScreenModel -import com.android.messaging.ui.conversationlist.redesign.ConversationListViewModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListContentUiState -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListEffect as Effect -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListUiState as State +import com.android.messaging.ui.conversationlist.ConversationListEffectHandler +import com.android.messaging.ui.conversationlist.ConversationListScreenModel +import com.android.messaging.ui.conversationlist.ConversationListViewModel +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState +import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListUiState as State import com.android.messaging.ui.core.MessagingPreviewTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt similarity index 97% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt index 8af2afd1c..b64a580e0 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width @@ -32,8 +32,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action -import com.android.messaging.ui.conversationlist.redesign.model.SelectionActionsUiState +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.SelectionActionsUiState import com.android.messaging.ui.core.MessagingPreviewTheme private val OverflowMenuWidth = 220.dp diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTestTags.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTestTags.kt similarity index 76% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTestTags.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListTestTags.kt index 51f324849..0d619dd7a 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTestTags.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTestTags.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui internal const val CONVERSATION_LIST_TEST_TAG = "conversation_list" diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTokens.kt similarity index 83% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListTokens.kt index edeb6e480..312995a49 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTokens.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTokens.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.ui.unit.dp diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt similarity index 96% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt rename to src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt index 1b94da9c9..d6f11e49e 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/ConversationListTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action import com.android.messaging.ui.core.MessagingPreviewTheme private val OverflowMenuWidth = 220.dp diff --git a/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt similarity index 98% rename from src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt rename to src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt index b210fe5d9..4597163f9 100644 --- a/src/com/android/messaging/ui/conversationlist/redesign/ui/SwipeableConversationListItem.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.redesign.ui +package com.android.messaging.ui.conversationlist.ui import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec @@ -38,7 +38,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversationlist.redesign.model.ConversationListItemUiModel +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.Job From a3bb6bfa01b075e0778363835d6260775a9e5c75 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 24 Jun 2026 13:17:21 +0200 Subject: [PATCH 25/39] Fix conversation list interactions and clean up supporting APIs --- .../delegate/OptimisticTestFixtures.kt | 3 - .../ConversationListUiStateMapperImplTest.kt | 6 +- .../repository/AppSettingsRepository.kt | 5 +- .../model/ConversationListItem.kt | 3 - .../repository/ConversationListRepository.kt | 73 ++++++----- .../model/SnoozeOption.kt | 2 + .../ConversationNotificationRepository.kt | 5 +- .../ConversationSettingsRepository.kt | 3 +- ...CoreBindsModule.kt => DebugBindsModule.kt} | 2 +- .../common/components/PrimaryActionButton.kt | 13 +- .../ui/common/components/SnoozeChatDialog.kt | 8 +- .../reorder/OverlayReorderAnimation.kt | 116 ++++++++++++------ .../ui/ConversationListContent.kt | 27 ++-- 13 files changed, 174 insertions(+), 92 deletions(-) rename src/com/android/messaging/di/core/{CoreBindsModule.kt => DebugBindsModule.kt} (92%) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt index 2d8abfe16..f883319a5 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt @@ -39,10 +39,7 @@ internal fun conversationItem( contactId = -1L, lookupKey = null, otherNormalizedDestination = "+1555000$conversationId", - selfId = null, - count = 1, isGroup = false, - includeEmailAddress = false, isEnterprise = false, ), latestMessage = ConversationListLatestMessage( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt index e9951472f..f4dd11bae 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt @@ -7,6 +7,7 @@ import com.android.messaging.data.conversationlist.model.ConversationListMessage import com.android.messaging.data.conversationlist.model.ConversationListNotification import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationsettings.model.SNOOZE_NEVER_EXPIRES import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri import com.android.messaging.domain.conversation.usecase.participant.CanAddContact import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact @@ -176,10 +177,7 @@ class ConversationListUiStateMapperImplTest { contactId = -1L, lookupKey = null, otherNormalizedDestination = "+1555000$conversationId", - selfId = null, - count = 1, isGroup = false, - includeEmailAddress = false, isEnterprise = false, ), latestMessage = ConversationListLatestMessage( @@ -202,7 +200,7 @@ class ConversationListUiStateMapperImplTest { notification = ConversationListNotification( isEnabled = true, snoozedUntilMillis = when { - isSnoozed -> Long.MAX_VALUE + isSnoozed -> SNOOZE_NEVER_EXPIRES else -> ConversationListNotification.SNOOZE_NOT_SET }, ), diff --git a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt index 73988a0e6..8f3a736e4 100644 --- a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt +++ b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt @@ -4,9 +4,9 @@ import android.content.Context import com.android.messaging.R import com.android.messaging.data.appsettings.model.AppBooleanPref import com.android.messaging.data.appsettings.model.AppSettings +import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.BuglePrefs -import com.android.messaging.util.DebugUtils import com.android.messaging.util.PhoneUtils import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -21,6 +21,7 @@ internal interface AppSettingsRepository { internal class AppSettingsRepositoryImpl @Inject constructor( @param:ApplicationContext private val context: Context, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val debugFeaturesProvider: DebugFeaturesProvider, ) : AppSettingsRepository { override suspend fun getAppSettings(): AppSettings { @@ -36,7 +37,7 @@ internal class AppSettingsRepositoryImpl @Inject constructor( context.getString(R.string.send_sound_pref_key), resources.getBoolean(R.bool.send_sound_pref_default), ), - isDebugEnabled = DebugUtils.isDebugEnabled(), + isDebugEnabled = debugFeaturesProvider.isEnabled(), dumpSmsEnabled = appPrefs.getBoolean( context.getString(R.string.dump_sms_pref_key), resources.getBoolean(R.bool.dump_sms_pref_default), diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt index 80808f2a4..e4c440345 100644 --- a/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt +++ b/src/com/android/messaging/data/conversationlist/model/ConversationListItem.kt @@ -17,10 +17,7 @@ internal data class ConversationListParticipant( val contactId: Long, val lookupKey: String?, val otherNormalizedDestination: String?, - val selfId: String?, - val count: Int, val isGroup: Boolean, - val includeEmailAddress: Boolean, val isEnterprise: Boolean, ) diff --git a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt index 5f9b5a069..461ddbade 100644 --- a/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt +++ b/src/com/android/messaging/data/conversationlist/repository/ConversationListRepository.kt @@ -11,6 +11,7 @@ import com.android.messaging.data.conversationlist.model.ConversationListNotific import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.store.ConversationListStatusStore +import com.android.messaging.data.conversationsettings.model.SNOOZE_NEVER_EXPIRES import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepository import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns @@ -113,35 +114,56 @@ internal class ConversationListRepositoryImpl @Inject constructor( ): Flow> { return flow { while (true) { - val now = System.currentTimeMillis() - val resolved = items - .map { item -> - val snoozedUntilMillis = notificationRepository - .getSnoozeUntilMillis(item.conversationId) - .takeIf { it > now } - ?: ConversationListNotification.SNOOZE_NOT_SET - - item.copy( - notification = item.notification.copy( - snoozedUntilMillis = snoozedUntilMillis, - ), - ) - } - .toImmutableList() + val nowMillis = System.currentTimeMillis() + val resolved = resolveSnoozeState( + items = items, + nowMillis = nowMillis, + ) emit(resolved) - val nextExpiryMillis = resolved - .map { it.notification.snoozedUntilMillis } - .filter { expiry -> expiry > now && expiry != SNOOZE_NEVER_EXPIRES } - .minOrNull() - ?: break + val nextExpiryMillis = nextFiniteSnoozeExpiryMillis( + items = resolved, + nowMillis = nowMillis, + ) ?: break - delay(nextExpiryMillis - now) + delay(nextExpiryMillis - nowMillis) } } } + private fun resolveSnoozeState( + items: ImmutableList, + nowMillis: Long, + ): ImmutableList { + return items + .map { item -> + val snoozedUntilMillis = notificationRepository + .getSnoozeUntilMillis(item.conversationId) + .takeIf { it > nowMillis } + ?: ConversationListNotification.SNOOZE_NOT_SET + + item.copy( + notification = item.notification.copy( + snoozedUntilMillis = snoozedUntilMillis, + ), + ) + } + .toImmutableList() + } + + private fun nextFiniteSnoozeExpiryMillis( + items: ImmutableList, + nowMillis: Long, + ): Long? { + return items + .map { item -> item.notification.snoozedUntilMillis } + .filter { expiryMillis -> + expiryMillis > nowMillis && expiryMillis != SNOOZE_NEVER_EXPIRES + } + .minOrNull() + } + private fun observeUri(uri: Uri): Flow { return callbackFlow { val observer = object : ContentObserver(null) { @@ -202,9 +224,7 @@ internal class ConversationListRepositoryImpl @Inject constructor( } private fun ConversationListItemData.toConversationListItem(): ConversationListItem? { - val resolvedConversationId = conversationId - ?.takeIf(String::isNotBlank) - ?: return null + val resolvedConversationId = conversationId?.takeIf(String::isNotBlank) ?: return null return ConversationListItem( conversationId = resolvedConversationId, @@ -227,10 +247,7 @@ internal class ConversationListRepositoryImpl @Inject constructor( contactId = participantContactId, lookupKey = participantLookupKey, otherNormalizedDestination = otherParticipantNormalizedDestination, - selfId = selfId, - count = participantCount, isGroup = isGroup, - includeEmailAddress = includeEmailAddress, isEnterprise = isEnterprise, ) } @@ -295,8 +312,6 @@ internal class ConversationListRepositoryImpl @Inject constructor( } private companion object { - private const val SNOOZE_NEVER_EXPIRES = Long.MAX_VALUE - private val BLOCKED_PARTICIPANTS_PROJECTION = arrayOf( ParticipantColumns._ID, ParticipantColumns.NORMALIZED_DESTINATION, diff --git a/src/com/android/messaging/data/conversationsettings/model/SnoozeOption.kt b/src/com/android/messaging/data/conversationsettings/model/SnoozeOption.kt index 356337487..edb138e7b 100644 --- a/src/com/android/messaging/data/conversationsettings/model/SnoozeOption.kt +++ b/src/com/android/messaging/data/conversationsettings/model/SnoozeOption.kt @@ -3,6 +3,8 @@ package com.android.messaging.data.conversationsettings.model import kotlin.time.Duration import kotlin.time.Duration.Companion.hours +internal const val SNOOZE_NEVER_EXPIRES = Long.MAX_VALUE + internal enum class SnoozeOption( val duration: Duration, ) { diff --git a/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt b/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt index 80fb87119..0318ef9a2 100644 --- a/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt +++ b/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt @@ -2,6 +2,7 @@ package com.android.messaging.data.conversationsettings.repository import android.content.Context import android.content.SharedPreferences +import com.android.messaging.data.conversationsettings.model.SNOOZE_NEVER_EXPIRES import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.util.BuglePrefs import dagger.hilt.EntryPoint @@ -69,7 +70,7 @@ internal class ConversationNotificationRepositoryImpl @Inject constructor( override fun snooze(conversationId: String, option: SnoozeOption) { val prefs = BuglePrefs.getApplicationPrefs() val untilMillis = when (option) { - SnoozeOption.Always -> Long.MAX_VALUE + SnoozeOption.Always -> SNOOZE_NEVER_EXPIRES else -> addSafely(System.currentTimeMillis(), option.duration.inWholeMilliseconds) } prefs.putLong(snoozeKey(conversationId), untilMillis) @@ -91,7 +92,7 @@ internal class ConversationNotificationRepositoryImpl @Inject constructor( val result = base + delta return if (result < base) { - Long.MAX_VALUE + SNOOZE_NEVER_EXPIRES } else { result } diff --git a/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt b/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt index aaa7bc151..9a7c98bbf 100644 --- a/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt +++ b/src/com/android/messaging/data/conversationsettings/repository/ConversationSettingsRepository.kt @@ -7,6 +7,7 @@ import android.database.ContentObserver import android.net.Uri import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversationsettings.model.ConversationSettingsData +import com.android.messaging.data.conversationsettings.model.SNOOZE_NEVER_EXPIRES import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.ConversationParticipantsData import com.android.messaging.datamodel.data.ParticipantData @@ -66,7 +67,7 @@ internal class ConversationSettingsRepositoryImpl @Inject constructor( ): Flow { return flow { val snoozeUntilMillis = notificationRepository.getSnoozeUntilMillis(conversationId) - if (snoozeUntilMillis == Long.MAX_VALUE) return@flow + if (snoozeUntilMillis == SNOOZE_NEVER_EXPIRES) return@flow val remaining = snoozeUntilMillis - System.currentTimeMillis() if (remaining <= 0L) return@flow diff --git a/src/com/android/messaging/di/core/CoreBindsModule.kt b/src/com/android/messaging/di/core/DebugBindsModule.kt similarity index 92% rename from src/com/android/messaging/di/core/CoreBindsModule.kt rename to src/com/android/messaging/di/core/DebugBindsModule.kt index d02c121c1..e5430dcda 100644 --- a/src/com/android/messaging/di/core/CoreBindsModule.kt +++ b/src/com/android/messaging/di/core/DebugBindsModule.kt @@ -10,7 +10,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -internal abstract class CoreBindsModule { +internal abstract class DebugBindsModule { @Binds @Reusable diff --git a/src/com/android/messaging/ui/common/components/PrimaryActionButton.kt b/src/com/android/messaging/ui/common/components/PrimaryActionButton.kt index a578cff0d..255afaafb 100644 --- a/src/com/android/messaging/ui/common/components/PrimaryActionButton.kt +++ b/src/com/android/messaging/ui/common/components/PrimaryActionButton.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.android.messaging.ui.core.MessagingPreviewColumn @@ -44,6 +46,7 @@ internal fun PrimaryActionButton( testTag: String? = null, shape: Shape = PrimaryActionButtonShape, ) { + val isInteractionEnabled = enabled && !isLoading val colorScheme = MaterialTheme.colorScheme val containerColor = when { enabled -> colorScheme.primaryContainer @@ -55,9 +58,15 @@ internal fun PrimaryActionButton( } ExtendedFloatingActionButton( - modifier = modifier.optionalTestTag(testTag), + modifier = modifier + .optionalTestTag(testTag) + .semantics { + if (!isInteractionEnabled) { + disabled() + } + }, onClick = { - if (enabled && !isLoading) { + if (isInteractionEnabled) { onClick() } }, diff --git a/src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt b/src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt index 0b5e724c8..3b0cf17fa 100644 --- a/src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt +++ b/src/com/android/messaging/ui/common/components/SnoozeChatDialog.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Snooze import androidx.compose.material3.BasicAlertDialog @@ -60,7 +62,11 @@ internal fun SnoozeChatDialog( shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - Column(modifier = Modifier.padding(vertical = DialogHorizontalPadding)) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = DialogHorizontalPadding), + ) { SnoozeChatDialogHeader() Column(modifier = Modifier.selectableGroup()) { diff --git a/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt b/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt index 8b8c6c6ad..88effc403 100644 --- a/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt +++ b/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -32,13 +32,22 @@ import androidx.compose.ui.zIndex import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull -private const val REORDER_OVERLAY_DURATION_MILLIS = 320 +private const val REORDER_OVERLAY_DURATION_MILLIS = 220 private const val REORDER_OVERLAY_Z_INDEX = 10f -private const val OFFSCREEN_FADE_DURATION_MILLIS = 120 +private const val OVERLAY_SHORT_PHASE_DURATION_MILLIS = 120 + +private const val OVERLAY_REORDER_PEAK_SCALE = 1.05f + +private val OverlayReorderPeakElevation = 8.dp + +private const val TARGET_RESOLUTION_TIMEOUT_MILLIS = 250L @Stable internal class OverlayReorderAnimationController( @@ -55,7 +64,7 @@ internal class OverlayReorderAnimationController( private val itemBoundsByKey = mutableMapOf() private var nextAnimationId = 0L - internal var animations by mutableStateOf>>(emptyList()) + internal var animations by mutableStateOf>>(emptyList()) private set fun updateContainerBounds(bounds: Rect) { @@ -157,7 +166,7 @@ internal class OverlayReorderAnimationController( } } - fun startAnimation(animationId: Long): OverlayReorderState? { + fun startAnimation(animationId: Long): OverlayReorderState? { val animationIndex = animations.indexOfFirst { animation -> animation.animationId == animationId } @@ -166,6 +175,11 @@ internal class OverlayReorderAnimationController( return null } + val animation = animations[animationIndex] + if (!animation.isCommitted || animation.isStarted) { + return null + } + updateAnimationAt(animationIndex) { state -> state.copy(isStarted = true) } @@ -173,7 +187,13 @@ internal class OverlayReorderAnimationController( return animations[animationIndex] } - fun fallbackTarget(animation: OverlayReorderState): Rect { + fun currentAnimation(animationId: Long): OverlayReorderState? { + return animations.firstOrNull { animation -> + animation.animationId == animationId + } + } + + fun fallbackTarget(animation: OverlayReorderState): Rect { return geometry.fallbackTarget( sourceBounds = animation.sourceBounds, anchorToTop = animation.anchorToTop, @@ -190,7 +210,7 @@ internal class OverlayReorderAnimationController( private fun updateAnimationAt( index: Int, - transform: (OverlayReorderState) -> OverlayReorderState, + transform: (OverlayReorderState) -> OverlayReorderState, ) { animations = animations.toMutableList().apply { this[index] = transform(this[index]) @@ -198,9 +218,9 @@ internal class OverlayReorderAnimationController( } } -internal data class OverlayReorderState( +internal data class OverlayReorderState( val animationId: Long, - val key: Any, + val key: K, val item: T, val sourceBounds: Rect, val sourceIndex: Int, @@ -219,8 +239,8 @@ internal fun rememberOverlayReorderAnimationController( } @Composable -internal fun OverlayReorderAnimation( - controller: OverlayReorderAnimationController, +internal fun OverlayReorderAnimation( + controller: OverlayReorderAnimationController, modifier: Modifier = Modifier, itemContent: @Composable (T) -> Unit, ) { @@ -238,14 +258,14 @@ internal fun OverlayReorderAnimation( } @Composable -private fun OverlayReorderItem( - animation: OverlayReorderState, - controller: OverlayReorderAnimationController, +private fun OverlayReorderItem( + animation: OverlayReorderState, + controller: OverlayReorderAnimationController, itemContent: @Composable (T) -> Unit, ) { val density = LocalDensity.current val alpha = remember(animation.animationId) { Animatable(1f) } - val scale = remember(animation.animationId) { Animatable(1.05f) } + val scale = remember(animation.animationId) { Animatable(1f) } val position = remember(animation.animationId) { Animatable(animation.sourceBounds.topLeft, Offset.VectorConverter) } @@ -275,19 +295,22 @@ private fun OverlayReorderItem( .height(with(density) { animation.sourceBounds.height.toDp() }) .zIndex(REORDER_OVERLAY_Z_INDEX) .graphicsLayer { + val scaleProgress = ((scale.value - 1f) / (OVERLAY_REORDER_PEAK_SCALE - 1f)) + .coerceIn(0f, 1f) + this.alpha = alpha.value scaleX = scale.value scaleY = scale.value - shadowElevation = 8.dp.toPx() + shadowElevation = OverlayReorderPeakElevation.toPx() * scaleProgress }, ) { itemContent(animation.item) } } -private suspend fun runOverlayReorderAnimation( - controller: OverlayReorderAnimationController, - animation: OverlayReorderState, +private suspend fun runOverlayReorderAnimation( + controller: OverlayReorderAnimationController, + animation: OverlayReorderState, position: Animatable, alpha: Animatable, scale: Animatable, @@ -296,11 +319,19 @@ private suspend fun runOverlayReorderAnimation( return } - withFrameNanos { } + val awaitedTargetBounds = withTimeoutOrNull(TARGET_RESOLUTION_TIMEOUT_MILLIS) { + snapshotFlow { + controller.currentAnimation(animation.animationId)?.targetBounds + } + .filterNotNull() + .first() + } val latestAnimation = controller.startAnimation(animation.animationId) ?: return - val targetBounds = latestAnimation.targetBounds ?: controller.fallbackTarget(latestAnimation) - val usesFallbackTarget = latestAnimation.targetBounds == null + val targetBounds = awaitedTargetBounds + ?: latestAnimation.targetBounds + ?: controller.fallbackTarget(latestAnimation) + val usesFallbackTarget = awaitedTargetBounds == null && latestAnimation.targetBounds == null val isAtTargetHorizontally = abs(targetBounds.left - animation.sourceBounds.left) <= TARGET_POSITION_EPSILON_PX @@ -308,8 +339,12 @@ private suspend fun runOverlayReorderAnimation( abs(targetBounds.top - animation.sourceBounds.top) <= TARGET_POSITION_EPSILON_PX val isAlreadyAtTarget = isAtTargetHorizontally && isAtTargetVertically - if (!isAlreadyAtTarget) { - coroutineScope { + coroutineScope { + launch { + animateOverlayScale(scale) + } + + if (!isAlreadyAtTarget) { launch { position.animateTo( targetValue = targetBounds.topLeft, @@ -328,24 +363,35 @@ private suspend fun runOverlayReorderAnimation( alpha.animateTo( targetValue = 0f, animationSpec = tween( - durationMillis = OFFSCREEN_FADE_DURATION_MILLIS, + durationMillis = OVERLAY_SHORT_PHASE_DURATION_MILLIS, delayMillis = REORDER_OVERLAY_DURATION_MILLIS - - OFFSCREEN_FADE_DURATION_MILLIS, + OVERLAY_SHORT_PHASE_DURATION_MILLIS, ), ) } } - - launch { - scale.animateTo( - targetValue = 1f, - animationSpec = tween( - durationMillis = REORDER_OVERLAY_DURATION_MILLIS, - ), - ) - } } } controller.finish(animation.animationId) } + +private suspend fun animateOverlayScale( + scale: Animatable, +) { + scale.animateTo( + targetValue = OVERLAY_REORDER_PEAK_SCALE, + animationSpec = tween( + durationMillis = OVERLAY_SHORT_PHASE_DURATION_MILLIS, + easing = FastOutSlowInEasing, + ), + ) + scale.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = REORDER_OVERLAY_DURATION_MILLIS - + OVERLAY_SHORT_PHASE_DURATION_MILLIS, + easing = FastOutSlowInEasing, + ), + ) +} diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt index bac82386b..b35eb2f3c 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt @@ -289,6 +289,11 @@ private fun KeepViewportStationaryOnPinChange( val hasSameConversationIds = previousItems.size == items.size && previousItems.all { item -> item.conversationId in currentItemsById } + val hasPinStateChange = previousItems.any { previousItem -> + currentItemsById[previousItem.conversationId]?.isPinned != previousItem.isPinned + } + val wasAtStart = listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 val firstVisibleConversationId = listState.layoutInfo .visibleItemsInfo .firstOrNull() @@ -301,15 +306,19 @@ private fun KeepViewportStationaryOnPinChange( val firstVisibleItemPinChanged = previousFirstVisibleItem?.isPinned != currentFirstVisibleItem?.isPinned - if ( - hasSameConversationIds && - previousFirstVisibleIndex >= 0 && - firstVisibleItemPinChanged - ) { - listState.requestScrollToItem( - index = previousFirstVisibleIndex, - scrollOffset = listState.firstVisibleItemScrollOffset, - ) + if (hasSameConversationIds && hasPinStateChange) { + when { + wasAtStart -> { + listState.requestScrollToItem(index = 0) + } + + previousFirstVisibleIndex >= 0 && firstVisibleItemPinChanged -> { + listState.requestScrollToItem( + index = previousFirstVisibleIndex, + scrollOffset = listState.firstVisibleItemScrollOffset, + ) + } + } } previousItemsState.value = items From 3472732ec13c9762c5fbbe08db92ccbe05e006aa Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 24 Jun 2026 13:41:40 +0200 Subject: [PATCH 26/39] Simplify conversation list selection and action state model --- .../ConversationListUiStateMapperImplTest.kt | 62 ++++++++++----- .../ConversationListViewModel.kt | 39 ++++++---- .../ConversationListActionsDelegate.kt | 3 +- .../mapper/ConversationListUiStateMapper.kt | 77 ++++++------------- .../model/ConversationListAction.kt | 24 ++++-- .../model/ConversationListEffect.kt | 3 +- .../model/ConversationListItemUiModel.kt | 31 +++----- .../model/ConversationListSelectionUiState.kt | 25 +----- .../model/ConversationListUiState.kt | 2 +- .../ui/ConversationListDialogs.kt | 12 ++- .../ui/ConversationListItemAvatar.kt | 2 +- .../ui/ConversationListItemRow.kt | 44 ++++++++--- .../ui/ConversationListPreviewSupport.kt | 10 +-- .../ui/ConversationListScreen.kt | 37 +++++---- .../ui/ConversationListSelectionTopAppBar.kt | 38 ++++----- .../ui/ConversationListTopAppBar.kt | 2 +- 16 files changed, 209 insertions(+), 202 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt index f4dd11bae..2a7af1cb8 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt @@ -46,7 +46,7 @@ class ConversationListUiStateMapperImplTest { ), ), selectedConversationIds = persistentListOf(), - isScrollUpVisible = false, + isScrollToTopVisible = false, isDebugEnabled = false, ) @@ -63,7 +63,7 @@ class ConversationListUiStateMapperImplTest { ), ), selectedConversationIds = persistentListOf(), - isScrollUpVisible = false, + isScrollToTopVisible = false, isDebugEnabled = false, ) @@ -75,15 +75,15 @@ class ConversationListUiStateMapperImplTest { val state = mapper.map( snapshot = snapshotOf(conversationItem(conversationId = "a")), selectedConversationIds = persistentListOf(), - isScrollUpVisible = false, + isScrollToTopVisible = false, isDebugEnabled = false, ) val actions = state.selection.actions - assertFalse(state.selection.isActive) - assertNull(actions.isFirstSelectedPinned) - assertNull(actions.isFirstSelectedSnoozed) - assertNull(actions.isFirstSelectedUnread) + assertEquals(0, state.selection.selectedCount) + assertNull(actions.firstSelectedIsPinned) + assertNull(actions.firstSelectedIsSnoozed) + assertNull(actions.firstSelectedIsUnread) } @Test @@ -98,14 +98,14 @@ class ConversationListUiStateMapperImplTest { ), ), selectedConversationIds = persistentListOf("selected"), - isScrollUpVisible = false, + isScrollToTopVisible = false, isDebugEnabled = false, ) val actions = state.selection.actions - assertEquals(true, actions.isFirstSelectedPinned) - assertEquals(true, actions.isFirstSelectedSnoozed) - assertEquals(true, actions.isFirstSelectedUnread) + assertEquals(true, actions.firstSelectedIsPinned) + assertEquals(true, actions.firstSelectedIsSnoozed) + assertEquals(true, actions.firstSelectedIsUnread) } @Test @@ -124,26 +124,45 @@ class ConversationListUiStateMapperImplTest { ), ), selectedConversationIds = persistentListOf("first", "second"), - isScrollUpVisible = false, + isScrollToTopVisible = false, isDebugEnabled = false, ) val actions = state.selection.actions - assertEquals(false, actions.isFirstSelectedPinned) - assertEquals(false, actions.isFirstSelectedSnoozed) + assertEquals(false, actions.firstSelectedIsPinned) + assertEquals(false, actions.firstSelectedIsSnoozed) } @Test - fun map_selection_canArchiveBecauseInboxItemsAreNotArchived() { + fun map_selection_exposesSelectedCount() { val state = mapper.map( - snapshot = snapshotOf(conversationItem(conversationId = "a")), - selectedConversationIds = persistentListOf("a"), - isScrollUpVisible = false, + snapshot = snapshotOf( + conversationItem(conversationId = "a"), + conversationItem(conversationId = "b"), + ), + selectedConversationIds = persistentListOf("a", "b"), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertEquals(2, state.selection.selectedCount) + } + + @Test + fun map_senderName_preservesItForSnippetAccessibility() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "group", + senderName = "Jane", + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, isDebugEnabled = false, ) - assertTrue(state.selection.actions.canArchive) - assertTrue(state.selection.actions.canDelete) + assertEquals("Jane", singleItem(state).snippet.senderName) } private fun singleItem( @@ -165,6 +184,7 @@ class ConversationListUiStateMapperImplTest { isPinned: Boolean = false, isSnoozed: Boolean = false, isRead: Boolean = true, + senderName: String? = null, ): ConversationListItem { return ConversationListItem( conversationId = conversationId, @@ -188,7 +208,7 @@ class ConversationListUiStateMapperImplTest { previewContentType = null, status = ConversationListMessageStatus.Normal, isIncoming = true, - senderName = null, + senderName = senderName, ), draft = ConversationListDraft( isVisible = false, diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt index f74ac609c..980145a3e 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt @@ -47,7 +47,7 @@ internal class ConversationListViewModel @Inject constructor( ) : ViewModel(), ConversationListScreenModel { - private val isScrollUpVisible = MutableStateFlow(false) + private val isScrollToTopVisible = MutableStateFlow(false) private val isDebugEnabled = MutableStateFlow(debugFeaturesProvider.isEnabled()) private val snapshot: StateFlow = optimisticSnapshotDelegate.snapshot @@ -61,13 +61,13 @@ internal class ConversationListViewModel @Inject constructor( override val uiState: StateFlow = combine( snapshot.filterNotNull(), selectionDelegate.selectedIds, - isScrollUpVisible, + isScrollToTopVisible, isDebugEnabled, - ) { snapshot, selectedIds, isScrollUpVisible, isDebugEnabled -> + ) { snapshot, selectedIds, isScrollToTopVisible, isDebugEnabled -> uiStateMapper.map( snapshot = snapshot, selectedConversationIds = selectedIds, - isScrollUpVisible = isScrollUpVisible, + isScrollToTopVisible = isScrollToTopVisible, isDebugEnabled = isDebugEnabled, ) }.stateIn( @@ -93,28 +93,37 @@ internal class ConversationListViewModel @Inject constructor( override fun onAction(action: Action) { when (action) { - is Action.DialogAction -> onDialogAction(action) + is Action.ConfirmationAction -> onConfirmationAction(action) is Action.LifecycleAction -> onLifecycleAction(action) is Action.ListAction -> onListAction(action) is Action.NavigationAction -> onNavigationAction(action) is Action.SelectionAction -> onSelectionAction(action) + is Action.SnackbarAction -> onSnackbarAction(action) } } - private fun onDialogAction(action: Action.DialogAction) { + private fun onConfirmationAction(action: Action.ConfirmationAction) { when (action) { is Action.AddContactConfirmed -> { onAddContactConfirmed(action.destination) } is Action.BlockConfirmed -> { - onBlockConfirmed() + onBlockConfirmed( + conversationId = action.conversationId, + destination = action.destination, + ) } is Action.DeleteConfirmed -> { onDeleteConfirmed() } + } + } + + private fun onSnackbarAction(action: Action.SnackbarAction) { + when (action) { is Action.ArchiveUndoClicked -> { onArchiveUndoClicked( conversationIds = action.conversationIds, @@ -138,12 +147,12 @@ internal class ConversationListViewModel @Inject constructor( selectionDelegate.clear() } - private fun onBlockConfirmed() { - val selectedItem = singleSelectedItem() ?: return - val destination = singleSelectedDestination() ?: return - + private fun onBlockConfirmed( + conversationId: String, + destination: String, + ) { actionsDelegate.block( - conversationId = selectedItem.conversationId, + conversationId = conversationId, destination = destination, ) selectionDelegate.clear() @@ -246,11 +255,11 @@ internal class ConversationListViewModel @Inject constructor( } private fun onNewestConversationVisibilityChanged(isVisible: Boolean) { - if (isScrollUpVisible.value == !isVisible) { + if (isScrollToTopVisible.value == !isVisible) { return } - isScrollUpVisible.value = !isVisible + isScrollToTopVisible.value = !isVisible repository.setNewestConversationVisible(isVisible) } @@ -308,7 +317,7 @@ internal class ConversationListViewModel @Inject constructor( _effects.tryEmit(Effect.OpenDebugOptions) } - Action.ScrollUpClicked -> { + Action.ScrollToTopClicked -> { _effects.tryEmit(Effect.ScrollToTop) } diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt index f18f71329..95f7c1fe1 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt @@ -76,9 +76,8 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( } _effects.tryEmit( - ConversationListEffect.ConversationsArchived( + ConversationListEffect.ArchiveStatusChanged( conversationIds = resolvedConversationIds.toImmutableList(), - count = resolvedConversationIds.size, isArchived = isArchived, ), ) diff --git a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt index 0bf567a33..9fa1a6c80 100644 --- a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt @@ -15,7 +15,6 @@ import com.android.messaging.ui.conversationlist.model.ConversationListPreviewUi import com.android.messaging.ui.conversationlist.model.ConversationListSelectionUiState import com.android.messaging.ui.conversationlist.model.ConversationListSnippetUiModel import com.android.messaging.ui.conversationlist.model.ConversationListUiState -import com.android.messaging.ui.conversationlist.model.SelectedConversationUiModel import com.android.messaging.ui.conversationlist.model.SelectionActionsUiState import com.android.messaging.util.ContentType import javax.inject.Inject @@ -27,7 +26,7 @@ internal interface ConversationListUiStateMapper { fun map( snapshot: ConversationListSnapshot, selectedConversationIds: ImmutableList, - isScrollUpVisible: Boolean, + isScrollToTopVisible: Boolean, isDebugEnabled: Boolean, ): ConversationListUiState } @@ -43,7 +42,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( override fun map( snapshot: ConversationListSnapshot, selectedConversationIds: ImmutableList, - isScrollUpVisible: Boolean, + isScrollToTopVisible: Boolean, isDebugEnabled: Boolean, ): ConversationListUiState { val items = snapshot.items @@ -76,7 +75,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( return ConversationListUiState( content = content, selection = selection, - isScrollUpVisible = isScrollUpVisible, + isScrollToTopVisible = isScrollToTopVisible, hasBlockedParticipants = snapshot.blockedDestinations.isNotEmpty(), isDebugEnabled = isDebugEnabled, ) @@ -103,11 +102,9 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( status = toStatus(), isOutgoing = isOutgoing, isUnread = !latestMessage.isRead, - isGroup = participant.isGroup, isEnterprise = participant.isEnterprise, isMuted = !notification.isEnabled, isSnoozed = notification.isSnoozed, - isArchived = isArchived, isPinned = isPinned, isSelected = isSelected, ) @@ -133,7 +130,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( lookupKey = participant.lookupKey, normalizedDestination = destination, isGroup = participant.isGroup, - details = destination.takeIf { isOneOnOne }, + subtitle = destination.takeIf { isOneOnOne }, canCall = isOneOnOne && canPlacePhoneCall(destination), canShowContact = canShowContact, isContactSaved = isContactSaved, @@ -153,65 +150,46 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( blockedDestinations: ImmutableSet, ): ConversationListSelectionUiState { val itemsById = items.associateBy(ConversationListItem::conversationId) - val selectedConversations = selectedConversationIds + val selectedItems = selectedConversationIds .mapNotNull { conversationId -> - itemsById[conversationId]?.let(::toSelectedConversation) + itemsById[conversationId] } - .toImmutableList() return ConversationListSelectionUiState( - selectedConversations = selectedConversations, + selectedCount = selectedItems.size, actions = mapSelectionActions( - selectedConversations = selectedConversations, + selectedItems = selectedItems, blockedDestinations = blockedDestinations, ), - isActive = selectedConversations.isNotEmpty(), - ) - } - - private fun toSelectedConversation( - item: ConversationListItem, - ): SelectedConversationUiModel { - return SelectedConversationUiModel( - conversationId = item.conversationId, - normalizedDestination = item.participant.otherNormalizedDestination, - participantLookupKey = item.participant.lookupKey, - isGroup = item.participant.isGroup, - isArchived = item.isArchived, - isPinned = item.isPinned, - isSnoozed = item.notification.isSnoozed, - isUnread = !item.latestMessage.isRead, ) } private fun mapSelectionActions( - selectedConversations: List, + selectedItems: List, blockedDestinations: ImmutableSet, ): SelectionActionsUiState { - val singleSelection = selectedConversations.singleOrNull() - val firstSelected = selectedConversations.firstOrNull() - val canAddContact = singleSelection?.let { conversation -> + val singleSelection = selectedItems.singleOrNull() + val firstSelected = selectedItems.firstOrNull() + val canAddContact = singleSelection?.participant?.let { participant -> canAddContact( - isGroup = conversation.isGroup, - lookupKey = conversation.participantLookupKey, - destination = conversation.normalizedDestination, + isGroup = participant.isGroup, + lookupKey = participant.lookupKey, + destination = participant.otherNormalizedDestination, ) } - val canBlock = singleSelection?.let { conversation -> + val canBlock = singleSelection?.let { item -> canBlock( - destination = conversation.normalizedDestination, + destination = item.participant.otherNormalizedDestination, blockedDestinations = blockedDestinations, ) } return SelectionActionsUiState( - canArchive = selectedConversations.any { !it.isArchived }, - canDelete = selectedConversations.isNotEmpty(), canAddContact = canAddContact == true, canBlock = canBlock == true, - isFirstSelectedPinned = firstSelected?.isPinned, - isFirstSelectedSnoozed = firstSelected?.isSnoozed, - isFirstSelectedUnread = firstSelected?.isUnread, + firstSelectedIsPinned = firstSelected?.isPinned, + firstSelectedIsSnoozed = firstSelected?.notification?.isSnoozed, + firstSelectedIsUnread = firstSelected?.latestMessage?.isRead?.not(), ) } @@ -267,10 +245,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( ): ConversationListPreviewUiModel { return when { ContentType.isAudioType(contentType) -> { - ConversationListPreviewUiModel.Audio( - contentUri = contentUri, - contentType = contentType, - ) + ConversationListPreviewUiModel.Audio } ContentType.isImageType(contentType) -> { @@ -288,17 +263,11 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( } ContentType.isVCardType(contentType) -> { - ConversationListPreviewUiModel.VCard( - contentUri = contentUri, - contentType = contentType, - ) + ConversationListPreviewUiModel.VCard } else -> { - ConversationListPreviewUiModel.File( - contentUri = contentUri, - contentType = contentType, - ) + ConversationListPreviewUiModel.File } } } diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt index 2771c7a19..232b87f0c 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt @@ -5,7 +5,7 @@ import kotlinx.collections.immutable.ImmutableList internal sealed interface ConversationListAction { - sealed interface DialogAction : ConversationListAction + sealed interface ConfirmationAction : ConversationListAction sealed interface LifecycleAction : ConversationListAction @@ -15,23 +15,31 @@ internal sealed interface ConversationListAction { sealed interface SelectionAction : ConversationListAction - // region DialogAction - data object BlockConfirmed : DialogAction - data object DeleteConfirmed : DialogAction + sealed interface SnackbarAction : ConversationListAction + + // region ConfirmationAction + data class BlockConfirmed( + val conversationId: String, + val destination: String, + ) : ConfirmationAction + + data object DeleteConfirmed : ConfirmationAction data class AddContactConfirmed( val destination: String, - ) : DialogAction + ) : ConfirmationAction + // endregion + // region SnackbarAction data class ArchiveUndoClicked( val conversationIds: ImmutableList, val isArchived: Boolean, - ) : DialogAction + ) : SnackbarAction data class BlockUndoClicked( val conversationId: String, val destination: String, - ) : DialogAction + ) : SnackbarAction // endregion // region LifecycleAction @@ -80,7 +88,7 @@ internal sealed interface ConversationListAction { data object ArchivedConversationsClicked : NavigationAction data object BlockedParticipantsClicked : NavigationAction data object DebugOptionsClicked : NavigationAction - data object ScrollUpClicked : NavigationAction + data object ScrollToTopClicked : NavigationAction data object SettingsClicked : NavigationAction data object StartChatClicked : NavigationAction // endregion diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListEffect.kt index 73709aac0..30b7cf4fc 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListEffect.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListEffect.kt @@ -11,9 +11,8 @@ internal sealed interface ConversationListEffect { data object OpenDebugOptions : ConversationListEffect data object ScrollToTop : ConversationListEffect - data class ConversationsArchived( + data class ArchiveStatusChanged( val conversationIds: ImmutableList, - val count: Int, val isArchived: Boolean, ) : ConversationListEffect diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt index 01c026984..987f49823 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListItemUiModel.kt @@ -14,11 +14,9 @@ internal data class ConversationListItemUiModel( val status: ConversationListMessageStatus, val isOutgoing: Boolean, val isUnread: Boolean, - val isGroup: Boolean, val isEnterprise: Boolean, val isMuted: Boolean, val isSnoozed: Boolean, - val isArchived: Boolean, val isPinned: Boolean, val isSelected: Boolean, ) @@ -30,7 +28,7 @@ internal data class ConversationListAvatarUiModel( val lookupKey: String?, val normalizedDestination: String?, val isGroup: Boolean, - val details: String?, + val subtitle: String?, val canCall: Boolean, val canShowContact: Boolean, val isContactSaved: Boolean, @@ -46,36 +44,25 @@ internal data class ConversationListSnippetUiModel( @Immutable internal sealed interface ConversationListPreviewUiModel { - val contentUri: String - val contentType: String @Immutable - data class Audio( - override val contentUri: String, - override val contentType: String, - ) : ConversationListPreviewUiModel + data object Audio : ConversationListPreviewUiModel @Immutable - data class File( - override val contentUri: String, - override val contentType: String, - ) : ConversationListPreviewUiModel + data object File : ConversationListPreviewUiModel @Immutable - data class Image( - override val contentUri: String, - override val contentType: String, - ) : ConversationListPreviewUiModel + data object VCard : ConversationListPreviewUiModel @Immutable - data class VCard( - override val contentUri: String, - override val contentType: String, + data class Image( + val contentUri: String, + val contentType: String, ) : ConversationListPreviewUiModel @Immutable data class Video( - override val contentUri: String, - override val contentType: String, + val contentUri: String, + val contentType: String, ) : ConversationListPreviewUiModel } diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListSelectionUiState.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListSelectionUiState.kt index 0dcc6a5df..0202afbdf 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListSelectionUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListSelectionUiState.kt @@ -1,35 +1,18 @@ package com.android.messaging.ui.conversationlist.model import androidx.compose.runtime.Immutable -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationListSelectionUiState( - val selectedConversations: ImmutableList = persistentListOf(), + val selectedCount: Int = 0, val actions: SelectionActionsUiState = SelectionActionsUiState(), - val isActive: Boolean = false, -) - -@Immutable -internal data class SelectedConversationUiModel( - val conversationId: String, - val normalizedDestination: String?, - val participantLookupKey: String?, - val isGroup: Boolean, - val isArchived: Boolean, - val isPinned: Boolean, - val isSnoozed: Boolean, - val isUnread: Boolean, ) @Immutable internal data class SelectionActionsUiState( - val canArchive: Boolean = false, - val canDelete: Boolean = false, val canAddContact: Boolean = false, val canBlock: Boolean = false, - val isFirstSelectedPinned: Boolean? = null, - val isFirstSelectedSnoozed: Boolean? = null, - val isFirstSelectedUnread: Boolean? = null, + val firstSelectedIsPinned: Boolean? = null, + val firstSelectedIsSnoozed: Boolean? = null, + val firstSelectedIsUnread: Boolean? = null, ) diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt index afc677494..75139453c 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt @@ -7,7 +7,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class ConversationListUiState( val content: ConversationListContentUiState = ConversationListContentUiState.Loading, val selection: ConversationListSelectionUiState = ConversationListSelectionUiState(), - val isScrollUpVisible: Boolean = false, + val isScrollToTopVisible: Boolean = false, val hasBlockedParticipants: Boolean = false, val isDebugEnabled: Boolean = false, ) diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt index 842315f8e..0904d31d8 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt @@ -17,6 +17,7 @@ internal fun ConversationListDialogs( selectedCount: Int, addContactDestination: String?, isDeleteVisible: Boolean, + blockConversationId: String?, blockDestination: String?, isSnoozeVisible: Boolean, onAction: (Action) -> Unit, @@ -47,12 +48,17 @@ internal fun ConversationListDialogs( ) } - blockDestination?.let { destination -> + if (blockConversationId != null && blockDestination != null) { ConversationListBlockDialog( - destination = destination, + destination = blockDestination, onConfirm = { onDismissBlock() - onAction(Action.BlockConfirmed) + onAction( + Action.BlockConfirmed( + conversationId = blockConversationId, + destination = blockDestination, + ), + ) }, onDismiss = onDismissBlock, ) diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemAvatar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemAvatar.kt index 645cffc11..d84f307c6 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemAvatar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemAvatar.kt @@ -98,7 +98,7 @@ private fun ConversationListAvatarQuickActions( visible = visible, avatarUri = item.avatar.uri, displayName = item.title.orEmpty(), - subtitle = item.avatar.details, + subtitle = item.avatar.subtitle, fallbackIcon = fallbackIcon, fallbackLabel = fallbackLabel, colorSeedCode = colorSeedCode, diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt index 48c370f4b..fe709ecf3 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt @@ -227,13 +227,30 @@ private fun ConversationListItemBadgeIcon(icon: ImageVector) { @Composable private fun ConversationListItemPreviewThumbnail(preview: ConversationListPreviewUiModel?) { - val isVisual = preview is ConversationListPreviewUiModel.Image || - preview is ConversationListPreviewUiModel.Video + when (preview) { + is ConversationListPreviewUiModel.Image -> { + ConversationListVisualPreviewThumbnail( + contentUri = preview.contentUri, + contentType = preview.contentType, + ) + } + + is ConversationListPreviewUiModel.Video -> { + ConversationListVisualPreviewThumbnail( + contentUri = preview.contentUri, + contentType = preview.contentType, + ) + } - if (preview == null || !isVisual) { - return + else -> Unit } +} +@Composable +private fun ConversationListVisualPreviewThumbnail( + contentUri: String, + contentType: String, +) { val thumbnailSizePx = with(LocalDensity.current) { ItemPreviewThumbnailSize.roundToPx() } @@ -242,8 +259,8 @@ private fun ConversationListItemPreviewThumbnail(preview: ConversationListPrevie modifier = Modifier .size(ItemPreviewThumbnailSize) .clip(MaterialTheme.shapes.extraSmall), - contentUri = preview.contentUri, - contentType = preview.contentType, + contentUri = contentUri, + contentType = contentType, size = IntSize( width = thumbnailSizePx, height = thumbnailSizePx, @@ -297,7 +314,15 @@ private fun itemSnippetText(item: ConversationListItemUiModel): String? { val snippetText = item.snippet.text?.takeIf(String::isNotBlank) if (snippetText != null) { - return snippetText + val senderName = item.snippet.senderName?.takeIf(String::isNotBlank) + + return when { + item.avatar.isGroup && !item.isOutgoing && senderName != null -> { + "$senderName: $snippetText" + } + + else -> snippetText + } } return when (item.snippet.preview) { @@ -470,10 +495,7 @@ private fun ConversationListItemRowBadgesPreview() { conversationId = "audio", title = "Sara Lindberg", snippetText = null, - preview = ConversationListPreviewUiModel.Audio( - contentUri = "content://preview/audio", - contentType = "audio/mp3", - ), + preview = ConversationListPreviewUiModel.Audio, ), onClick = {}, onLongClick = {}, diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListPreviewSupport.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListPreviewSupport.kt index 4b6bc36a0..c26b6be34 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListPreviewSupport.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListPreviewSupport.kt @@ -14,6 +14,7 @@ internal fun previewConversationListItem( conversationId: String, title: String, snippetText: String?, + senderName: String? = null, status: ConversationListMessageStatus = ConversationListMessageStatus.Normal, preview: ConversationListPreviewUiModel? = null, subject: String? = null, @@ -38,14 +39,14 @@ internal fun previewConversationListItem( lookupKey = null, normalizedDestination = normalizedDestination, isGroup = isGroup, - details = normalizedDestination, + subtitle = normalizedDestination, canCall = !isGroup, canShowContact = !isGroup, isContactSaved = false, ), snippet = ConversationListSnippetUiModel( text = snippetText, - senderName = null, + senderName = senderName, preview = preview, isDraft = isDraft, ), @@ -54,11 +55,9 @@ internal fun previewConversationListItem( status = status, isOutgoing = isOutgoing, isUnread = isUnread, - isGroup = isGroup, isEnterprise = isEnterprise, isMuted = isMuted, isSnoozed = isSnoozed, - isArchived = false, isPinned = isPinned, isSelected = isSelected, ) @@ -87,7 +86,8 @@ internal fun previewConversationListItems(): ImmutableList(null) } var pendingDelete by rememberSaveable { mutableStateOf(false) } + var pendingBlockConversationId by rememberSaveable { mutableStateOf(null) } var pendingBlockDestination by rememberSaveable { mutableStateOf(null) } var pendingSnooze by rememberSaveable { mutableStateOf(false) } @@ -107,7 +108,10 @@ internal fun ConversationListScreen( pinAnimationController = pinAnimationController, onAction = screenModel::onAction, onConfirmAddContact = { pendingAddContactDestination = it }, - onConfirmBlock = { pendingBlockDestination = it }, + onConfirmBlock = { conversationId, destination -> + pendingBlockConversationId = conversationId + pendingBlockDestination = destination + }, ) Box( @@ -125,7 +129,7 @@ internal fun ConversationListScreen( onAction = screenModel::onAction, onDeleteClick = { pendingDelete = true }, onSnoozeClick = { pendingSnooze = true }, - onScrollToTop = { screenModel.onAction(Action.ScrollUpClicked) }, + onScrollToTop = { screenModel.onAction(Action.ScrollToTopClicked) }, modifier = Modifier.fillMaxSize(), ) @@ -133,15 +137,19 @@ internal fun ConversationListScreen( } ConversationListDialogs( - selectedCount = uiState.selection.selectedConversations.size, + selectedCount = uiState.selection.selectedCount, addContactDestination = pendingAddContactDestination, isDeleteVisible = pendingDelete, + blockConversationId = pendingBlockConversationId, blockDestination = pendingBlockDestination, isSnoozeVisible = pendingSnooze, onAction = screenModel::onAction, onDismissAddContact = { pendingAddContactDestination = null }, onDismissDelete = { pendingDelete = false }, - onDismissBlock = { pendingBlockDestination = null }, + onDismissBlock = { + pendingBlockConversationId = null + pendingBlockDestination = null + }, onDismissSnooze = { pendingSnooze = false }, ) } @@ -168,7 +176,7 @@ private fun ConversationListEffects( pinAnimationController: OverlayReorderAnimationController, onAction: (Action) -> Unit, onConfirmAddContact: (String) -> Unit, - onConfirmBlock: (String) -> Unit, + onConfirmBlock: (conversationId: String, destination: String) -> Unit, ) { val context = LocalContext.current val undoLabel = stringResource(R.string.snack_bar_undo) @@ -193,10 +201,13 @@ private fun ConversationListEffects( } is Effect.ConfirmBlock -> { - currentOnConfirmBlock(effect.destination) + currentOnConfirmBlock( + effect.conversationId, + effect.destination, + ) } - is Effect.ConversationsArchived -> { + is Effect.ArchiveStatusChanged -> { snackbarScope.launchArchivedSnackbar( snackbarHostState = snackbarHostState, context = currentContext, @@ -264,7 +275,7 @@ private fun CoroutineScope.launchArchivedSnackbar( snackbarHostState: SnackbarHostState, context: Context, undoLabel: String, - effect: Effect.ConversationsArchived, + effect: Effect.ArchiveStatusChanged, onAction: (Action) -> Unit, ) { val messageResId = when { @@ -274,7 +285,7 @@ private fun CoroutineScope.launchArchivedSnackbar( launch { val undoClicked = snackbarHostState.showActionSnackbar( - message = context.getString(messageResId, effect.count), + message = context.getString(messageResId, effect.conversationIds.size), actionLabel = undoLabel, ) @@ -329,7 +340,7 @@ private fun ConversationListScaffold( onScrollToTop: () -> Unit, modifier: Modifier = Modifier, ) { - val isSelectionMode = uiState.selection.isActive + val isSelectionMode = uiState.selection.selectedCount > 0 val backdropColor = conversationListBackdropColor(isSelectionMode) BackHandler(enabled = isSelectionMode) { @@ -399,7 +410,7 @@ private fun BoxScope.ConversationListFabs( onScrollToTop: () -> Unit, ) { ScrollToTopFab( - visible = uiState.isScrollUpVisible, + visible = uiState.isScrollToTopVisible, onClick = onScrollToTop, modifier = Modifier .align(Alignment.BottomCenter) @@ -409,7 +420,7 @@ private fun BoxScope.ConversationListFabs( StartChatFab( visible = !isSelectionMode, - expanded = !uiState.isScrollUpVisible, + expanded = !uiState.isScrollToTopVisible, onClick = { onAction(Action.StartChatClicked) }, modifier = Modifier .align(Alignment.BottomEnd) @@ -437,7 +448,7 @@ private fun ConversationListTopBar( when { isSelectionMode -> { ConversationListSelectionTopAppBar( - selectedCount = uiState.selection.selectedConversations.size, + selectedCount = uiState.selection.selectedCount, actions = uiState.selection.actions, onAction = onAction, onDeleteClick = onDeleteClick, diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt index b64a580e0..23b153ec3 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt @@ -92,7 +92,7 @@ private fun ConversationListSelectionActions( onDeleteClick: () -> Unit, onSnoozeClick: () -> Unit, ) { - actions.isFirstSelectedSnoozed?.let { isSnoozed -> + actions.firstSelectedIsSnoozed?.let { isSnoozed -> when { isSnoozed -> SelectionActionButton( imageVector = Icons.Default.NotificationsActive, @@ -108,7 +108,7 @@ private fun ConversationListSelectionActions( } } - actions.isFirstSelectedPinned?.let { isPinned -> + actions.firstSelectedIsPinned?.let { isPinned -> when { isPinned -> SelectionActionButton( imageVector = Icons.Outlined.PushPin, @@ -124,21 +124,17 @@ private fun ConversationListSelectionActions( } } - if (actions.canArchive) { - SelectionActionButton( - imageVector = Icons.Default.Archive, - labelResId = R.string.action_archive, - onClick = { onAction(Action.ArchiveClicked) }, - ) - } + SelectionActionButton( + imageVector = Icons.Default.Archive, + labelResId = R.string.action_archive, + onClick = { onAction(Action.ArchiveClicked) }, + ) - if (actions.canDelete) { - SelectionActionButton( - imageVector = Icons.Default.Delete, - labelResId = R.string.action_delete, - onClick = onDeleteClick, - ) - } + SelectionActionButton( + imageVector = Icons.Default.Delete, + labelResId = R.string.action_delete, + onClick = onDeleteClick, + ) SelectionOverflowMenu( actions = actions, @@ -168,7 +164,7 @@ private fun SelectionOverflowMenu( expanded = isExpanded, onDismissRequest = { isExpanded = false }, ) { - actions.isFirstSelectedUnread?.let { isUnread -> + actions.firstSelectedIsUnread?.let { isUnread -> SelectionMenuItem( labelResId = when { isUnread -> R.string.mark_as_read @@ -252,13 +248,11 @@ private fun ConversationListSelectionTopAppBarPreview() { ConversationListSelectionTopAppBar( selectedCount = 2, actions = SelectionActionsUiState( - canArchive = true, - canDelete = true, canAddContact = true, canBlock = true, - isFirstSelectedPinned = false, - isFirstSelectedSnoozed = false, - isFirstSelectedUnread = true, + firstSelectedIsPinned = false, + firstSelectedIsSnoozed = false, + firstSelectedIsUnread = true, ), onAction = {}, onDeleteClick = {}, diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt index d6f11e49e..dac376ff1 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt @@ -50,7 +50,7 @@ internal fun ConversationListTopAppBar( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - onAction(Action.ScrollUpClicked) + onAction(Action.ScrollToTopClicked) }, text = stringResource(R.string.app_name), maxLines = 1, From 8ea23f5aa7205f1710d4681c97f2a3ee73a686ff Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 24 Jun 2026 14:25:22 +0200 Subject: [PATCH 27/39] Restore MMS subject cleansing and clarify conversation list mapper naming --- .../ConversationListUiStateMapperImplTest.kt | 4 ++- .../mapper/ConversationListUiStateMapper.kt | 32 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt index 2a7af1cb8..a58e3cb81 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversationlist.mapper +import android.content.Context import com.android.messaging.data.conversationlist.model.ConversationListDraft import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListLatestMessage @@ -29,10 +30,11 @@ import org.junit.Test class ConversationListUiStateMapperImplTest { private val mapper = ConversationListUiStateMapperImpl( + context = mockk(relaxed = true), canAddContact = mockk(relaxed = true), canPlacePhoneCall = mockk(relaxed = true), canShowOrAddContact = mockk(relaxed = true), - isContactSavedUseCase = mockk(relaxed = true), + isContactSaved = mockk(relaxed = true), resolveAvatarUri = mockk(relaxed = true), ) diff --git a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt index 9fa1a6c80..d4a07c6b2 100644 --- a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversationlist.mapper +import android.content.Context import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus import com.android.messaging.data.conversationlist.model.ConversationListSnapshot @@ -8,6 +9,7 @@ import com.android.messaging.domain.conversation.usecase.participant.CanAddConta import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall +import com.android.messaging.sms.cleanseMmsSubject import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel @@ -17,6 +19,7 @@ import com.android.messaging.ui.conversationlist.model.ConversationListSnippetUi import com.android.messaging.ui.conversationlist.model.ConversationListUiState import com.android.messaging.ui.conversationlist.model.SelectionActionsUiState import com.android.messaging.util.ContentType +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet @@ -32,10 +35,11 @@ internal interface ConversationListUiStateMapper { } internal class ConversationListUiStateMapperImpl @Inject constructor( + @param:ApplicationContext private val context: Context, private val canAddContact: CanAddContact, private val canPlacePhoneCall: CanPlacePhoneCall, private val canShowOrAddContact: CanShowOrAddContact, - private val isContactSavedUseCase: IsContactSaved, + private val isContactSaved: IsContactSaved, private val resolveAvatarUri: ResolveAvatarUri, ) : ConversationListUiStateMapper { @@ -48,7 +52,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( val items = snapshot.items .map { item -> val isSelected = item.conversationId in selectedConversationIds - item.toConversationListUiState(isSelected) + item.toItemUiModel(isSelected) } .toImmutableList() @@ -81,7 +85,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( ) } - private fun ConversationListItem.toConversationListUiState( + private fun ConversationListItem.toItemUiModel( isSelected: Boolean, ): ConversationListItemUiModel { val isDraft = draft.isVisible @@ -119,10 +123,6 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( lookupKey = participant.lookupKey, destination = destination, ) - val isContactSaved = isContactSavedUseCase( - contactId = participant.contactId, - lookupKey = participant.lookupKey, - ) return ConversationListAvatarUiModel( uri = resolveAvatarUri(icon), @@ -133,7 +133,10 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( subtitle = destination.takeIf { isOneOnOne }, canCall = isOneOnOne && canPlacePhoneCall(destination), canShowContact = canShowContact, - isContactSaved = isContactSaved, + isContactSaved = isContactSaved( + contactId = participant.contactId, + lookupKey = participant.lookupKey, + ), ) } @@ -170,14 +173,14 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( ): SelectionActionsUiState { val singleSelection = selectedItems.singleOrNull() val firstSelected = selectedItems.firstOrNull() - val canAddContact = singleSelection?.participant?.let { participant -> + val canAddSelectedContact = singleSelection?.participant?.let { participant -> canAddContact( isGroup = participant.isGroup, lookupKey = participant.lookupKey, destination = participant.otherNormalizedDestination, ) } - val canBlock = singleSelection?.let { item -> + val canBlockSelected = singleSelection?.let { item -> canBlock( destination = item.participant.otherNormalizedDestination, blockedDestinations = blockedDestinations, @@ -185,8 +188,8 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( } return SelectionActionsUiState( - canAddContact = canAddContact == true, - canBlock = canBlock == true, + canAddContact = canAddSelectedContact == true, + canBlock = canBlockSelected == true, firstSelectedIsPinned = firstSelected?.isPinned, firstSelectedIsSnoozed = firstSelected?.notification?.isSnoozed, firstSelectedIsUnread = firstSelected?.latestMessage?.isRead?.not(), @@ -212,7 +215,10 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( private fun ConversationListItem.activeSubject(): String? { return when { draft.isVisible -> draft.subject - else -> subject + else -> cleanseMmsSubject( + resources = context.resources, + subject = subject, + ) }?.takeIf(String::isNotBlank) } From daf4daf1a25f10734f2d68ddf9e3412459f50c10 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 24 Jun 2026 23:57:41 +0200 Subject: [PATCH 28/39] Refactor conversation list optimistic updates and list animations --- .../reorder/OverlayReorderAnimation.kt | 8 +- .../ConversationListActivity.kt | 2 +- .../ConversationListActivityEffectHandler.kt | 86 -------- .../ConversationListEffectHandler.kt | 82 +++++++ .../ConversationListViewModel.kt | 86 ++++---- .../ConversationListActionsDelegate.kt | 12 +- .../ConversationListOptimisticOverrides.kt | 25 ++- .../ConversationListOptimisticReducer.kt | 114 +++++----- ...versationListOptimisticSnapshotDelegate.kt | 181 +++++++-------- .../ConversationListSelectionDelegate.kt | 89 +++----- .../model/ConversationListAction.kt | 4 + .../ui/ConversationAppearanceAnimation.kt | 97 ++++++++ .../ui/ConversationListContent.kt | 208 +++++++++++------- .../ui/ConversationListDialogs.kt | 6 +- .../ui/ConversationListItemRow.kt | 27 +-- .../ui/ConversationListScreen.kt | 75 +++++-- .../ui/ConversationListSelectionTopAppBar.kt | 7 +- .../ui/ConversationListTopAppBar.kt | 8 +- .../ui/SwipeableConversationListItem.kt | 105 ++++++--- 19 files changed, 718 insertions(+), 504 deletions(-) delete mode 100644 src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt create mode 100644 src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt diff --git a/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt b/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt index 88effc403..a30eeb8b9 100644 --- a/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt +++ b/src/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimation.kt @@ -170,13 +170,9 @@ internal class OverlayReorderAnimationController( val animationIndex = animations.indexOfFirst { animation -> animation.animationId == animationId } + val animation = animations.getOrNull(animationIndex) - if (animationIndex < 0) { - return null - } - - val animation = animations[animationIndex] - if (!animation.isCommitted || animation.isStarted) { + if (animation == null || !animation.isCommitted || animation.isStarted) { return null } diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt index 126884638..f69553647 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.kt @@ -28,7 +28,7 @@ class ConversationListActivity : BugleComponentActivity() { AppTheme { val hostView = LocalView.current val effectHandler = remember(hostView) { - ConversationListActivityEffectHandler( + ConversationListEffectHandlerImpl( activity = this, hostView = hostView, ) diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt deleted file mode 100644 index 8fc00c735..000000000 --- a/src/com/android/messaging/ui/conversationlist/ConversationListActivityEffectHandler.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.android.messaging.ui.conversationlist - -import android.app.Activity -import android.graphics.Point -import android.view.View -import androidx.core.net.toUri -import com.android.messaging.ui.UIIntents -import com.android.messaging.ui.conversationlist.ConversationListEffectHandler -import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect -import com.android.messaging.util.ContactUtil -import com.android.messaging.util.DebugUtils - -internal class ConversationListActivityEffectHandler( - private val activity: Activity, - private val hostView: View, -) : ConversationListEffectHandler { - - override fun handle(effect: Effect) { - when (effect) { - Effect.StartChat -> { - UIIntents.get().launchCreateNewConversationActivity( - activity, - null, - ) - } - - is Effect.OpenConversation -> { - UIIntents.get().launchConversationActivity( - activity, - effect.conversationId, - null, - ) - } - - is Effect.OpenConversationSettings -> { - UIIntents.get().launchPeopleAndOptionsActivity( - activity, - effect.conversationId, - ) - } - - Effect.OpenArchivedConversations -> { - UIIntents.get().launchArchivedConversationsActivity(activity) - } - - Effect.OpenBlockedParticipants -> { - UIIntents.get().launchBlockedParticipantsActivity(activity) - } - - Effect.OpenSettings -> { - UIIntents.get().launchSettingsActivity(activity) - } - - Effect.OpenDebugOptions -> { - DebugUtils.showDebugOptions(activity) - } - - is Effect.OpenAddContact -> { - UIIntents.get().launchAddContactActivity( - activity, - effect.destination, - ) - } - - is Effect.PlaceCall -> { - UIIntents.get().launchPhoneCallActivity( - activity, - effect.destination, - Point(0, 0), - ) - } - - is Effect.ShowOrAddContact -> { - ContactUtil.showOrAddContact( - hostView, - effect.contactId, - effect.lookupKey, - effect.avatarUri?.toUri(), - effect.destination, - ) - } - - else -> Unit - } - } -} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListEffectHandler.kt b/src/com/android/messaging/ui/conversationlist/ConversationListEffectHandler.kt index e170e23a0..5f68fc7a5 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListEffectHandler.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListEffectHandler.kt @@ -1,7 +1,89 @@ package com.android.messaging.ui.conversationlist +import android.app.Activity +import android.graphics.Point +import android.view.View +import androidx.core.net.toUri +import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect +import com.android.messaging.util.ContactUtil +import com.android.messaging.util.DebugUtils internal interface ConversationListEffectHandler { fun handle(effect: Effect) } + +internal class ConversationListEffectHandlerImpl( + private val activity: Activity, + private val hostView: View, +) : ConversationListEffectHandler { + + override fun handle(effect: Effect) { + when (effect) { + Effect.StartChat -> { + UIIntents.get().launchCreateNewConversationActivity( + activity, + null, + ) + } + + is Effect.OpenConversation -> { + UIIntents.get().launchConversationActivity( + activity, + effect.conversationId, + null, + ) + } + + is Effect.OpenConversationSettings -> { + UIIntents.get().launchPeopleAndOptionsActivity( + activity, + effect.conversationId, + ) + } + + Effect.OpenArchivedConversations -> { + UIIntents.get().launchArchivedConversationsActivity(activity) + } + + Effect.OpenBlockedParticipants -> { + UIIntents.get().launchBlockedParticipantsActivity(activity) + } + + Effect.OpenSettings -> { + UIIntents.get().launchSettingsActivity(activity) + } + + Effect.OpenDebugOptions -> { + DebugUtils.showDebugOptions(activity) + } + + is Effect.OpenAddContact -> { + UIIntents.get().launchAddContactActivity( + activity, + effect.destination, + ) + } + + is Effect.PlaceCall -> { + UIIntents.get().launchPhoneCallActivity( + activity, + effect.destination, + Point(0, 0), + ) + } + + is Effect.ShowOrAddContact -> { + ContactUtil.showOrAddContact( + hostView, + effect.contactId, + effect.lookupKey, + effect.avatarUri?.toUri(), + effect.destination, + ) + } + + else -> Unit + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt index 980145a3e..f07c7d043 100644 --- a/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/ConversationListViewModel.kt @@ -18,15 +18,15 @@ import com.android.messaging.ui.conversationlist.model.ConversationListUiState a import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn internal interface ConversationListScreenModel { @@ -52,9 +52,9 @@ internal class ConversationListViewModel @Inject constructor( private val snapshot: StateFlow = optimisticSnapshotDelegate.snapshot - private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + private val _effects = Channel(Channel.BUFFERED) override val effects: Flow = merge( - _effects.asSharedFlow(), + _effects.receiveAsFlow(), actionsDelegate.effects, ) @@ -84,7 +84,7 @@ internal class ConversationListViewModel @Inject constructor( ) selectionDelegate.bind( scope = viewModelScope, - snapshotFlow = snapshot, + snapshot = snapshot, ) actionsDelegate.bind( scope = viewModelScope, @@ -118,12 +118,15 @@ internal class ConversationListViewModel @Inject constructor( is Action.DeleteConfirmed -> { onDeleteConfirmed() } - } } private fun onSnackbarAction(action: Action.SnackbarAction) { when (action) { + is Action.ArchiveSnackbarDismissed -> { + optimisticSnapshotDelegate.discardArchived(action.conversationIds) + } + is Action.ArchiveUndoClicked -> { onArchiveUndoClicked( conversationIds = action.conversationIds, @@ -143,7 +146,7 @@ internal class ConversationListViewModel @Inject constructor( private fun onAddContactConfirmed(destination: String) { val resolvedDestination = destination.takeIf(String::isNotBlank) ?: return - _effects.tryEmit(Effect.OpenAddContact(resolvedDestination)) + _effects.trySend(Effect.OpenAddContact(resolvedDestination)) selectionDelegate.clear() } @@ -159,7 +162,7 @@ internal class ConversationListViewModel @Inject constructor( } private fun onDeleteConfirmed() { - val selectedItems = selectionDelegate.currentSelectedItems() + val selectedItems = currentSelectedItems() if (selectedItems.isEmpty()) { return @@ -197,11 +200,11 @@ internal class ConversationListViewModel @Inject constructor( private fun onListAction(action: Action.ListAction) { when (action) { is Action.AvatarMessageClicked -> { - _effects.tryEmit(Effect.OpenConversation(action.conversationId)) + _effects.trySend(Effect.OpenConversation(action.conversationId)) } is Action.AvatarCallClicked -> { - _effects.tryEmit(Effect.PlaceCall(action.destination)) + _effects.trySend(Effect.PlaceCall(action.destination)) } is Action.ConversationClicked -> { @@ -221,7 +224,7 @@ internal class ConversationListViewModel @Inject constructor( } is Action.AvatarInfoClicked -> { - _effects.tryEmit(Effect.OpenConversationSettings(action.conversationId)) + _effects.trySend(Effect.OpenConversationSettings(action.conversationId)) } is Action.ConversationSwipedToArchive -> { @@ -235,36 +238,34 @@ internal class ConversationListViewModel @Inject constructor( } private fun onConversationClick(conversationId: String) { - val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return - when { - selectionDelegate.isSelectionActive() -> { - selectionDelegate.toggle(resolvedConversationId) + currentSelectedItems().isNotEmpty() -> { + selectionDelegate.toggle(conversationId) } else -> { - _effects.tryEmit(Effect.OpenConversation(resolvedConversationId)) + _effects.trySend(Effect.OpenConversation(conversationId)) } } } private fun onConversationLongClick(conversationId: String) { - val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return - - selectionDelegate.toggle(resolvedConversationId) + selectionDelegate.toggle(conversationId) } private fun onNewestConversationVisibilityChanged(isVisible: Boolean) { - if (isScrollToTopVisible.value == !isVisible) { + val shouldShowScrollToTop = !isVisible + + if (isScrollToTopVisible.value == shouldShowScrollToTop) { return } - isScrollToTopVisible.value = !isVisible + isScrollToTopVisible.value = shouldShowScrollToTop repository.setNewestConversationVisible(isVisible) } private fun onAvatarContactClick(avatar: ConversationListAvatarUiModel) { - _effects.tryEmit( + _effects.trySend( Effect.ShowOrAddContact( contactId = avatar.contactId, lookupKey = avatar.lookupKey, @@ -275,8 +276,7 @@ internal class ConversationListViewModel @Inject constructor( } private fun onConversationSwipedToArchive(conversationId: String) { - val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return - val conversationIds = listOf(resolvedConversationId) + val conversationIds = listOf(conversationId) optimisticSnapshotDelegate.archive(conversationIds) actionsDelegate.setArchived( @@ -287,11 +287,10 @@ internal class ConversationListViewModel @Inject constructor( } private fun onConversationSwipedToToggleRead(conversationId: String) { - val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return - val item = itemById(resolvedConversationId) ?: return + val item = itemById(conversationId) ?: return val shouldMarkRead = !item.latestMessage.isRead - val conversationIds = listOf(resolvedConversationId) + val conversationIds = listOf(conversationId) optimisticSnapshotDelegate.markRead( conversationIds = conversationIds, @@ -306,27 +305,27 @@ internal class ConversationListViewModel @Inject constructor( private fun onNavigationAction(action: Action.NavigationAction) { when (action) { Action.ArchivedConversationsClicked -> { - _effects.tryEmit(Effect.OpenArchivedConversations) + _effects.trySend(Effect.OpenArchivedConversations) } Action.BlockedParticipantsClicked -> { - _effects.tryEmit(Effect.OpenBlockedParticipants) + _effects.trySend(Effect.OpenBlockedParticipants) } Action.DebugOptionsClicked -> { - _effects.tryEmit(Effect.OpenDebugOptions) + _effects.trySend(Effect.OpenDebugOptions) } Action.ScrollToTopClicked -> { - _effects.tryEmit(Effect.ScrollToTop) + _effects.trySend(Effect.ScrollToTop) } Action.SettingsClicked -> { - _effects.tryEmit(Effect.OpenSettings) + _effects.trySend(Effect.OpenSettings) } Action.StartChatClicked -> { - _effects.tryEmit(Effect.StartChat) + _effects.trySend(Effect.StartChat) } } } @@ -385,7 +384,7 @@ internal class ConversationListViewModel @Inject constructor( private fun onAddContactClick() { val destination = singleSelectedDestination() ?: return - _effects.tryEmit(Effect.ConfirmAddContact(destination)) + _effects.trySend(Effect.ConfirmAddContact(destination)) } private fun onArchiveClick() { @@ -404,7 +403,7 @@ internal class ConversationListViewModel @Inject constructor( val selectedItem = singleSelectedItem() ?: return val destination = singleSelectedDestination() ?: return - _effects.tryEmit( + _effects.trySend( Effect.ConfirmBlock( conversationId = selectedItem.conversationId, destination = destination, @@ -428,7 +427,7 @@ internal class ConversationListViewModel @Inject constructor( private fun onPinClick(isPinned: Boolean) { withSelectedIds { conversationIds -> - _effects.tryEmit( + _effects.trySend( Effect.PreparePinAnimation( conversationIds = conversationIds.toImmutableList(), isPinned = isPinned, @@ -470,7 +469,7 @@ internal class ConversationListViewModel @Inject constructor( } private inline fun withSelectedIds(block: (List) -> Unit) { - val selectedItems = selectionDelegate.currentSelectedItems() + val selectedItems = currentSelectedItems() if (selectedItems.isEmpty()) { return @@ -486,7 +485,18 @@ internal class ConversationListViewModel @Inject constructor( } private fun singleSelectedItem(): ConversationListItem? { - return selectionDelegate.currentSelectedItems().singleOrNull() + return currentSelectedItems().singleOrNull() + } + + private fun currentSelectedItems(): List { + val currentSelectedIds = selectionDelegate.selectedIds.value + + return snapshot.value + ?.items + .orEmpty() + .filter { item -> + item.conversationId in currentSelectedIds + } } private fun singleSelectedDestination(): String? { diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt index 95f7c1fe1..b7c941116 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt @@ -9,9 +9,9 @@ import com.android.messaging.ui.conversationlist.model.ConversationListEffect import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch internal interface ConversationListActionsDelegate { @@ -40,8 +40,8 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( private val blockedParticipantsRepository: BlockedParticipantsRepository, ) : ConversationListActionsDelegate { - private val _effects = MutableSharedFlow(extraBufferCapacity = 1) - override val effects: Flow = _effects.asSharedFlow() + private val _effects = Channel(Channel.BUFFERED) + override val effects: Flow = _effects.receiveAsFlow() private var boundScope: CoroutineScope? = null @@ -75,7 +75,7 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( return } - _effects.tryEmit( + _effects.trySend( ConversationListEffect.ArchiveStatusChanged( conversationIds = resolvedConversationIds.toImmutableList(), isArchived = isArchived, @@ -173,7 +173,7 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( isBlocked = true, ) - _effects.emit( + _effects.trySend( ConversationListEffect.ConversationBlocked( conversationId = conversationId, destination = resolvedDestination, diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticOverrides.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticOverrides.kt index b3948b7bb..af3812fc6 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticOverrides.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticOverrides.kt @@ -2,25 +2,28 @@ package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem import kotlinx.collections.immutable.PersistentMap -import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.persistentSetOf internal data class ConversationListOptimisticOverrides( - val archivedIds: PersistentSet = persistentSetOf(), - val archivedItemsById: PersistentMap = persistentMapOf(), - val restoringById: PersistentMap = persistentMapOf(), + val archiveById: PersistentMap = persistentMapOf(), val readById: PersistentMap = persistentMapOf(), val pinnedById: PersistentMap = persistentMapOf(), ) { val isEmpty: Boolean - get() = archivedIds.isEmpty() && - restoringById.isEmpty() && + get() = archiveById.isEmpty() && readById.isEmpty() && pinnedById.isEmpty() } -internal data class RestoringConversation( - val item: ConversationListItem, - val hasObservedArchivedSnapshot: Boolean, -) +internal sealed interface ConversationArchiveOverride { + val item: ConversationListItem + + data class Archived( + override val item: ConversationListItem, + ) : ConversationArchiveOverride + + data class Restoring( + override val item: ConversationListItem, + val awaitingRemoval: Boolean, + ) : ConversationArchiveOverride +} diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducer.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducer.kt index d255f73ff..28f54034b 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducer.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducer.kt @@ -5,9 +5,8 @@ import dagger.Reusable import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentMap -import kotlinx.collections.immutable.toPersistentSet @Reusable internal class ConversationListOptimisticReducer @Inject constructor() { @@ -20,17 +19,18 @@ internal class ConversationListOptimisticReducer @Inject constructor() { return items } - val restoredItems = overrides.restoringById - .filterKeys { conversationId -> - items.none { it.conversationId == conversationId } - } - .values - .map(RestoringConversation::item) + val currentIds = items.mapTo(mutableSetOf()) { it.conversationId } + val restoredItems = overrides.archiveById.mapNotNull { (conversationId, override) -> + val restoring = override as? ConversationArchiveOverride.Restoring + ?: return@mapNotNull null + + restoring.item.takeIf { conversationId !in currentIds } + } val overridden = (items + restoredItems) .asSequence() .filterNot { item -> - item.conversationId in overrides.archivedIds + overrides.archiveById[item.conversationId] is ConversationArchiveOverride.Archived } .map { item -> item.withOverrides(overrides) @@ -54,77 +54,83 @@ internal class ConversationListOptimisticReducer @Inject constructor() { } val itemsById = items.associateBy(ConversationListItem::conversationId) - val restoringById = overrides.restoringById.pruneRestoring(itemsById) + val archiveById = overrides.archiveById.pruneArchiveOverrides(itemsById) + val restoringIds = archiveById + .filterValues { override -> + override is ConversationArchiveOverride.Restoring + } + .keys val readById = overrides.readById .pruneStaleOverrides( itemsById = itemsById, - restoringById = restoringById, - ) { item, isRead -> - item.latestMessage.isRead != isRead - } - - val archivedIds = overrides.archivedIds - .filter { conversationId -> - conversationId in itemsById - } - .toPersistentSet() - - val archivedItemsById = overrides.archivedItemsById - .filterKeys { conversationId -> - conversationId in archivedIds || conversationId in restoringById - } - .toPersistentMap() + restoringIds = restoringIds, + isStillPending = { item, isRead -> + item.latestMessage.isRead != isRead + }, + ) val pinnedById = overrides.pinnedById .pruneStaleOverrides( itemsById = itemsById, - restoringById = restoringById, - ) { item, isPinned -> - item.isPinned != isPinned - } + restoringIds = restoringIds, + isStillPending = { item, isPinned -> + item.isPinned != isPinned + }, + ) return ConversationListOptimisticOverrides( - archivedIds = archivedIds, - archivedItemsById = archivedItemsById, - restoringById = restoringById, + archiveById = archiveById, readById = readById, pinnedById = pinnedById, ) } - private fun PersistentMap.pruneRestoring( + private fun PersistentMap.pruneArchiveOverrides( itemsById: Map, - ): PersistentMap { - return mapNotNull { (conversationId, restoring) -> - when { - conversationId !in itemsById -> { - conversationId to restoring.copy( - hasObservedArchivedSnapshot = true, - ) + ): PersistentMap { + return mutate { archiveOverrides -> + forEach { (conversationId, override) -> + when (override) { + is ConversationArchiveOverride.Archived -> Unit + + is ConversationArchiveOverride.Restoring -> { + when { + conversationId !in itemsById -> { + archiveOverrides[conversationId] = override.copy( + awaitingRemoval = false, + ) + } + + !override.awaitingRemoval -> { + archiveOverrides.remove(conversationId) + } + } + } } - - restoring.hasObservedArchivedSnapshot -> null - - else -> conversationId to restoring } - }.toMap().toPersistentMap() + } } private fun PersistentMap.pruneStaleOverrides( itemsById: Map, - restoringById: PersistentMap, + restoringIds: Set, isStillPending: (item: ConversationListItem, override: V) -> Boolean, ): PersistentMap { - return filter { (conversationId, override) -> - val item = itemsById[conversationId] + return mutate { retainedOverrides -> + forEach { (conversationId, override) -> + val item = itemsById[conversationId] + val shouldRetain = when { + item != null -> isStillPending(item, override) + conversationId in restoringIds -> true + else -> false + } - when { - item != null -> isStillPending(item, override) - conversationId in restoringById -> true - else -> false + if (!shouldRetain) { + retainedOverrides.remove(conversationId) + } } - }.toPersistentMap() + } } private fun ConversationListItem.withOverrides( diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt index 83e1eb958..18ff124ea 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt @@ -4,16 +4,10 @@ import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository import javax.inject.Inject -import kotlinx.collections.immutable.mutate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch internal interface ConversationListOptimisticSnapshotDelegate { @@ -22,6 +16,7 @@ internal interface ConversationListOptimisticSnapshotDelegate { fun bind(scope: CoroutineScope) fun archive(conversationIds: List) + fun discardArchived(conversationIds: List) fun restoreArchived(conversationIds: List) fun markRead(conversationIds: List, isRead: Boolean) fun pin(conversationIds: List, isPinned: Boolean) @@ -32,131 +27,145 @@ internal class ConversationListOptimisticSnapshotDelegateImpl @Inject constructo private val reducer: ConversationListOptimisticReducer, ) : ConversationListOptimisticSnapshotDelegate { - private val overrides = MutableStateFlow(ConversationListOptimisticOverrides()) - private val _snapshot = MutableStateFlow(null) override val snapshot: StateFlow = _snapshot.asStateFlow() - private var rawSnapshot: StateFlow = MutableStateFlow(null) - private var boundScope: CoroutineScope? = null + private var rawSnapshot: ConversationListSnapshot? = null + private var overrides = ConversationListOptimisticOverrides() + private var isBound = false override fun bind(scope: CoroutineScope) { - if (boundScope != null) { + if (isBound) { return } - boundScope = scope - rawSnapshot = repository.observeInboxSnapshot().stateIn( - scope = scope, - started = SharingStarted.Lazily, - initialValue = null, - ) + isBound = true - emitEffectiveSnapshots(scope) - pruneOverridesOnFreshData(scope) + scope.launch { + repository.observeInboxSnapshot() + .collect { snapshot -> + rawSnapshot = snapshot + overrides = reducer.prune( + items = snapshot.items, + overrides = overrides, + ) + publishSnapshot() + } + } } override fun archive(conversationIds: List) { - val archivedItemsById = _snapshot.value + val requestedIds = conversationIds.toSet() + val archivedItems = _snapshot.value ?.items .orEmpty() .filter { item -> - item.conversationId in conversationIds + item.conversationId in requestedIds + } + .associate { item -> + item.conversationId to ConversationArchiveOverride.Archived(item) } - .associateBy(ConversationListItem::conversationId) - overrides.update { overrides -> - overrides.copy( - archivedIds = overrides.archivedIds.addAll(conversationIds), - archivedItemsById = overrides.archivedItemsById.putAll(archivedItemsById), - restoringById = overrides.restoringById.mutate { restoring -> - conversationIds.forEach(restoring::remove) - }, - ) + if (archivedItems.isEmpty()) { + return } + + overrides = overrides.copy( + archiveById = overrides.archiveById.putAll(archivedItems), + ) + publishSnapshot() + } + + override fun discardArchived(conversationIds: List) { + var archiveById = overrides.archiveById + + conversationIds.forEach { conversationId -> + if (archiveById[conversationId] is ConversationArchiveOverride.Archived) { + archiveById = archiveById.remove(conversationId) + } + } + + overrides = overrides.copy(archiveById = archiveById) + publishSnapshot() } override fun restoreArchived(conversationIds: List) { - val rawItemsById = rawSnapshot.value + var archiveById = overrides.archiveById + val rawItemsById = rawSnapshot ?.items .orEmpty() .associateBy(ConversationListItem::conversationId) - overrides.update { overrides -> - val archivedItemsById = overrides.archivedItemsById.putAll( - rawItemsById.filterKeys { conversationId -> - conversationId in conversationIds - }, - ) - - overrides.copy( - archivedIds = overrides.archivedIds.removeAll(conversationIds.toSet()), - archivedItemsById = archivedItemsById, - restoringById = overrides.restoringById.mutate { restoring -> - conversationIds.forEach { conversationId -> - val item = archivedItemsById[conversationId] ?: return@forEach - - restoring[conversationId] = RestoringConversation( - item = item, - hasObservedArchivedSnapshot = conversationId !in rawItemsById, - ) - } - }, + conversationIds.forEach { conversationId -> + val item = archiveById[conversationId]?.item + ?: rawItemsById[conversationId] + ?: return@forEach + + archiveById = archiveById.put( + key = conversationId, + value = ConversationArchiveOverride.Restoring( + item = item, + awaitingRemoval = conversationId in rawItemsById, + ), ) } + + overrides = overrides.copy(archiveById = archiveById) + publishSnapshot() } override fun markRead( conversationIds: List, isRead: Boolean, ) { - val readById = conversationIds.associateWith { isRead } + val effectiveIds = _snapshot.value + ?.items + .orEmpty() + .mapTo(mutableSetOf()) { item -> item.conversationId } + val readOverrides = conversationIds + .filter(effectiveIds::contains) + .associateWith { isRead } - overrides.update { overrides -> - overrides.copy( - readById = overrides.readById.putAll(readById), - ) + if (readOverrides.isEmpty()) { + return } + + overrides = overrides.copy( + readById = overrides.readById.putAll(readOverrides), + ) + publishSnapshot() } override fun pin( conversationIds: List, isPinned: Boolean, ) { - val pinnedById = conversationIds.associateWith { isPinned } + val effectiveIds = _snapshot.value + ?.items + .orEmpty() + .mapTo(mutableSetOf()) { item -> item.conversationId } + val pinOverrides = conversationIds + .filter(effectiveIds::contains) + .associateWith { isPinned } - overrides.update { overrides -> - overrides.copy( - pinnedById = overrides.pinnedById.putAll(pinnedById), - ) + if (pinOverrides.isEmpty()) { + return } - } - private fun emitEffectiveSnapshots(scope: CoroutineScope) { - scope.launch { - combine(rawSnapshot, overrides) { snapshot, overrides -> - snapshot?.copy( - items = reducer.apply( - items = snapshot.items, - overrides = overrides, - ), - ) - }.collect { effectiveSnapshot -> - _snapshot.value = effectiveSnapshot - } - } + overrides = overrides.copy( + pinnedById = overrides.pinnedById.putAll(pinOverrides), + ) + publishSnapshot() } - private fun pruneOverridesOnFreshData(scope: CoroutineScope) { - scope.launch { - rawSnapshot.filterNotNull().collect { snapshot -> - overrides.update { overrides -> - reducer.prune( - items = snapshot.items, - overrides = overrides, - ) - } - } - } + private fun publishSnapshot() { + val snapshot = rawSnapshot ?: return + + _snapshot.value = snapshot.copy( + items = reducer.apply( + items = snapshot.items, + overrides = overrides, + ), + ) } } diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt index 27819b744..f5815a514 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt @@ -6,58 +6,72 @@ import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch internal interface ConversationListSelectionDelegate { - val selectedIds: StateFlow> + val selectedIds: StateFlow> - fun bind(scope: CoroutineScope, snapshotFlow: StateFlow) + fun bind(scope: CoroutineScope, snapshot: StateFlow) fun toggle(conversationId: String) fun clear() - fun isSelectionActive(): Boolean - fun currentSelectedItems(): ImmutableList } internal class ConversationListSelectionDelegateImpl @Inject constructor() : ConversationListSelectionDelegate { private val _selectedIds = MutableStateFlow>(persistentListOf()) - override val selectedIds: StateFlow> = _selectedIds.asStateFlow() + override val selectedIds: StateFlow> = _selectedIds.asStateFlow() - private var boundSnapshotFlow: StateFlow? = null + private var isBound = false override fun bind( scope: CoroutineScope, - snapshotFlow: StateFlow, + snapshot: StateFlow, ) { - if (boundSnapshotFlow != null) { + if (isBound) { return } - boundSnapshotFlow = snapshotFlow + isBound = true - scope.launch { - snapshotFlow - .filterNotNull() - .collect { snapshot -> - pruneSelection(snapshot) + snapshot + .filterNotNull() + .onEach { currentSnapshot -> + if (_selectedIds.value.isEmpty()) { + return@onEach } - } + + val knownIds = currentSnapshot.items.mapTo( + destination = HashSet(currentSnapshot.items.size), + transform = ConversationListItem::conversationId, + ) + + _selectedIds.update { currentSelectedIds -> + currentSelectedIds.retainAll(knownIds) + } + } + .launchIn(scope) } override fun toggle(conversationId: String) { + val resolvedConversationId = conversationId.takeIf(String::isNotBlank) ?: return + _selectedIds.update { currentSelectedIds -> when { - conversationId in currentSelectedIds -> currentSelectedIds.remove(conversationId) - else -> currentSelectedIds.add(conversationId) + resolvedConversationId in currentSelectedIds -> { + currentSelectedIds.remove(resolvedConversationId) + } + + else -> { + currentSelectedIds.add(resolvedConversationId) + } } } } @@ -65,39 +79,4 @@ internal class ConversationListSelectionDelegateImpl @Inject constructor() : override fun clear() { _selectedIds.value = persistentListOf() } - - override fun isSelectionActive(): Boolean { - return _selectedIds.value.isNotEmpty() - } - - override fun currentSelectedItems(): ImmutableList { - val items = boundSnapshotFlow?.value?.items ?: return persistentListOf() - val currentSelectedIds = _selectedIds.value - - return items - .filter { item -> - item.conversationId in currentSelectedIds - } - .toImmutableList() - } - - private fun pruneSelection(snapshot: ConversationListSnapshot) { - if (_selectedIds.value.isEmpty()) { - return - } - - val visibleConversationIds = snapshot.items - .map { item -> - item.conversationId - } - .toSet() - - _selectedIds.update { currentSelectedIds -> - currentSelectedIds - .filter { conversationId -> - conversationId in visibleConversationIds - } - .toPersistentList() - } - } } diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt index 232b87f0c..cb0aafd9b 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListAction.kt @@ -36,6 +36,10 @@ internal sealed interface ConversationListAction { val isArchived: Boolean, ) : SnackbarAction + data class ArchiveSnackbarDismissed( + val conversationIds: ImmutableList, + ) : SnackbarAction + data class BlockUndoClicked( val conversationId: String, val destination: String, diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt new file mode 100644 index 000000000..07b8808a7 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt @@ -0,0 +1,97 @@ +package com.android.messaging.ui.conversationlist.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import kotlinx.collections.immutable.ImmutableList + +internal class AppearanceAnimationToken + +@Stable +internal class AppearanceAnimationTracker { + + private var knownConversationIds: Set? = null + private val activeTokens = mutableStateMapOf() + + fun computeEntering( + currentConversationIds: Set, + ): Map { + val previousConversationIds = knownConversationIds ?: return emptyMap() + + return currentConversationIds + .minus(previousConversationIds) + .associateWith { AppearanceAnimationToken() } + } + + fun commit( + currentConversationIds: Set, + enteringTokens: Map, + ) { + knownConversationIds = currentConversationIds + + activeTokens.keys + .filterNot(currentConversationIds::contains) + .forEach(activeTokens::remove) + activeTokens.putAll(enteringTokens) + } + + fun tokenFor( + conversationId: String, + enteringTokens: Map, + ): AppearanceAnimationToken? { + return enteringTokens[conversationId] ?: activeTokens[conversationId] + } + + fun onAnimationFinished( + conversationId: String, + token: AppearanceAnimationToken, + ) { + if (activeTokens[conversationId] == token) { + activeTokens.remove(conversationId) + } + } +} + +internal class AppearanceAnimationTokens( + private val tracker: AppearanceAnimationTracker, + private val enteringTokens: Map, +) { + fun tokenFor(conversationId: String): AppearanceAnimationToken? { + return tracker.tokenFor(conversationId, enteringTokens) + } + + fun onAnimationFinished( + conversationId: String, + token: AppearanceAnimationToken, + ) { + tracker.onAnimationFinished(conversationId, token) + } +} + +@Composable +internal fun rememberAppearanceAnimationTokens( + items: ImmutableList, +): AppearanceAnimationTokens { + val tracker = remember { AppearanceAnimationTracker() } + val currentConversationIds = remember(items) { + items.mapTo(HashSet(items.size), ConversationListItemUiModel::conversationId) + } + val enteringTokens = remember(currentConversationIds) { + tracker.computeEntering(currentConversationIds) + } + + SideEffect { + tracker.commit( + currentConversationIds = currentConversationIds, + enteringTokens = enteringTokens, + ) + } + + return AppearanceAnimationTokens( + tracker = tracker, + enteringTokens = enteringTokens, + ) +} diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt index b35eb2f3c..9ae50a342 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -64,9 +63,9 @@ internal fun ConversationListContent( content: ConversationListContentUiState, listState: LazyListState, onAction: (Action) -> Unit, - contentPadding: PaddingValues, + scaffoldContentPadding: PaddingValues, isSelectionMode: Boolean, - bottomReserve: Dp, + fabBottomReserve: Dp, pinAnimationController: OverlayReorderAnimationController?, modifier: Modifier = Modifier, ) { @@ -77,13 +76,13 @@ internal fun ConversationListContent( } ConversationListContentUiState.WaitingForSync -> { - ConversationListMessage( + ConversationListStatusMessage( text = stringResource(R.string.conversation_list_first_sync_text), ) } ConversationListContentUiState.Empty -> { - ConversationListMessage( + ConversationListStatusMessage( text = stringResource(R.string.conversation_list_empty_text), ) } @@ -93,9 +92,9 @@ internal fun ConversationListContent( items = content.items, listState = listState, onAction = onAction, - contentPadding = contentPadding, + scaffoldContentPadding = scaffoldContentPadding, isSelectionMode = isSelectionMode, - bottomReserve = bottomReserve, + fabBottomReserve = fabBottomReserve, pinAnimationController = pinAnimationController, ) } @@ -108,26 +107,14 @@ private fun ConversationListItems( items: ImmutableList, listState: LazyListState, onAction: (Action) -> Unit, - contentPadding: PaddingValues, + scaffoldContentPadding: PaddingValues, isSelectionMode: Boolean, - bottomReserve: Dp, + fabBottomReserve: Dp, pinAnimationController: OverlayReorderAnimationController?, ) { - val currentConversationIds: Set = - items.mapTo(mutableSetOf(), ConversationListItemUiModel::conversationId) - val previousConversationIdsState = remember { mutableStateOf(currentConversationIds) } - val enteringConversationIds = currentConversationIds - previousConversationIdsState.value - val appearanceGenerationById = remember { mutableMapOf() } - val activeAppearanceTokens = remember { mutableStateMapOf() } - val enteringAppearanceTokens = remember(items, enteringConversationIds) { - enteringConversationIds.associateWith { conversationId -> - appearanceGenerationById.getOrDefault(conversationId, 0L) + 1L - }.also(appearanceGenerationById::putAll) - } + val appearanceTokens = rememberAppearanceAnimationTokens(items) SideEffect { - previousConversationIdsState.value = currentConversationIds - activeAppearanceTokens.putAll(enteringAppearanceTokens) pinAnimationController?.updateItems(items) } @@ -143,7 +130,9 @@ private fun ConversationListItems( state = listState, contentPadding = PaddingValues( top = ListContentPadding, - bottom = contentPadding.calculateBottomPadding() + ListContentPadding + bottomReserve, + bottom = scaffoldContentPadding.calculateBottomPadding() + + ListContentPadding + + fabBottomReserve, ), verticalArrangement = Arrangement.spacedBy(ListVerticalSpacing), ) { @@ -152,19 +141,20 @@ private fun ConversationListItems( key = { item -> item.conversationId }, contentType = { CONVERSATION_ROW_CONTENT_TYPE }, ) { item -> - val appearanceAnimationToken = enteringAppearanceTokens[item.conversationId] - ?: activeAppearanceTokens[item.conversationId] + val appearanceAnimationToken = appearanceTokens.tokenFor(item.conversationId) ConversationListRow( item = item, - items = items, listState = listState, isSelectionMode = isSelectionMode, appearanceAnimationToken = appearanceAnimationToken, pinAnimationController = pinAnimationController, onAppearanceAnimationFinished = { - if (activeAppearanceTokens[item.conversationId] == appearanceAnimationToken) { - activeAppearanceTokens.remove(item.conversationId) + if (appearanceAnimationToken != null) { + appearanceTokens.onAnimationFinished( + conversationId = item.conversationId, + token = appearanceAnimationToken, + ) } }, onAction = onAction, @@ -176,10 +166,9 @@ private fun ConversationListItems( @Composable private fun LazyItemScope.ConversationListRow( item: ConversationListItemUiModel, - items: ImmutableList, listState: LazyListState, isSelectionMode: Boolean, - appearanceAnimationToken: Long?, + appearanceAnimationToken: AppearanceAnimationToken?, pinAnimationController: OverlayReorderAnimationController?, onAppearanceAnimationFinished: () -> Unit, onAction: (Action) -> Unit, @@ -196,6 +185,7 @@ private fun LazyItemScope.ConversationListRow( SwipeableConversationListItem( item = item, isSelectionMode = isSelectionMode, + isInteractionEnabled = !isHiddenByPinAnimation, appearanceAnimationToken = appearanceAnimationToken, onAppearanceAnimationFinished = onAppearanceAnimationFinished, onArchive = { @@ -210,9 +200,8 @@ private fun LazyItemScope.ConversationListRow( isPinned = item.isPinned, animatePlacement = !isHiddenByPinAnimation, ) - .reportPinAnimationBounds( + .trackPinAnimationBounds( listState = listState, - items = items, conversationId = item.conversationId, pinAnimationController = pinAnimationController, ) @@ -246,31 +235,37 @@ private fun LazyItemScope.ConversationListRow( } } -private fun Modifier.reportPinAnimationBounds( +private fun Modifier.trackPinAnimationBounds( listState: LazyListState, - items: ImmutableList, conversationId: String, pinAnimationController: OverlayReorderAnimationController?, ): Modifier { + if (pinAnimationController == null) { + return this + } + return onGloballyPositioned { coordinates -> val layoutInfo = listState.layoutInfo val physicallyVisibleItems = layoutInfo.visibleItemsInfo.filter { visibleItem -> visibleItem.offset < layoutInfo.viewportEndOffset && visibleItem.offset + visibleItem.size > layoutInfo.viewportStartOffset } + + val firstVisibleItemIndex = physicallyVisibleItems + .minOfOrNull { visibleItem -> visibleItem.index } + ?: listState.firstVisibleItemIndex + val lastVisibleItemIndex = physicallyVisibleItems - .filter { visibleItem -> visibleItem.key != conversationId } .maxOfOrNull { visibleItem -> visibleItem.index } - ?.let { lastIndex -> (lastIndex + 1).coerceAtMost(items.lastIndex) } - ?: listState.firstVisibleItemIndex + ?: firstVisibleItemIndex - pinAnimationController?.updateItemBounds( + pinAnimationController.updateItemBounds( itemKey = conversationId, boundsInRoot = coordinates.boundsInRoot(), isPhysicallyVisible = physicallyVisibleItems.any { visibleItem -> visibleItem.key == conversationId }, - firstVisibleItemIndex = listState.firstVisibleItemIndex, + firstVisibleItemIndex = firstVisibleItemIndex, lastVisibleItemIndex = lastVisibleItemIndex, ) } @@ -285,53 +280,114 @@ private fun KeepViewportStationaryOnPinChange( SideEffect { val previousItems = previousItemsState.value - val currentItemsById = items.associateBy(ConversationListItemUiModel::conversationId) - val hasSameConversationIds = previousItems.size == items.size && previousItems.all { item -> - item.conversationId in currentItemsById - } - val hasPinStateChange = previousItems.any { previousItem -> - currentItemsById[previousItem.conversationId]?.isPinned != previousItem.isPinned - } - val wasAtStart = listState.firstVisibleItemIndex == 0 && - listState.firstVisibleItemScrollOffset == 0 val firstVisibleConversationId = listState.layoutInfo .visibleItemsInfo - .firstOrNull() + .firstOrNull { visibleItem -> + visibleItem.index == listState.firstVisibleItemIndex + } ?.key as? String - val previousFirstVisibleIndex = previousItems.indexOfFirst { item -> - item.conversationId == firstVisibleConversationId - } - val previousFirstVisibleItem = previousItems.getOrNull(previousFirstVisibleIndex) - val currentFirstVisibleItem = firstVisibleConversationId?.let(currentItemsById::get) - val firstVisibleItemPinChanged = previousFirstVisibleItem?.isPinned != - currentFirstVisibleItem?.isPinned - if (hasSameConversationIds && hasPinStateChange) { - when { - wasAtStart -> { - listState.requestScrollToItem(index = 0) - } + val scrollRequest = resolvePinChangeScrollRequest( + previousItems = previousItems, + currentItems = items, + firstVisibleConversationId = firstVisibleConversationId, + firstVisibleItemIndex = listState.firstVisibleItemIndex, + firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset, + ) - previousFirstVisibleIndex >= 0 && firstVisibleItemPinChanged -> { - listState.requestScrollToItem( - index = previousFirstVisibleIndex, - scrollOffset = listState.firstVisibleItemScrollOffset, - ) - } - } + if (scrollRequest != null) { + listState.requestScrollToItem( + index = scrollRequest.index, + scrollOffset = scrollRequest.scrollOffset, + ) } previousItemsState.value = items } } +internal data class ConversationListScrollRequest( + val index: Int, + val scrollOffset: Int, +) + +internal fun resolvePinChangeScrollRequest( + previousItems: List, + currentItems: List, + firstVisibleConversationId: String?, + firstVisibleItemIndex: Int, + firstVisibleItemScrollOffset: Int, +): ConversationListScrollRequest? { + val currentItemsById = currentItems.associateBy(ConversationListItemUiModel::conversationId) + + if (!hasPinReorder(previousItems, currentItemsById)) { + return null + } + + return when { + firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 -> { + ConversationListScrollRequest( + index = 0, + scrollOffset = 0, + ) + } + + else -> resolveAnchorScrollRequest( + previousItems = previousItems, + currentItemsById = currentItemsById, + firstVisibleConversationId = firstVisibleConversationId, + firstVisibleItemScrollOffset = firstVisibleItemScrollOffset, + ) + } +} + +private fun hasPinReorder( + previousItems: List, + currentItemsById: Map, +): Boolean { + val hasSameConversationIds = previousItems.size == currentItemsById.size && + previousItems.all { item -> item.conversationId in currentItemsById } + + return hasSameConversationIds && previousItems.any { previousItem -> + currentItemsById.getValue(previousItem.conversationId).isPinned != previousItem.isPinned + } +} + +private fun resolveAnchorScrollRequest( + previousItems: List, + currentItemsById: Map, + firstVisibleConversationId: String?, + firstVisibleItemScrollOffset: Int, +): ConversationListScrollRequest? { + val previousFirstVisibleIndex = previousItems.indexOfFirst { item -> + item.conversationId == firstVisibleConversationId + } + val previousFirstVisibleItem = previousItems.getOrNull(previousFirstVisibleIndex) + val currentFirstVisibleItem = previousFirstVisibleItem + ?.let { currentItemsById[it.conversationId] } + + val hasAnchorPinChange = previousFirstVisibleItem != null && + currentFirstVisibleItem != null && + previousFirstVisibleItem.isPinned != currentFirstVisibleItem.isPinned + + return ConversationListScrollRequest( + index = previousFirstVisibleIndex, + scrollOffset = firstVisibleItemScrollOffset, + ).takeIf { hasAnchorPinChange } +} + private fun Modifier.conversationItemAnimation( lazyItemScope: LazyItemScope, isPinned: Boolean, animatePlacement: Boolean, ): Modifier = with(lazyItemScope) { this@conversationItemAnimation - .zIndex(if (isPinned) PINNED_ITEM_Z_INDEX else 0f) + .zIndex( + when { + isPinned -> PINNED_ITEM_Z_INDEX + else -> 0f + }, + ) .animateItem( fadeInSpec = null, fadeOutSpec = null, @@ -340,7 +396,7 @@ private fun Modifier.conversationItemAnimation( } @Composable -private fun ConversationListMessage(text: String) { +private fun ConversationListStatusMessage(text: String) { Box( modifier = Modifier .fillMaxSize() @@ -364,9 +420,9 @@ private fun ConversationListContentEmptyPreview() { content = ConversationListContentUiState.Empty, listState = rememberLazyListState(), onAction = {}, - contentPadding = PaddingValues(), + scaffoldContentPadding = PaddingValues(), isSelectionMode = false, - bottomReserve = 0.dp, + fabBottomReserve = 0.dp, pinAnimationController = null, ) } @@ -380,9 +436,9 @@ private fun ConversationListContentWaitingForSyncPreview() { content = ConversationListContentUiState.WaitingForSync, listState = rememberLazyListState(), onAction = {}, - contentPadding = PaddingValues(), + scaffoldContentPadding = PaddingValues(), isSelectionMode = false, - bottomReserve = 0.dp, + fabBottomReserve = 0.dp, pinAnimationController = null, ) } @@ -398,9 +454,9 @@ private fun ConversationListContentItemsPreview() { ), listState = rememberLazyListState(), onAction = {}, - contentPadding = PaddingValues(), + scaffoldContentPadding = PaddingValues(), isSelectionMode = false, - bottomReserve = 0.dp, + fabBottomReserve = 0.dp, pinAnimationController = null, ) } diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt index 0904d31d8..51247ece3 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListDialogs.kt @@ -30,8 +30,8 @@ internal fun ConversationListDialogs( ConversationListAddContactDialog( destination = destination, onConfirm = { - onDismissAddContact() onAction(Action.AddContactConfirmed(destination)) + onDismissAddContact() }, onDismiss = onDismissAddContact, ) @@ -41,8 +41,8 @@ internal fun ConversationListDialogs( ConversationListDeleteDialog( selectedCount = selectedCount, onConfirm = { - onDismissDelete() onAction(Action.DeleteConfirmed) + onDismissDelete() }, onDismiss = onDismissDelete, ) @@ -52,13 +52,13 @@ internal fun ConversationListDialogs( ConversationListBlockDialog( destination = blockDestination, onConfirm = { - onDismissBlock() onAction( Action.BlockConfirmed( conversationId = blockConversationId, destination = blockDestination, ), ) + onDismissBlock() }, onDismiss = onDismissBlock, ) diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt index fe709ecf3..90b56b9c7 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListItemRow.kt @@ -227,30 +227,23 @@ private fun ConversationListItemBadgeIcon(icon: ImageVector) { @Composable private fun ConversationListItemPreviewThumbnail(preview: ConversationListPreviewUiModel?) { + val contentUri: String + val contentType: String + when (preview) { is ConversationListPreviewUiModel.Image -> { - ConversationListVisualPreviewThumbnail( - contentUri = preview.contentUri, - contentType = preview.contentType, - ) + contentUri = preview.contentUri + contentType = preview.contentType } is ConversationListPreviewUiModel.Video -> { - ConversationListVisualPreviewThumbnail( - contentUri = preview.contentUri, - contentType = preview.contentType, - ) + contentUri = preview.contentUri + contentType = preview.contentType } - else -> Unit + else -> return } -} -@Composable -private fun ConversationListVisualPreviewThumbnail( - contentUri: String, - contentType: String, -) { val thumbnailSizePx = with(LocalDensity.current) { ItemPreviewThumbnailSize.roundToPx() } @@ -342,7 +335,9 @@ private fun itemSnippetText(item: ConversationListItemUiModel): String? { stringResource(R.string.conversation_list_snippet_vcard) } - is ConversationListPreviewUiModel.File -> stringResource(R.string.mms_text) + is ConversationListPreviewUiModel.File -> { + stringResource(R.string.mms_text) + } null -> null } diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt index 509af2b8c..b8baa1046 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt @@ -114,27 +114,16 @@ internal fun ConversationListScreen( }, ) - Box( - modifier = modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> - pinAnimationController.updateContainerBounds(coordinates.boundsInRoot()) - }, - ) { - ConversationListScaffold( - uiState = uiState, - listState = listState, - snackbarHostState = snackbarHostState, - pinAnimationController = pinAnimationController, - onAction = screenModel::onAction, - onDeleteClick = { pendingDelete = true }, - onSnoozeClick = { pendingSnooze = true }, - onScrollToTop = { screenModel.onAction(Action.ScrollToTopClicked) }, - modifier = Modifier.fillMaxSize(), - ) - - ConversationListPinOverlay(pinAnimationController) - } + ConversationListScaffoldWithPinOverlay( + uiState = uiState, + listState = listState, + snackbarHostState = snackbarHostState, + pinAnimationController = pinAnimationController, + onAction = screenModel::onAction, + onDeleteClick = { pendingDelete = true }, + onSnoozeClick = { pendingSnooze = true }, + modifier = modifier.fillMaxSize(), + ) ConversationListDialogs( selectedCount = uiState.selection.selectedCount, @@ -154,6 +143,40 @@ internal fun ConversationListScreen( ) } +@Composable +private fun ConversationListScaffoldWithPinOverlay( + uiState: State, + listState: LazyListState, + snackbarHostState: SnackbarHostState, + pinAnimationController: OverlayReorderAnimationController, + onAction: (Action) -> Unit, + onDeleteClick: () -> Unit, + onSnoozeClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + pinAnimationController.updateContainerBounds(coordinates.boundsInRoot()) + }, + ) { + ConversationListScaffold( + uiState = uiState, + listState = listState, + snackbarHostState = snackbarHostState, + pinAnimationController = pinAnimationController, + onAction = onAction, + onDeleteClick = onDeleteClick, + onSnoozeClick = onSnoozeClick, + onScrollToTop = { onAction(Action.ScrollToTopClicked) }, + modifier = Modifier.fillMaxSize(), + ) + + ConversationListPinOverlay(pinAnimationController) + } +} + @Composable private fun ConversationListPinOverlay( controller: OverlayReorderAnimationController, @@ -296,6 +319,12 @@ private fun CoroutineScope.launchArchivedSnackbar( isArchived = effect.isArchived, ), ) + } else { + onAction( + Action.ArchiveSnackbarDismissed( + conversationIds = effect.conversationIds, + ), + ) } } } @@ -386,9 +415,9 @@ private fun ConversationListScaffold( content = uiState.content, listState = listState, onAction = onAction, - contentPadding = contentPadding, + scaffoldContentPadding = contentPadding, isSelectionMode = isSelectionMode, - bottomReserve = FabBottomReserve, + fabBottomReserve = FabBottomReserve, pinAnimationController = pinAnimationController, ) diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt index 23b153ec3..7382c4ae1 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListSelectionTopAppBar.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -147,9 +148,7 @@ private fun SelectionOverflowMenu( actions: SelectionActionsUiState, onAction: (Action) -> Unit, ) { - var isExpanded by remember { - mutableStateOf(value = false) - } + var isExpanded by remember { mutableStateOf(false) } Box { IconButton(onClick = { isExpanded = true }) { @@ -219,7 +218,7 @@ private fun SelectionMenuItem( @Composable private fun SelectionActionButton( - imageVector: androidx.compose.ui.graphics.vector.ImageVector, + imageVector: ImageVector, labelResId: Int, onClick: () -> Unit, ) { diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt index dac376ff1..9b80c9107 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListTopAppBar.kt @@ -91,8 +91,8 @@ private fun ConversationListOverflowMenu( ConversationListMenuItem( labelResId = R.string.action_menu_show_archived, onClick = { - isExpanded = false onAction(Action.ArchivedConversationsClicked) + isExpanded = false }, ) @@ -100,8 +100,8 @@ private fun ConversationListOverflowMenu( ConversationListMenuItem( labelResId = R.string.blocked_contacts_title, onClick = { - isExpanded = false onAction(Action.BlockedParticipantsClicked) + isExpanded = false }, ) } @@ -109,8 +109,8 @@ private fun ConversationListOverflowMenu( ConversationListMenuItem( labelResId = R.string.action_settings, onClick = { - isExpanded = false onAction(Action.SettingsClicked) + isExpanded = false }, ) @@ -118,8 +118,8 @@ private fun ConversationListOverflowMenu( ConversationListMenuItem( labelResId = R.string.action_debug_options, onClick = { - isExpanded = false onAction(Action.DebugOptionsClicked) + isExpanded = false }, ) } diff --git a/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt index 4597163f9..3bbd8e388 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt @@ -31,10 +31,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.layout import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -89,7 +91,8 @@ private enum class ConversationSwipeAction { internal fun SwipeableConversationListItem( item: ConversationListItemUiModel, isSelectionMode: Boolean, - appearanceAnimationToken: Long?, + isInteractionEnabled: Boolean, + appearanceAnimationToken: AppearanceAnimationToken?, onAppearanceAnimationFinished: () -> Unit, onArchive: () -> Unit, onToggleRead: () -> Unit, @@ -98,35 +101,17 @@ internal fun SwipeableConversationListItem( ) { val currentOnArchive by rememberUpdatedState(onArchive) val currentOnToggleRead by rememberUpdatedState(onToggleRead) - val currentOnAppearanceAnimationFinished by rememberUpdatedState(onAppearanceAnimationFinished) val offsetX = remember { mutableFloatStateOf(0f) } - val visibilityFraction = remember(item.conversationId) { - val initialValue = when { - appearanceAnimationToken != null -> 0f - else -> 1f - } - - Animatable(initialValue) - } val backgroundAction by remember { derivedStateOf { swipeAction(offsetX.floatValue) } } - - LaunchedEffect(item.conversationId, appearanceAnimationToken) { - if (appearanceAnimationToken == null) { - return@LaunchedEffect - } - - visibilityFraction.stop() - visibilityFraction.snapTo(0f) - visibilityFraction.animateTo( - targetValue = 1f, - animationSpec = ItemAppearanceSpec, - ) - currentOnAppearanceAnimationFinished() - } + val visibilityFraction = rememberAppearanceVisibility( + conversationId = item.conversationId, + appearanceAnimationToken = appearanceAnimationToken, + onAppearanceAnimationFinished = onAppearanceAnimationFinished, + ) val gestureModifier = when { - isSelectionMode -> Modifier + !isInteractionEnabled || isSelectionMode -> Modifier else -> Modifier.swipeActions( offsetX = offsetX, @@ -135,17 +120,24 @@ internal fun SwipeableConversationListItem( onToggleRead = { currentOnToggleRead() }, ) } + val interactionModifier = when { + isInteractionEnabled -> Modifier + else -> + Modifier + .consumeAllPointerInput() + .clearAndSetSemantics {} + } Box( modifier = modifier .then(gestureModifier) + .then(interactionModifier) .collapseVertically { visibilityFraction.value } .graphicsLayer { alpha = visibilityFraction.value } .clipToBounds(), ) { ConversationListSwipeBackground( action = backgroundAction, - isUnread = item.isUnread, modifier = Modifier .matchParentSize() .padding(horizontal = SwipeBackgroundOuterHorizontalPadding), @@ -164,6 +156,50 @@ internal fun SwipeableConversationListItem( } } +@Composable +private fun rememberAppearanceVisibility( + conversationId: String, + appearanceAnimationToken: AppearanceAnimationToken?, + onAppearanceAnimationFinished: () -> Unit, +): Animatable { + val currentOnAppearanceAnimationFinished by rememberUpdatedState(onAppearanceAnimationFinished) + val visibilityFraction = remember(conversationId) { + val initialValue = when { + appearanceAnimationToken != null -> 0f + else -> 1f + } + + Animatable(initialValue) + } + + LaunchedEffect(conversationId, appearanceAnimationToken) { + if (appearanceAnimationToken == null) { + return@LaunchedEffect + } + + visibilityFraction.stop() + visibilityFraction.snapTo(0f) + visibilityFraction.animateTo( + targetValue = 1f, + animationSpec = ItemAppearanceSpec, + ) + currentOnAppearanceAnimationFinished() + } + + return visibilityFraction +} + +private fun Modifier.consumeAllPointerInput(): Modifier { + return pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + event.changes.forEach { change -> change.consume() } + } + } + } +} + private fun Modifier.collapseVertically(fraction: () -> Float): Modifier { return layout { measurable, constraints -> val placeable = measurable.measure(constraints) @@ -274,12 +310,14 @@ private suspend fun settleSwipe( ) } - ConversationSwipeAction.None -> animateOffset( - offsetX = offsetX, - targetValue = 0f, - initialVelocity = velocityX, - animationSpec = SwipeSettleSpec, - ) + ConversationSwipeAction.None -> { + animateOffset( + offsetX = offsetX, + targetValue = 0f, + initialVelocity = velocityX, + animationSpec = SwipeSettleSpec, + ) + } } } @@ -328,7 +366,6 @@ private fun swipeAction(offset: Float): ConversationSwipeAction { @Composable private fun ConversationListSwipeBackground( action: ConversationSwipeAction, - isUnread: Boolean, modifier: Modifier = Modifier, ) { if (action == ConversationSwipeAction.None) { @@ -355,13 +392,11 @@ private fun ConversationListSwipeBackground( val icon = when { isArchive -> Icons.Filled.Archive - isUnread -> Icons.Filled.MarkChatRead else -> Icons.Filled.MarkChatUnread } val description = when { isArchive -> stringResource(R.string.action_archive) - isUnread -> stringResource(R.string.mark_as_read) else -> stringResource(R.string.mark_as_unread) } From 9655876a0abe97817f6c0c3882b64df9f9e98d37 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 25 Jun 2026 00:20:08 +0200 Subject: [PATCH 29/39] Update optimistic conversation list reducer coverage --- .../ConversationListOptimisticReducerTest.kt | 263 ++++++++---------- 1 file changed, 113 insertions(+), 150 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt index 6e2f45ff0..e346d3d04 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt @@ -1,9 +1,8 @@ package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toImmutableList import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -14,11 +13,11 @@ internal class ConversationListOptimisticReducerTest { private val reducer = ConversationListOptimisticReducer() @Test - fun apply_emptyOverrides_returnsSameItems() { - val items = listOf( + fun apply_emptyOverrides_returnsItemsUnchanged() { + val items = persistentListOf( conversationItem("a"), conversationItem("b"), - ).toImmutableList() + ) val result = reducer.apply( items = items, @@ -29,30 +28,31 @@ internal class ConversationListOptimisticReducerTest { } @Test - fun apply_archivedId_removesItem() { - val items = listOf( - conversationItem("a"), + fun apply_archivedOverride_removesItem() { + val archivedItem = conversationItem("a") + val items = persistentListOf( + archivedItem, conversationItem("b"), - ).toImmutableList() + ) val result = reducer.apply( items = items, overrides = ConversationListOptimisticOverrides( - archivedIds = persistentSetOf("a"), + archiveById = persistentMapOf( + "a" to ConversationArchiveOverride.Archived(archivedItem), + ), ), ) - assertEquals(listOf("b"), result.map { it.conversationId }) + assertEquals(listOf("b"), result.conversationIds()) } @Test - fun apply_readOverride_overridesReadState() { - val items = listOf( - conversationItem( - conversationId = "a", - isRead = false, - ), - ).toImmutableList() + fun apply_readOverride_updatesStateWithoutReordering() { + val items = persistentListOf( + conversationItem("a", isRead = false, timestamp = 1_000L), + conversationItem("b", timestamp = 2_000L), + ) val result = reducer.apply( items = items, @@ -61,16 +61,17 @@ internal class ConversationListOptimisticReducerTest { ), ) - assertTrue(result.single().latestMessage.isRead) + assertEquals(listOf("a", "b"), result.conversationIds()) + assertTrue(result.first().latestMessage.isRead) } @Test - fun apply_pinOverride_pinsAndReordersToTop() { - val items = listOf( + fun apply_pinOverride_reordersByPinThenTimestamp() { + val items = persistentListOf( conversationItem("a", timestamp = 3_000L), - conversationItem("b", timestamp = 2_000L), + conversationItem("b", isPinned = true, timestamp = 2_000L), conversationItem("c", timestamp = 1_000L), - ).toImmutableList() + ) val result = reducer.apply( items = items, @@ -79,210 +80,172 @@ internal class ConversationListOptimisticReducerTest { ), ) - assertEquals(listOf("c", "a", "b"), result.map { it.conversationId }) - assertTrue(result.first().isPinned) + assertEquals(listOf("b", "c", "a"), result.conversationIds()) + assertTrue(result.first { it.conversationId == "c" }.isPinned) } @Test - fun apply_restoringItemMissingFromDatabase_keepsItemVisible() { - val item = conversationItem( - conversationId = "a", - isRead = false, + fun apply_unpinOverride_movesItemIntoTimestampOrder() { + val items = persistentListOf( + conversationItem("a", isPinned = true, timestamp = 1_000L), + conversationItem("b", timestamp = 3_000L), ) val result = reducer.apply( - items = emptyList().toImmutableList(), + items = items, overrides = ConversationListOptimisticOverrides( - restoringById = persistentMapOf( - "a" to RestoringConversation( - item = item, - hasObservedArchivedSnapshot = true, - ), - ), - readById = persistentMapOf("a" to true), + pinnedById = persistentMapOf("a" to false), ), ) - assertEquals(listOf("a"), result.map(ConversationListItem::conversationId)) - assertTrue(result.single().latestMessage.isRead) + assertEquals(listOf("b", "a"), result.conversationIds()) + assertFalse(result.last().isPinned) } @Test - fun apply_restoringItemAlongsideExistingItems_reordersByPinThenTimestamp() { - val present = listOf( - conversationItem("a", timestamp = 3_000L), - conversationItem("b", timestamp = 1_000L), - ).toImmutableList() - - val restoring = conversationItem( + fun apply_restoringItemMissingFromRawSnapshot_keepsItVisibleAndOrdered() { + val restoringItem = conversationItem( conversationId = "c", isPinned = true, + isRead = false, timestamp = 2_000L, ) val result = reducer.apply( - items = present, + items = persistentListOf( + conversationItem("a", timestamp = 3_000L), + conversationItem("b", timestamp = 1_000L), + ), overrides = ConversationListOptimisticOverrides( - restoringById = persistentMapOf( - "c" to RestoringConversation( - item = restoring, - hasObservedArchivedSnapshot = true, + archiveById = persistentMapOf( + "c" to ConversationArchiveOverride.Restoring( + item = restoringItem, + awaitingRemoval = false, ), ), + readById = persistentMapOf("c" to true), ), ) - assertEquals(listOf("c", "a", "b"), result.map { it.conversationId }) - } - - @Test - fun prune_dropsArchivedId_whenItemNoLongerPresent() { - val raw = listOf(conversationItem("b")).toImmutableList() - - val pruned = reducer.prune( - items = raw, - overrides = ConversationListOptimisticOverrides( - archivedIds = persistentSetOf("a"), - ), - ) - - assertTrue(pruned.isEmpty) - } - - @Test - fun prune_keepsArchivedId_whilePresent() { - val raw = listOf(conversationItem("a")).toImmutableList() - - val pruned = reducer.prune( - items = raw, - overrides = ConversationListOptimisticOverrides( - archivedIds = persistentSetOf("a"), - ), - ) - - assertTrue("a" in pruned.archivedIds) + assertEquals(listOf("c", "a", "b"), result.conversationIds()) + assertTrue(result.first().latestMessage.isRead) } @Test - fun prune_dropsReadOverride_whenDatabaseMatches() { - val raw = listOf( - conversationItem( - conversationId = "a", - isRead = true, - ), - ).toImmutableList() + fun apply_restoringItemAlreadyInRawSnapshot_doesNotDuplicateCachedItem() { + val cachedItem = conversationItem("a", isRead = false) + val rawItem = conversationItem("a", isRead = true) - val pruned = reducer.prune( - items = raw, + val result = reducer.apply( + items = persistentListOf(rawItem), overrides = ConversationListOptimisticOverrides( - readById = persistentMapOf("a" to true), + archiveById = persistentMapOf( + "a" to ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = false, + ), + ), ), ) - assertFalse("a" in pruned.readById) + assertEquals(listOf("a"), result.conversationIds()) + assertTrue(result.single().latestMessage.isRead) } @Test - fun prune_keepsReadOverride_whileDatabaseDiffers() { - val raw = listOf( - conversationItem( - conversationId = "a", - isRead = false, - ), - ).toImmutableList() + fun prune_archivedItemMissingFromRawSnapshot_keepsItForUndo() { + val archivedItem = conversationItem("a") + val archivedOverride = ConversationArchiveOverride.Archived(archivedItem) val pruned = reducer.prune( - items = raw, + items = persistentListOf(), overrides = ConversationListOptimisticOverrides( - readById = persistentMapOf("a" to true), + archiveById = persistentMapOf("a" to archivedOverride), ), ) - assertEquals(true, pruned.readById["a"]) + assertEquals(archivedOverride, pruned.archiveById["a"]) } @Test - fun archiveUndoThenRead_keepsItemAndReadOverrideThroughDatabaseRace() { - val unreadItem = conversationItem( + fun prune_restoreRace_retainsOverridesUntilRawSnapshotCatchesUp() { + val cachedItem = conversationItem( conversationId = "a", + isPinned = false, isRead = false, ) var overrides = ConversationListOptimisticOverrides( - archivedItemsById = persistentMapOf("a" to unreadItem), - restoringById = persistentMapOf( - "a" to RestoringConversation( - item = unreadItem, - hasObservedArchivedSnapshot = false, + archiveById = persistentMapOf( + "a" to ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = true, ), ), readById = persistentMapOf("a" to true), + pinnedById = persistentMapOf("a" to true), ) overrides = reducer.prune( - items = emptyList().toImmutableList(), + items = persistentListOf(cachedItem), overrides = overrides, ) + assertEquals( + ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = true, + ), + overrides.archiveById["a"], + ) - val whileArchiveSnapshotIsVisible = reducer.apply( - items = emptyList().toImmutableList(), + overrides = reducer.prune( + items = persistentListOf(), overrides = overrides, ) - - assertEquals(listOf("a"), whileArchiveSnapshotIsVisible.map { it.conversationId }) - assertTrue(whileArchiveSnapshotIsVisible.single().latestMessage.isRead) - assertTrue(overrides.restoringById.getValue("a").hasObservedArchivedSnapshot) - assertEquals(true, overrides.readById["a"]) + assertEquals( + ConversationArchiveOverride.Restoring( + item = cachedItem, + awaitingRemoval = false, + ), + overrides.archiveById["a"], + ) + assertTrue(overrides.readById.getValue("a")) + assertTrue(overrides.pinnedById.getValue("a")) overrides = reducer.prune( - items = listOf(unreadItem).toImmutableList(), + items = persistentListOf(cachedItem), overrides = overrides, ) + assertFalse("a" in overrides.archiveById) + assertTrue(overrides.readById.getValue("a")) + assertTrue(overrides.pinnedById.getValue("a")) - val afterUnarchiveSnapshot = reducer.apply( - items = listOf(unreadItem).toImmutableList(), + overrides = reducer.prune( + items = persistentListOf( + conversationItem( + conversationId = "a", + isPinned = true, + isRead = true, + ), + ), overrides = overrides, ) - - assertFalse("a" in overrides.restoringById) - assertTrue(afterUnarchiveSnapshot.single().latestMessage.isRead) - assertEquals(true, overrides.readById["a"]) + assertTrue(overrides.isEmpty) } @Test - fun prune_dropsPinOverride_whenDatabaseMatches() { - val raw = listOf( - conversationItem( - conversationId = "a", - isPinned = true, - ), - ).toImmutableList() - + fun prune_itemMissingAndNotRestoring_dropsReadAndPinOverrides() { val pruned = reducer.prune( - items = raw, + items = persistentListOf(), overrides = ConversationListOptimisticOverrides( + readById = persistentMapOf("a" to true), pinnedById = persistentMapOf("a" to true), ), ) - assertFalse("a" in pruned.pinnedById) + assertTrue(pruned.isEmpty) } - @Test - fun prune_keepsPinOverride_whileDatabaseDiffers() { - val raw = listOf( - conversationItem( - conversationId = "a", - isPinned = false, - ), - ).toImmutableList() - - val pruned = reducer.prune( - items = raw, - overrides = ConversationListOptimisticOverrides( - pinnedById = persistentMapOf("a" to true), - ), - ) - - assertEquals(true, pruned.pinnedById["a"]) + private fun List.conversationIds(): List { + return map(ConversationListItem::conversationId) } } From 6019b62191300995f91ad441add72867430b2d83 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 25 Jun 2026 00:53:48 +0200 Subject: [PATCH 30/39] Cover conversation stores and permission gate --- .../store/ConversationPinStoreTest.kt | 22 +++ .../store/ConversationReadStoreTest.kt | 145 ++++++++++++++++++ .../store/ConversationListStatusStoreTest.kt | 66 ++++++++ .../ui/ActivityPermissionGateTest.kt | 1 - 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt index a92c7ae83..fecb3d436 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt @@ -13,6 +13,8 @@ import io.mockk.unmockkAll import io.mockk.verify import io.mockk.verifyOrder import org.junit.After +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -81,6 +83,26 @@ class ConversationPinStoreTest { } } + @Test + fun pinConversation_updateFails_endsTransactionWithoutNotifying() { + val failure = IllegalStateException("update failed") + every { + BugleDatabaseOperations.updateConversationPinStatusInTransaction(any(), any(), any()) + } throws failure + + val thrown = assertThrows(IllegalStateException::class.java) { + store.pinConversation(CONVERSATION_ID) + } + + assertSame(failure, thrown) + verify(exactly = 1) { databaseWrapper.endTransaction() } + verify(exactly = 0) { databaseWrapper.setTransactionSuccessful() } + verify(exactly = 0) { MessagingContentProvider.notifyConversationListChanged() } + verify(exactly = 0) { + MessagingContentProvider.notifyConversationMetadataChanged(any()) + } + } + private companion object { private const val CONVERSATION_ID = "conversation-42" } diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt new file mode 100644 index 000000000..db56a1249 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt @@ -0,0 +1,145 @@ +package com.android.messaging.data.conversation.store + +import android.content.ContentValues +import android.database.sqlite.SQLiteStatement +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseHelper +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.sms.MmsUtils +import com.android.messaging.util.PendingIntentConstants +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationReadStoreTest { + + private val database = mockk(relaxed = true) + private val dataModel = mockk() + private val latestMessageStatement = mockk() + + private val store = ConversationReadStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(BugleDatabaseOperations::class) + mockkStatic(BugleNotifications::class) + mockkStatic(MessagingContentProvider::class) + mockkStatic(MmsUtils::class) + + every { DataModel.get() } returns dataModel + every { dataModel.database } returns database + every { BugleDatabaseOperations.getThreadId(any(), any()) } returns THREAD_ID + every { + BugleDatabaseOperations.getQueryConversationsLatestMessageStatement(any(), any()) + } returns latestMessageStatement + every { latestMessageStatement.simpleQueryForString() } returns MESSAGE_ID + every { database.update(any(), any(), any(), any()) } returns 1 + every { MmsUtils.updateSmsReadStatus(any(), any()) } just runs + every { MessagingContentProvider.notifyMessagesChanged(any()) } just runs + every { BugleNotifications.cancel(any(), any()) } just runs + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun markConversationRead_updatesMessagesAndCancelsNotification() { + val values = slot() + + store.markConversationRead(CONVERSATION_ID) + + verify { MmsUtils.updateSmsReadStatus(THREAD_ID, Long.MAX_VALUE) } + verify { + database.update( + DatabaseHelper.MESSAGES_TABLE, + capture(values), + any(), + match { arguments -> arguments.contentEquals(arrayOf(CONVERSATION_ID)) }, + ) + } + assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.READ)) + assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.SEEN)) + verify { MessagingContentProvider.notifyMessagesChanged(CONVERSATION_ID) } + verify { + BugleNotifications.cancel( + PendingIntentConstants.SMS_NOTIFICATION_ID, + CONVERSATION_ID, + ) + } + } + + @Test + fun markConversationRead_nothingUpdated_skipsTelephonyAndContentNotifications() { + every { BugleDatabaseOperations.getThreadId(any(), any()) } returns -1L + every { database.update(any(), any(), any(), any()) } returns 0 + + store.markConversationRead(CONVERSATION_ID) + + verify(exactly = 0) { MmsUtils.updateSmsReadStatus(any(), any()) } + verify(exactly = 0) { MessagingContentProvider.notifyMessagesChanged(any()) } + verify { + BugleNotifications.cancel( + PendingIntentConstants.SMS_NOTIFICATION_ID, + CONVERSATION_ID, + ) + } + } + + @Test + fun markConversationUnread_updatesLatestMessage() { + val values = slot() + + store.markConversationUnread(CONVERSATION_ID) + + verify { + BugleDatabaseOperations.getQueryConversationsLatestMessageStatement( + database, + CONVERSATION_ID, + ) + } + verify { + database.update( + DatabaseHelper.MESSAGES_TABLE, + capture(values), + any(), + match { arguments -> arguments.contentEquals(arrayOf(MESSAGE_ID)) }, + ) + } + assertEquals(0, values.captured.getAsInteger(DatabaseHelper.MessageColumns.READ)) + verify { MessagingContentProvider.notifyMessagesChanged(CONVERSATION_ID) } + } + + @Test + fun markConversationUnread_noLatestMessage_doesNothing() { + every { latestMessageStatement.simpleQueryForString() } returns null + + store.markConversationUnread(CONVERSATION_ID) + + verify(exactly = 0) { database.update(any(), any(), any(), any()) } + verify(exactly = 0) { MessagingContentProvider.notifyMessagesChanged(any()) } + } + + private companion object { + private const val CONVERSATION_ID = "conversation-42" + private const val MESSAGE_ID = "message-24" + private const val THREAD_ID = 7L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt new file mode 100644 index 000000000..50439319a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt @@ -0,0 +1,66 @@ +package com.android.messaging.data.conversationlist.store + +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.SyncManager +import com.android.messaging.receiver.SmsReceiver +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class ConversationListStatusStoreTest { + + private val dataModel = mockk(relaxed = true) + private val syncManager = mockk() + + private val store = ConversationListStatusStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(SmsReceiver::class) + + every { DataModel.get() } returns dataModel + every { dataModel.syncManager } returns syncManager + every { SmsReceiver.cancelSecondaryUserNotification() } just runs + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun hasFirstSyncCompleted_returnsSyncManagerValue() { + every { syncManager.hasFirstSyncCompleted } returns true + + assertTrue(store.hasFirstSyncCompleted()) + } + + @Test + fun setNewestConversationVisible_visible_updatesStatusAndCancelsSecondaryNotification() { + store.setNewestConversationVisible(isVisible = true) + + verify { dataModel.isConversationListScrolledToNewestConversation = true } + verify { SmsReceiver.cancelSecondaryUserNotification() } + } + + @Test + fun setNewestConversationVisible_notVisible_updatesStatusWithoutCancellingNotification() { + store.setNewestConversationVisible(isVisible = false) + + verify(exactly = 1) { + dataModel.isConversationListScrolledToNewestConversation = false + } + verify(exactly = 0) { + SmsReceiver.cancelSecondaryUserNotification() + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt index df2b8d4fe..dc0ec186a 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt @@ -1,6 +1,5 @@ package com.android.messaging.ui -import com.android.messaging.ui.blockedparticipants.BlockedParticipantsActivity import com.android.messaging.ui.conversation.LaunchConversationActivity import com.android.messaging.ui.license.LicenseActivity import com.android.messaging.ui.permissioncheck.PermissionCheckActivity From 270dfaa962fe70adfe984352fe92e29bc797ad27 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 25 Jun 2026 01:15:48 +0200 Subject: [PATCH 31/39] Cover conversation list reorder behavior --- .../OverlayReorderAnimationControllerTest.kt | 85 +++++++++++++ .../reorder/OverlayReorderGeometryTest.kt | 36 ++++++ .../ui/AppearanceAnimationTrackerTest.kt | 62 ++++++++++ .../ui/ConversationListContentTest.kt | 113 ++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt new file mode 100644 index 000000000..6b5790619 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt @@ -0,0 +1,85 @@ +package com.android.messaging.ui.common.components.reorder + +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class OverlayReorderAnimationControllerTest { + + private val controller = OverlayReorderAnimationController( + key = { it }, + isSettled = { _, _ -> true }, + ) + + @Test + fun prepare_withKnownItemAndBounds_createsHiddenPendingAnimation() { + seedItem("a") + + controller.prepare( + keys = listOf("a"), + anchorToTop = true, + transform = { it }, + ) + + val animation = controller.animations.single() + assertEquals("a", animation.key) + assertFalse(animation.isCommitted) + assertFalse(animation.isStarted) + assertTrue(controller.isItemHidden("a")) + } + + @Test + fun startAnimation_onlySucceedsOnceAndAfterCommit() { + seedItem("a") + controller.prepare( + keys = listOf("a"), + anchorToTop = true, + transform = { it }, + ) + + val animationId = controller.animations.single().animationId + + assertNull(controller.startAnimation(animationId)) + + controller.markCommitted() + + val started = controller.startAnimation(animationId) + + assertNotNull(started) + assertTrue(started!!.isStarted) + assertNull(controller.startAnimation(animationId)) + } + + @Test + fun finish_removesAnimationAndUnhidesItem() { + seedItem("a") + controller.prepare( + keys = listOf("a"), + anchorToTop = true, + transform = { it }, + ) + + val animationId = controller.animations.single().animationId + + controller.finish(animationId) + + assertTrue(controller.animations.isEmpty()) + assertFalse(controller.isItemHidden("a")) + } + + private fun seedItem(itemKey: String) { + controller.updateItems(listOf(itemKey)) + controller.updateContainerBounds(Rect(left = 0f, top = 0f, right = 100f, bottom = 1_000f)) + controller.updateItemBounds( + itemKey = itemKey, + boundsInRoot = Rect(left = 0f, top = 0f, right = 100f, bottom = 50f), + isPhysicallyVisible = true, + firstVisibleItemIndex = 0, + lastVisibleItemIndex = 0, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt index ad6e6b292..8b4156f8e 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt @@ -71,6 +71,42 @@ class OverlayReorderGeometryTest { ) } + @Test + fun isAcceptableTarget_movesDownButShiftWithinEpsilon_isRejected() { + assertFalse( + isAcceptableTarget( + sourceIndex = 0, + sourceTop = 0f, + candidateTop = TARGET_POSITION_EPSILON_PX, + targetIndex = 4, + ), + ) + } + + @Test + fun isAcceptableTarget_movesDownJustBeyondEpsilon_isAccepted() { + assertTrue( + isAcceptableTarget( + sourceIndex = 0, + sourceTop = 0f, + candidateTop = TARGET_POSITION_EPSILON_PX + 0.5f, + targetIndex = 4, + ), + ) + } + + @Test + fun isAcceptableTarget_movesUpButShiftWithinEpsilon_isRejected() { + assertFalse( + isAcceptableTarget( + sourceIndex = 3, + sourceTop = 300f, + candidateTop = 300f - TARGET_POSITION_EPSILON_PX, + targetIndex = 0, + ), + ) + } + @Test fun isAcceptableTarget_sameIndex_isAccepted() { assertTrue( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt new file mode 100644 index 000000000..89f9db5fe --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt @@ -0,0 +1,62 @@ +package com.android.messaging.ui.conversationlist.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppearanceAnimationTrackerTest { + + private val tracker = AppearanceAnimationTracker() + + @Test + fun computeEntering_firstFrame_hasNoEnteringConversations() { + val entering = tracker.computeEntering(setOf("a", "b")) + + assertTrue(entering.isEmpty()) + } + + @Test + fun computeEntering_afterCommit_marksOnlyAddedConversations() { + tracker.commitFrame(setOf("a", "b")) + + val entering = tracker.computeEntering(setOf("a", "b", "c")) + + assertEquals(setOf("c"), entering.keys) + } + + @Test + fun onAnimationFinished_withActiveToken_clearsToken() { + tracker.commitFrame(setOf("a")) + + val token = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.onAnimationFinished("b", token) + + assertNull(tracker.tokenFor("b", emptyMap())) + } + + @Test + fun onAnimationFinished_withStaleToken_keepsActiveToken() { + tracker.commitFrame(setOf("a")) + + val staleToken = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.commitFrame(setOf("a")) + + val activeToken = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.onAnimationFinished("b", staleToken) + + assertSame(activeToken, tracker.tokenFor("b", emptyMap())) + } + + private fun AppearanceAnimationTracker.commitFrame( + conversationIds: Set, + ): Map { + val entering = computeEntering(conversationIds) + commit( + currentConversationIds = conversationIds, + enteringTokens = entering, + ) + return entering + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt new file mode 100644 index 000000000..38b78fc7a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt @@ -0,0 +1,113 @@ +package com.android.messaging.ui.conversationlist.ui + +import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConversationListContentTest { + + @Test + fun resolvePinChangeScrollRequest_noPinChange_returnsNull() { + val items = listOf(item("a"), item("b")) + + val result = resolvePinChangeScrollRequest( + previousItems = items, + currentItems = items, + firstVisibleConversationId = "b", + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 12, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_conversationSetChanged_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("a", isPinned = true), item("c")), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_atStart_requestsFirstItem() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("b", isPinned = true), item("a")), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertEquals( + ConversationListScrollRequest( + index = 0, + scrollOffset = 0, + ), + result, + ) + } + + @Test + fun resolvePinChangeScrollRequest_firstVisibleItemPinned_preservesPreviousPosition() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b"), item("c")), + currentItems = listOf(item("b", isPinned = true), item("a"), item("c")), + firstVisibleConversationId = "b", + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 24, + ) + + assertEquals( + ConversationListScrollRequest( + index = 1, + scrollOffset = 24, + ), + result, + ) + } + + @Test + fun resolvePinChangeScrollRequest_otherItemPinned_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b"), item("c")), + currentItems = listOf(item("a", isPinned = true), item("b"), item("c")), + firstVisibleConversationId = "b", + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 24, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_firstVisibleItemUnknown_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("b", isPinned = true), item("a")), + firstVisibleConversationId = null, + firstVisibleItemIndex = 1, + firstVisibleItemScrollOffset = 24, + ) + + assertNull(result) + } + + private fun item( + conversationId: String, + isPinned: Boolean = false, + ): ConversationListItemUiModel { + return previewConversationListItem( + conversationId = conversationId, + title = conversationId, + snippetText = conversationId, + isPinned = isPinned, + ) + } +} From 93a18f71036c5e68fe7f899c7ced1eb170d9b21c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 25 Jun 2026 01:16:23 +0200 Subject: [PATCH 32/39] Cover conversation list delegate --- ...ConversationListActionsDelegateImplTest.kt | 303 +++++++++++++----- ...nListOptimisticSnapshotDelegateImplTest.kt | 129 ++++++-- ...nversationListSelectionDelegateImplTest.kt | 60 ++++ 3 files changed, 378 insertions(+), 114 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt index a724a24a7..552896a03 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt @@ -1,167 +1,299 @@ package com.android.messaging.ui.conversationlist.delegate +import app.cash.turbine.test import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.conversationsettings.model.SnoozeOption -import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.conversationlist.model.ConversationListEffect +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.Rule +import org.junit.Assert.assertEquals import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ConversationListActionsDelegateImplTest { - @get:Rule - val mainDispatcherRule = MainDispatcherRule() + @Test + fun setPinned_pinsEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() + + harness.delegate.setPinned( + conversationIds = listOf("a", "", " ", "b", "a"), + isPinned = true, + ) + runCurrent() + + coVerify(exactly = 1) { harness.conversationsRepository.pinConversation("a") } + coVerify(exactly = 1) { harness.conversationsRepository.pinConversation("b") } + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation("") } + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(" ") } + } @Test - fun setPinned_pinsEachDistinctNonBlankConversation() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun setPinned_unpinsWhenNotPinned() = runTest { + val harness = createHarness() - harness.delegate.setPinned( - conversationIds = listOf("a", "", " ", "b", "a"), - isPinned = true, - ) - advanceUntilIdle() + harness.delegate.setPinned( + conversationIds = listOf("a"), + isPinned = false, + ) + runCurrent() - coVerify(exactly = 1) { harness.conversationsRepository.pinConversation("a") } - coVerify(exactly = 1) { harness.conversationsRepository.pinConversation("b") } - coVerify(exactly = 0) { harness.conversationsRepository.pinConversation("") } - coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(" ") } - } + coVerify(exactly = 1) { harness.conversationsRepository.unpinConversation("a") } + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } } @Test - fun setPinned_unpinsWhenNotPinned() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun setPinned_emptyIds_doesNothing() = runTest { + val harness = createHarness() - harness.delegate.setPinned( - conversationIds = listOf("a"), - isPinned = false, - ) - advanceUntilIdle() + harness.delegate.setPinned( + conversationIds = emptyList(), + isPinned = true, + ) + runCurrent() - coVerify(exactly = 1) { harness.conversationsRepository.unpinConversation("a") } - coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } - } + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } + coVerify(exactly = 0) { harness.conversationsRepository.unpinConversation(any()) } } @Test - fun setPinned_emptyIds_doesNothing() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun setPinned_blankOnlyIds_doesNothing() = runTest { + val harness = createHarness() - harness.delegate.setPinned( - conversationIds = emptyList(), - isPinned = true, - ) - advanceUntilIdle() + harness.delegate.setPinned( + conversationIds = listOf("", " "), + isPinned = true, + ) + runCurrent() + + coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } + } + + @Test + fun setRead_marksEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() + + harness.delegate.setRead( + conversationIds = listOf("a", "", " ", "b", "a"), + isRead = true, + ) + runCurrent() + + coVerify(exactly = 1) { harness.conversationsRepository.markConversationRead("a") } + coVerify(exactly = 1) { harness.conversationsRepository.markConversationRead("b") } + coVerify(exactly = 0) { harness.conversationsRepository.markConversationUnread(any()) } + } + + @Test + fun setRead_marksUnreadWhenNotRead() = runTest { + val harness = createHarness() + + harness.delegate.setRead( + conversationIds = listOf("a"), + isRead = false, + ) + runCurrent() + + coVerify(exactly = 1) { harness.conversationsRepository.markConversationUnread("a") } + coVerify(exactly = 0) { harness.conversationsRepository.markConversationRead(any()) } + } + + @Test + fun snooze_snoozesEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() + + harness.delegate.snooze( + conversationIds = listOf("a", "", " ", "b", "a"), + option = SnoozeOption.OneHour, + ) - coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } - coVerify(exactly = 0) { harness.conversationsRepository.unpinConversation(any()) } + verify(exactly = 1) { + harness.conversationListRepository.snooze("a", SnoozeOption.OneHour) + } + verify(exactly = 1) { + harness.conversationListRepository.snooze("b", SnoozeOption.OneHour) } } @Test - fun setPinned_blankOnlyIds_doesNothing() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun unsnooze_clearsEachDistinctNonBlankConversation() = runTest { + val harness = createHarness() - harness.delegate.setPinned( - conversationIds = listOf("", " "), - isPinned = true, + harness.delegate.unsnooze(listOf("a", "", " ", "b", "a")) + + verify(exactly = 1) { harness.conversationListRepository.clearSnooze("a") } + verify(exactly = 1) { harness.conversationListRepository.clearSnooze("b") } + } + + @Test + fun setArchived_withSnackbar_archivesAndEmitsStatusEffect() = runTest { + val harness = createHarness() + + harness.delegate.effects.test { + harness.delegate.setArchived( + conversationIds = listOf("a", "", "a", "b"), + isArchived = true, + shouldShowSnackbar = true, ) - advanceUntilIdle() - coVerify(exactly = 0) { harness.conversationsRepository.pinConversation(any()) } + verify(exactly = 1) { harness.conversationsRepository.archiveConversation("a") } + verify(exactly = 1) { harness.conversationsRepository.archiveConversation("b") } + assertEquals( + ConversationListEffect.ArchiveStatusChanged( + conversationIds = persistentListOf("a", "b"), + isArchived = true, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() } } @Test - fun setRead_marksEachDistinctNonBlankConversation() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun setArchived_withoutSnackbar_archivesWithoutEmittingEffect() = runTest { + val harness = createHarness() - harness.delegate.setRead( - conversationIds = listOf("a", "", " ", "b", "a"), - isRead = true, + harness.delegate.effects.test { + harness.delegate.setArchived( + conversationIds = listOf("a"), + isArchived = true, + shouldShowSnackbar = false, ) - advanceUntilIdle() - coVerify(exactly = 1) { harness.conversationsRepository.markConversationRead("a") } - coVerify(exactly = 1) { harness.conversationsRepository.markConversationRead("b") } - coVerify(exactly = 0) { harness.conversationsRepository.markConversationUnread(any()) } + verify(exactly = 1) { harness.conversationsRepository.archiveConversation("a") } + expectNoEvents() + cancelAndIgnoreRemainingEvents() } } @Test - fun setRead_marksUnreadWhenNotRead() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun setArchived_unarchivesAndEmitsStatusEffect() = runTest { + val harness = createHarness() - harness.delegate.setRead( + harness.delegate.effects.test { + harness.delegate.setArchived( conversationIds = listOf("a"), - isRead = false, + isArchived = false, + shouldShowSnackbar = true, ) - advanceUntilIdle() - coVerify(exactly = 1) { harness.conversationsRepository.markConversationUnread("a") } - coVerify(exactly = 0) { harness.conversationsRepository.markConversationRead(any()) } + verify(exactly = 1) { harness.conversationsRepository.unarchiveConversation("a") } + assertEquals( + ConversationListEffect.ArchiveStatusChanged( + conversationIds = persistentListOf("a"), + isArchived = false, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() } } @Test - fun snooze_snoozesEachDistinctNonBlankConversation() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun block_emitsResultFromRepository() = runTest { + val harness = createHarness() + coEvery { + harness.blockedParticipantsRepository.setDestinationBlocked( + destination = "+15551234", + conversationId = "conv", + isBlocked = true, + ) + } returns true + + harness.delegate.effects.test { + harness.delegate.block(conversationId = "conv", destination = "+15551234") - harness.delegate.snooze( - conversationIds = listOf("a", "", " ", "b", "a"), - option = SnoozeOption.OneHour, + assertEquals( + ConversationListEffect.ConversationBlocked( + conversationId = "conv", + destination = "+15551234", + success = true, + ), + awaitItem(), ) + cancelAndIgnoreRemainingEvents() + } + } - verify(exactly = 1) { - harness.conversationListRepository.snooze("a", SnoozeOption.OneHour) - } - verify(exactly = 1) { - harness.conversationListRepository.snooze("b", SnoozeOption.OneHour) + @Test + fun block_blankDestination_doesNothing() = runTest { + val harness = createHarness() + + harness.delegate.effects.test { + harness.delegate.block(conversationId = "conv", destination = " ") + + coVerify(exactly = 0) { + harness.blockedParticipantsRepository.setDestinationBlocked(any(), any(), any()) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() } } @Test - fun unsnooze_clearsEachDistinctNonBlankConversation() { - runTest(context = mainDispatcherRule.testDispatcher) { - val harness = createHarness() + fun unblock_clearsBlockedState() = runTest { + val harness = createHarness() + coEvery { + harness.blockedParticipantsRepository.setDestinationBlocked( + destination = "+15551234", + conversationId = "conv", + isBlocked = false, + ) + } returns true - harness.delegate.unsnooze(listOf("a", "", " ", "b", "a")) + harness.delegate.unblock(conversationId = "conv", destination = "+15551234") + runCurrent() - verify(exactly = 1) { harness.conversationListRepository.clearSnooze("a") } - verify(exactly = 1) { harness.conversationListRepository.clearSnooze("b") } + coVerify(exactly = 1) { + harness.blockedParticipantsRepository.setDestinationBlocked( + destination = "+15551234", + conversationId = "conv", + isBlocked = false, + ) } } - private fun createHarness(): Harness { + @Test + fun delete_routesEachItemWithItsLatestMessageTimestamp() = runTest { + val harness = createHarness() + + harness.delegate.delete( + listOf( + conversationItem("a", timestamp = 5_000L), + conversationItem("b", timestamp = 7_000L), + ), + ) + + verify(exactly = 1) { harness.conversationsRepository.deleteConversation("a", 5_000L) } + verify(exactly = 1) { harness.conversationsRepository.deleteConversation("b", 7_000L) } + } + + private fun TestScope.createHarness(): Harness { val conversationsRepository = mockk(relaxed = true) val conversationListRepository = mockk(relaxed = true) + val blockedParticipantsRepository = + mockk(relaxed = true) val delegate = ConversationListActionsDelegateImpl( conversationsRepository = conversationsRepository, conversationListRepository = conversationListRepository, - blockedParticipantsRepository = mockk(relaxed = true), - ).apply { bind(scope = TestScope(mainDispatcherRule.testDispatcher)) } + blockedParticipantsRepository = blockedParticipantsRepository, + ).apply { + bind(backgroundScope) + } return Harness( conversationsRepository = conversationsRepository, conversationListRepository = conversationListRepository, + blockedParticipantsRepository = blockedParticipantsRepository, delegate = delegate, ) } @@ -169,6 +301,7 @@ class ConversationListActionsDelegateImplTest { private class Harness( val conversationsRepository: ConversationsRepository, val conversationListRepository: ConversationListRepository, + val blockedParticipantsRepository: BlockedParticipantsRepository, val delegate: ConversationListActionsDelegateImpl, ) } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt index cba612d5e..4e89031f3 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt @@ -5,13 +5,14 @@ import com.android.messaging.data.conversationlist.model.ConversationListSnapsho import com.android.messaging.data.conversationlist.repository.ConversationListRepository import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -19,19 +20,18 @@ import org.junit.Test class ConversationListOptimisticSnapshotDelegateImplTest { @Test - fun archive_removesItemFromEffectiveSnapshot() = runTest(UnconfinedTestDispatcher()) { - val delegate = bindDelegate(snapshot("a", "b")) + fun archive_removesItemFromEffectiveSnapshot() = runTest { + val delegate = bindDelegate(snapshotOfIds("a", "b")) delegate.archive(listOf("a")) - advanceUntilIdle() assertEquals(listOf("b"), delegate.conversationIds()) } @Test - fun pin_reordersEffectiveSnapshotToTop() = runTest(UnconfinedTestDispatcher()) { + fun pin_reordersEffectiveSnapshotToTop() = runTest { val delegate = bindDelegate( - snapshot( + snapshotOfItems( conversationItem("a", timestamp = 3_000L), conversationItem("b", timestamp = 2_000L), conversationItem("c", timestamp = 1_000L), @@ -42,15 +42,14 @@ class ConversationListOptimisticSnapshotDelegateImplTest { conversationIds = listOf("c"), isPinned = true, ) - advanceUntilIdle() assertEquals(listOf("c", "a", "b"), delegate.conversationIds()) } @Test - fun markRead_overridesReadStateInEffectiveSnapshot() = runTest(UnconfinedTestDispatcher()) { + fun markRead_overridesReadStateInEffectiveSnapshot() = runTest { val delegate = bindDelegate( - snapshot( + snapshotOfItems( conversationItem( conversationId = "a", isRead = false, @@ -62,28 +61,25 @@ class ConversationListOptimisticSnapshotDelegateImplTest { conversationIds = listOf("a"), isRead = true, ) - advanceUntilIdle() - assertTrue(delegate.snapshot.value?.items?.single()?.latestMessage?.isRead == true) + val item = requireNotNull(delegate.snapshot.value).items.single() + assertTrue(item.latestMessage.isRead) } @Test - fun restoreArchived_afterArchive_bringsItemBack() = runTest(UnconfinedTestDispatcher()) { - val delegate = bindDelegate(snapshot("a", "b")) + fun restoreArchived_afterArchive_bringsItemBack() = runTest { + val delegate = bindDelegate(snapshotOfIds("a", "b")) delegate.archive(listOf("a")) - advanceUntilIdle() - delegate.restoreArchived(listOf("a")) - advanceUntilIdle() assertTrue("a" in delegate.conversationIds()) } @Test - fun overrideIsDropped_whenDatabaseCatchesUp() = runTest(UnconfinedTestDispatcher()) { + fun readOverrideIsDropped_afterDatabaseCatchesUp() = runTest { val rawSnapshot = MutableStateFlow( - snapshot( + snapshotOfItems( conversationItem( conversationId = "a", isRead = false, @@ -96,27 +92,101 @@ class ConversationListOptimisticSnapshotDelegateImplTest { conversationIds = listOf("a"), isRead = true, ) - advanceUntilIdle() - rawSnapshot.value = snapshot( + rawSnapshot.value = snapshotOfItems( conversationItem( conversationId = "a", isRead = true, ), ) - advanceUntilIdle() + runCurrent() + rawSnapshot.value = snapshotOfItems( + conversationItem( + conversationId = "a", + isRead = false, + ), + ) + runCurrent() - assertTrue(delegate.snapshot.value?.items?.single()?.latestMessage?.isRead == true) + val item = requireNotNull(delegate.snapshot.value).items.single() + assertFalse(item.latestMessage.isRead) } @Test - fun bind_isIdempotent() = runTest(UnconfinedTestDispatcher()) { - val delegate = bindDelegate(snapshot("a")) + fun discardArchived_afterDatabaseRemovesItem_keepsItemHidden() = + runTest { + val rawSnapshot = MutableStateFlow(snapshotOfIds("a", "b")) + val delegate = bindDelegate(rawSnapshot) + + delegate.archive(listOf("a")) + rawSnapshot.value = snapshotOfIds("b") + runCurrent() + + delegate.discardArchived(listOf("a")) + + assertEquals(listOf("b"), delegate.conversationIds()) + } + + @Test + fun restoreThenDiscard_keepsRestoredItemVisible() = runTest { + val delegate = bindDelegate(snapshotOfIds("a", "b")) + + delegate.archive(listOf("a")) + delegate.restoreArchived(listOf("a")) + delegate.discardArchived(listOf("a")) + + assertTrue("a" in delegate.conversationIds()) + } + + @Test + fun pinOverrideIsDropped_afterDatabaseCatchesUp() = runTest { + val rawSnapshot = MutableStateFlow( + snapshotOfItems( + conversationItem("a", isPinned = false, timestamp = 1_000L), + conversationItem("b", timestamp = 2_000L), + ), + ) + val delegate = bindDelegate(rawSnapshot) + + delegate.pin( + conversationIds = listOf("a"), + isPinned = true, + ) + assertEquals(listOf("a", "b"), delegate.conversationIds()) + + rawSnapshot.value = snapshotOfItems( + conversationItem("a", isPinned = true, timestamp = 1_000L), + conversationItem("b", timestamp = 2_000L), + ) + runCurrent() + rawSnapshot.value = snapshotOfItems( + conversationItem("b", timestamp = 2_000L), + conversationItem("a", isPinned = false, timestamp = 1_000L), + ) + runCurrent() + + val pinnedItem = delegate.snapshot.value + ?.items + ?.first { item -> item.conversationId == "a" } + + assertFalse(requireNotNull(pinnedItem).isPinned) + assertEquals(listOf("b", "a"), delegate.conversationIds()) + } + + @Test + fun bind_isIdempotent() = runTest { + val repository = mockk() + every { repository.observeInboxSnapshot() } returns MutableStateFlow(snapshotOfIds("a")) + val delegate = ConversationListOptimisticSnapshotDelegateImpl( + repository = repository, + reducer = ConversationListOptimisticReducer(), + ) - delegate.bind(TestScope()) - advanceUntilIdle() + delegate.bind(backgroundScope) + delegate.bind(backgroundScope) + runCurrent() - assertEquals(listOf("a"), delegate.conversationIds()) + verify(exactly = 1) { repository.observeInboxSnapshot() } } private fun TestScope.bindDelegate( @@ -128,7 +198,7 @@ class ConversationListOptimisticSnapshotDelegateImplTest { private fun TestScope.bindDelegate( rawSnapshot: MutableStateFlow, ): ConversationListOptimisticSnapshotDelegateImpl { - val repository = mockk(relaxed = true) + val repository = mockk() every { repository.observeInboxSnapshot() } returns rawSnapshot return ConversationListOptimisticSnapshotDelegateImpl( @@ -136,6 +206,7 @@ class ConversationListOptimisticSnapshotDelegateImplTest { reducer = ConversationListOptimisticReducer(), ).apply { bind(backgroundScope) + runCurrent() } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt new file mode 100644 index 000000000..163b2fb9b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt @@ -0,0 +1,60 @@ +package com.android.messaging.ui.conversationlist.delegate + +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationListSelectionDelegateImplTest { + + private val delegate = ConversationListSelectionDelegateImpl() + + @Test + fun toggle_addsThenRemovesConversation() { + delegate.toggle("a") + delegate.toggle("b") + + assertEquals(listOf("a", "b"), delegate.selectedIds.value) + + delegate.toggle("a") + + assertEquals(listOf("b"), delegate.selectedIds.value) + } + + @Test + fun toggle_blankConversationId_isIgnored() { + delegate.toggle(" ") + + assertTrue(delegate.selectedIds.value.isEmpty()) + } + + @Test + fun clear_removesAllSelection() { + delegate.toggle("a") + delegate.toggle("b") + + delegate.clear() + + assertTrue(delegate.selectedIds.value.isEmpty()) + } + + @Test + fun bind_dropsSelectionForConversationsMissingFromSnapshot() = runTest { + val snapshot = MutableStateFlow(snapshotOfIds("a", "b")) + delegate.bind(backgroundScope, snapshot) + runCurrent() + + delegate.toggle("a") + delegate.toggle("b") + + snapshot.value = snapshotOfIds("a") + runCurrent() + + assertEquals(listOf("a"), delegate.selectedIds.value) + } +} From a6e7f734bb90dce4156ae947c2e4d1f0f3befaa7 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 25 Jun 2026 01:16:42 +0200 Subject: [PATCH 33/39] Cover conversation list state mapping and actions --- .../store/ConversationPinStoreTest.kt | 2 +- .../ui/ActivityPermissionGateTest.kt | 17 +- .../reorder/OverlayReorderGeometryTest.kt | 2 +- .../ConversationListViewModelTest.kt | 279 ++++++++++++++++++ ...ConversationListActionsDelegateImplTest.kt | 2 +- ...nListOptimisticSnapshotDelegateImplTest.kt | 21 +- .../delegate/OptimisticTestFixtures.kt | 10 +- .../ConversationListUiStateMapperImplTest.kt | 157 ++++++++-- .../mapper/TargetUiStateMapperImplTest.kt | 25 +- 9 files changed, 460 insertions(+), 55 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt index fecb3d436..0e3b20025 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationPinStoreTest.kt @@ -21,7 +21,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class ConversationPinStoreTest { +internal class ConversationPinStoreTest { private val databaseWrapper = mockk(relaxed = true) private val dataModel = mockk() diff --git a/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt index dc0ec186a..012364890 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt @@ -10,7 +10,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.w3c.dom.Element -class ActivityPermissionGateTest { +internal class ActivityPermissionGateTest { private val applicationId = "com.android.messaging" @@ -52,8 +52,9 @@ class ActivityPermissionGateTest { ) } - private fun isGated(activityClass: Class<*>): Boolean = - gatingBases.any { base -> base.isAssignableFrom(activityClass) } + private fun isGated(activityClass: Class<*>): Boolean { + return gatingBases.any { base -> base.isAssignableFrom(activityClass) } + } private fun manifestActivityNames(): List { val document = DocumentBuilderFactory.newInstance() @@ -68,10 +69,12 @@ class ActivityPermissionGateTest { .map { name -> resolveClassName(name) } } - private fun resolveClassName(name: String): String = when { - name.startsWith(".") -> applicationId + name - !name.contains(".") -> "$applicationId.$name" - else -> name + private fun resolveClassName(name: String): String { + return when { + name.startsWith(".") -> applicationId + name + !name.contains(".") -> "$applicationId.$name" + else -> name + } } private fun manifestFile(): File { diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt index 8b4156f8e..a7393aea9 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderGeometryTest.kt @@ -6,7 +6,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class OverlayReorderGeometryTest { +internal class OverlayReorderGeometryTest { private val geometry = OverlayReorderGeometry() diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt new file mode 100644 index 000000000..4524b29e8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt @@ -0,0 +1,279 @@ +package com.android.messaging.ui.conversationlist + +import app.cash.turbine.test +import com.android.messaging.data.conversationlist.model.ConversationListDraft +import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.data.conversationlist.model.ConversationListLatestMessage +import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus +import com.android.messaging.data.conversationlist.model.ConversationListNotification +import com.android.messaging.data.conversationlist.model.ConversationListParticipant +import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.data.debug.DebugFeaturesProvider +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate +import com.android.messaging.ui.conversationlist.delegate.ConversationListSelectionDelegate +import com.android.messaging.ui.conversationlist.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationListViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository = mockk(relaxUnitFun = true) + private val uiStateMapper = mockk() + private val selectionDelegate = + mockk(relaxUnitFun = true) + private val actionsDelegate = mockk(relaxUnitFun = true) + private val optimisticSnapshotDelegate = + mockk(relaxUnitFun = true) + private val debugFeaturesProvider = mockk() + + private val snapshotFlow = MutableStateFlow(null) + private val selectedIdsFlow = MutableStateFlow>(persistentListOf()) + + @Test + fun init_bindsDelegates() { + createViewModel() + + verify(exactly = 1) { optimisticSnapshotDelegate.bind(any()) } + verify(exactly = 1) { selectionDelegate.bind(any(), snapshotFlow) } + verify(exactly = 1) { actionsDelegate.bind(any()) } + } + + @Test + fun archiveClicked_archivesOptimisticallyAndPersistsWithSnackbar() { + selectedIdsFlow.value = persistentListOf("a") + snapshotFlow.value = snapshotOf(conversationItem("a")) + + val viewModel = createViewModel() + + viewModel.onAction(Action.ArchiveClicked) + + verify { optimisticSnapshotDelegate.archive(listOf("a")) } + verify { + actionsDelegate.setArchived( + conversationIds = listOf("a"), + isArchived = true, + shouldShowSnackbar = true, + ) + } + verify { selectionDelegate.clear() } + } + + @Test + fun swipeToggleRead_marksUnreadConversationReadOptimisticallyAndPersists() { + snapshotFlow.value = snapshotOf(conversationItem("a", isRead = false)) + + val viewModel = createViewModel() + + viewModel.onAction(Action.ConversationSwipedToToggleRead("a")) + + verify { optimisticSnapshotDelegate.markRead(listOf("a"), isRead = true) } + verify { actionsDelegate.setRead(listOf("a"), isRead = true) } + } + + @Test + fun pinClicked_emitsPrepareAnimationEffectForSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + selectedIdsFlow.value = persistentListOf("a") + snapshotFlow.value = snapshotOf(conversationItem("a")) + + val viewModel = createViewModel() + + viewModel.effects.test { + viewModel.onAction(Action.PinClicked) + + assertEquals( + Effect.PreparePinAnimation( + conversationIds = persistentListOf("a"), + isPinned = true, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun pinAnimationPrepared_commitsPinChangeAndClearsSelection() { + val viewModel = createViewModel() + + viewModel.onAction( + Action.PinAnimationPrepared( + conversationIds = persistentListOf("a"), + isPinned = true, + ), + ) + + verify { optimisticSnapshotDelegate.pin(listOf("a"), isPinned = true) } + verify { actionsDelegate.setPinned(listOf("a"), isPinned = true) } + verify { selectionDelegate.clear() } + } + + @Test + fun conversationClicked_withoutSelection_emitsOpenConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + snapshotFlow.value = snapshotOf(conversationItem("a")) + + val viewModel = createViewModel() + + viewModel.effects.test { + viewModel.onAction(Action.ConversationClicked("a")) + + assertEquals(Effect.OpenConversation("a"), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun startChatClicked_emitsStartChatEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + + viewModel.effects.test { + viewModel.onAction(Action.StartChatClicked) + + assertEquals(Effect.StartChat, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun archiveSnackbarDismissed_discardsArchivedItems() { + val viewModel = createViewModel() + + viewModel.onAction( + Action.ArchiveSnackbarDismissed( + conversationIds = persistentListOf("a", "b"), + ), + ) + + verify { optimisticSnapshotDelegate.discardArchived(listOf("a", "b")) } + } + + @Test + fun archiveUndoClicked_restoresItemsAndPersistsUnarchivedState() { + val viewModel = createViewModel() + + viewModel.onAction( + Action.ArchiveUndoClicked( + conversationIds = persistentListOf("a"), + isArchived = true, + ), + ) + + verify { optimisticSnapshotDelegate.restoreArchived(listOf("a")) } + verify { + actionsDelegate.setArchived( + conversationIds = listOf("a"), + isArchived = false, + shouldShowSnackbar = false, + ) + } + } + + @Test + fun unarchiveUndoClicked_archivesItemsAndPersistsArchivedState() { + val viewModel = createViewModel() + + viewModel.onAction( + Action.ArchiveUndoClicked( + conversationIds = persistentListOf("a"), + isArchived = false, + ), + ) + + verify { optimisticSnapshotDelegate.archive(listOf("a")) } + verify { + actionsDelegate.setArchived( + conversationIds = listOf("a"), + isArchived = true, + shouldShowSnackbar = false, + ) + } + } + + private fun createViewModel(): ConversationListViewModel { + every { optimisticSnapshotDelegate.snapshot } returns snapshotFlow + every { selectionDelegate.selectedIds } returns selectedIdsFlow + every { actionsDelegate.effects } returns emptyFlow() + every { debugFeaturesProvider.isEnabled() } returns false + + return ConversationListViewModel( + repository = repository, + uiStateMapper = uiStateMapper, + selectionDelegate = selectionDelegate, + actionsDelegate = actionsDelegate, + optimisticSnapshotDelegate = optimisticSnapshotDelegate, + debugFeaturesProvider = debugFeaturesProvider, + ) + } + + private fun snapshotOf(vararg items: ConversationListItem): ConversationListSnapshot { + return ConversationListSnapshot( + items = persistentListOf(*items), + blockedDestinations = persistentSetOf(), + hasFirstSyncCompleted = true, + ) + } + + private fun conversationItem( + conversationId: String, + isRead: Boolean = true, + ): ConversationListItem { + return ConversationListItem( + conversationId = conversationId, + title = "Title $conversationId", + icon = null, + subject = null, + isArchived = false, + isPinned = false, + participant = ConversationListParticipant( + contactId = -1L, + lookupKey = null, + otherNormalizedDestination = "+1555000$conversationId", + isGroup = false, + isEnterprise = false, + ), + latestMessage = ConversationListLatestMessage( + isRead = isRead, + timestamp = 1_000L, + snippetText = "Snippet $conversationId", + previewUri = null, + previewContentType = null, + status = ConversationListMessageStatus.Normal, + isIncoming = true, + senderName = null, + ), + draft = ConversationListDraft( + isVisible = false, + snippetText = null, + previewUri = null, + previewContentType = null, + subject = null, + ), + notification = ConversationListNotification(isEnabled = true), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt index 552896a03..9583b6593 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt @@ -19,7 +19,7 @@ import org.junit.Assert.assertEquals import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -class ConversationListActionsDelegateImplTest { +internal class ConversationListActionsDelegateImplTest { @Test fun setPinned_pinsEachDistinctNonBlankConversation() = runTest { diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt index 4e89031f3..0b8fb73f6 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt @@ -17,7 +17,7 @@ import org.junit.Assert.assertTrue import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -class ConversationListOptimisticSnapshotDelegateImplTest { +internal class ConversationListOptimisticSnapshotDelegateImplTest { @Test fun archive_removesItemFromEffectiveSnapshot() = runTest { @@ -113,19 +113,18 @@ class ConversationListOptimisticSnapshotDelegateImplTest { } @Test - fun discardArchived_afterDatabaseRemovesItem_keepsItemHidden() = - runTest { - val rawSnapshot = MutableStateFlow(snapshotOfIds("a", "b")) - val delegate = bindDelegate(rawSnapshot) + fun discardArchived_afterDatabaseRemovesItem_keepsItemHidden() = runTest { + val rawSnapshot = MutableStateFlow(snapshotOfIds("a", "b")) + val delegate = bindDelegate(rawSnapshot) - delegate.archive(listOf("a")) - rawSnapshot.value = snapshotOfIds("b") - runCurrent() + delegate.archive(listOf("a")) + rawSnapshot.value = snapshotOfIds("b") + runCurrent() - delegate.discardArchived(listOf("a")) + delegate.discardArchived(listOf("a")) - assertEquals(listOf("b"), delegate.conversationIds()) - } + assertEquals(listOf("b"), delegate.conversationIds()) + } @Test fun restoreThenDiscard_keepsRestoredItemVisible() = runTest { diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt index f883319a5..9f2de2953 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt @@ -7,16 +7,16 @@ import com.android.messaging.data.conversationlist.model.ConversationListMessage import com.android.messaging.data.conversationlist.model.ConversationListNotification import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toImmutableList -internal fun snapshot(vararg conversationIds: String): ConversationListSnapshot { - return snapshot(*conversationIds.map { conversationItem(it) }.toTypedArray()) +internal fun snapshotOfIds(vararg conversationIds: String): ConversationListSnapshot { + return snapshotOfItems(*conversationIds.map(::conversationItem).toTypedArray()) } -internal fun snapshot(vararg items: ConversationListItem): ConversationListSnapshot { +internal fun snapshotOfItems(vararg items: ConversationListItem): ConversationListSnapshot { return ConversationListSnapshot( - items = items.toList().toImmutableList(), + items = persistentListOf(*items), blockedDestinations = persistentSetOf(), hasFirstSyncCompleted = true, ) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt index a58e3cb81..04e0326cc 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt @@ -17,25 +17,31 @@ import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhone import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import com.android.messaging.ui.conversationlist.model.ConversationListUiState +import io.mockk.every import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toImmutableList import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -class ConversationListUiStateMapperImplTest { +internal class ConversationListUiStateMapperImplTest { + + private val canAddContact = mockk(relaxed = true) + private val canPlacePhoneCall = mockk(relaxed = true) + private val canShowOrAddContact = mockk(relaxed = true) + private val isContactSaved = mockk(relaxed = true) + private val resolveAvatarUri = mockk(relaxed = true) private val mapper = ConversationListUiStateMapperImpl( context = mockk(relaxed = true), - canAddContact = mockk(relaxed = true), - canPlacePhoneCall = mockk(relaxed = true), - canShowOrAddContact = mockk(relaxed = true), - isContactSaved = mockk(relaxed = true), - resolveAvatarUri = mockk(relaxed = true), + canAddContact = canAddContact, + canPlacePhoneCall = canPlacePhoneCall, + canShowOrAddContact = canShowOrAddContact, + isContactSaved = isContactSaved, + resolveAvatarUri = resolveAvatarUri, ) @Test @@ -105,9 +111,9 @@ class ConversationListUiStateMapperImplTest { ) val actions = state.selection.actions - assertEquals(true, actions.firstSelectedIsPinned) - assertEquals(true, actions.firstSelectedIsSnoozed) - assertEquals(true, actions.firstSelectedIsUnread) + assertTrue(requireNotNull(actions.firstSelectedIsPinned)) + assertTrue(requireNotNull(actions.firstSelectedIsSnoozed)) + assertTrue(requireNotNull(actions.firstSelectedIsUnread)) } @Test @@ -131,8 +137,8 @@ class ConversationListUiStateMapperImplTest { ) val actions = state.selection.actions - assertEquals(false, actions.firstSelectedIsPinned) - assertEquals(false, actions.firstSelectedIsSnoozed) + assertFalse(requireNotNull(actions.firstSelectedIsPinned)) + assertFalse(requireNotNull(actions.firstSelectedIsSnoozed)) } @Test @@ -167,6 +173,116 @@ class ConversationListUiStateMapperImplTest { assertEquals("Jane", singleItem(state).snippet.senderName) } + @Test + fun map_itemsPresent_producesContentItems() { + val state = mapper.map( + snapshot = snapshotOf(conversationItem(conversationId = "a")), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertTrue(state.content is ConversationListContentUiState.Items) + } + + @Test + fun map_emptyAfterFirstSync_producesEmptyContent() { + val state = mapper.map( + snapshot = snapshotOf(), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertEquals(ConversationListContentUiState.Empty, state.content) + } + + @Test + fun map_emptyBeforeFirstSync_producesWaitingForSync() { + val state = mapper.map( + snapshot = ConversationListSnapshot( + items = persistentListOf(), + blockedDestinations = persistentSetOf(), + hasFirstSyncCompleted = false, + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + assertEquals(ConversationListContentUiState.WaitingForSync, state.content) + } + + @Test + fun map_visibleDraft_takesPrecedenceOverLatestMessage() { + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "a", + isDraftVisible = true, + draftSnippet = "Draft body", + draftSubject = "Draft subject", + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + val item = singleItem(state) + assertTrue(item.snippet.isDraft) + assertEquals("Draft body", item.snippet.text) + assertEquals("Draft subject", item.subject) + assertEquals(ConversationListMessageStatus.Draft, item.status) + assertTrue(item.isOutgoing) + } + + @Test + fun map_callableSavedContact_populatesAvatarCapabilities() { + every { canPlacePhoneCall(any()) } returns true + every { canShowOrAddContact(any(), any(), any(), any()) } returns true + every { isContactSaved(any(), any()) } returns true + + val state = mapper.map( + snapshot = snapshotOf( + conversationItem( + conversationId = "a", + contactId = 42L, + lookupKey = "lookup", + ), + ), + selectedConversationIds = persistentListOf(), + isScrollToTopVisible = false, + isDebugEnabled = false, + ) + + val avatar = singleItem(state).avatar + assertTrue(avatar.canCall) + assertTrue(avatar.canShowContact) + assertTrue(avatar.isContactSaved) + assertEquals("+1555000a", avatar.normalizedDestination) + } + + @Test + fun map_selectedBlockedConversation_propagatesScreenState() { + val state = mapper.map( + snapshot = ConversationListSnapshot( + items = persistentListOf(conversationItem(conversationId = "a")), + blockedDestinations = persistentSetOf("+1555000a"), + hasFirstSyncCompleted = true, + ), + selectedConversationIds = persistentListOf("a"), + isScrollToTopVisible = true, + isDebugEnabled = true, + ) + + assertTrue(singleItem(state).isSelected) + assertTrue(state.isScrollToTopVisible) + assertTrue(state.hasBlockedParticipants) + assertTrue(state.isDebugEnabled) + assertFalse(state.selection.actions.canBlock) + } + private fun singleItem( state: ConversationListUiState, ): ConversationListItemUiModel { @@ -175,7 +291,7 @@ class ConversationListUiStateMapperImplTest { private fun snapshotOf(vararg items: ConversationListItem): ConversationListSnapshot { return ConversationListSnapshot( - items = items.toList().toImmutableList(), + items = persistentListOf(*items), blockedDestinations = persistentSetOf(), hasFirstSyncCompleted = true, ) @@ -187,6 +303,11 @@ class ConversationListUiStateMapperImplTest { isSnoozed: Boolean = false, isRead: Boolean = true, senderName: String? = null, + contactId: Long = -1L, + lookupKey: String? = null, + isDraftVisible: Boolean = false, + draftSnippet: String? = null, + draftSubject: String? = null, ): ConversationListItem { return ConversationListItem( conversationId = conversationId, @@ -196,8 +317,8 @@ class ConversationListUiStateMapperImplTest { isArchived = false, isPinned = isPinned, participant = ConversationListParticipant( - contactId = -1L, - lookupKey = null, + contactId = contactId, + lookupKey = lookupKey, otherNormalizedDestination = "+1555000$conversationId", isGroup = false, isEnterprise = false, @@ -213,11 +334,11 @@ class ConversationListUiStateMapperImplTest { senderName = senderName, ), draft = ConversationListDraft( - isVisible = false, - snippetText = null, + isVisible = isDraftVisible, + snippetText = draftSnippet, previewUri = null, previewContentType = null, - subject = null, + subject = draftSubject, ), notification = ConversationListNotification( isEnabled = true, diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt index 1cab01858..444dc2f2c 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/mapper/TargetUiStateMapperImplTest.kt @@ -15,6 +15,9 @@ import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -79,7 +82,7 @@ internal class TargetUiStateMapperImplTest { assertEquals("wrapped:Name", conversation.displayName) assertEquals("canonical:+15550100", conversation.normalizedDestination) assertEquals("details:formatted:+15550100", conversation.details) - assertEquals(false, conversation.isGroup) + assertFalse(conversation.isGroup) } @Test @@ -96,9 +99,9 @@ internal class TargetUiStateMapperImplTest { ).single() val conversation = result as TargetUiState.Conversation - assertEquals(true, conversation.isGroup) - assertEquals(null, conversation.normalizedDestination) - assertEquals(null, conversation.details) + assertTrue(conversation.isGroup) + assertNull(conversation.normalizedDestination) + assertNull(conversation.details) } @Test @@ -115,7 +118,7 @@ internal class TargetUiStateMapperImplTest { ).single() val conversation = result as TargetUiState.Conversation - assertEquals(null, conversation.normalizedDestination) + assertNull(conversation.normalizedDestination) } @Test @@ -127,8 +130,8 @@ internal class TargetUiStateMapperImplTest { ).single() val conversation = result as TargetUiState.Conversation - assertEquals(null, conversation.normalizedDestination) - assertEquals(null, conversation.details) + assertNull(conversation.normalizedDestination) + assertNull(conversation.details) } @Test @@ -151,7 +154,7 @@ internal class TargetUiStateMapperImplTest { persistentListOf(conversation(icon = avatarIcon)), ).single() - assertEquals(null, result.avatarUri) + assertNull(result.avatarUri) } @Test @@ -171,7 +174,7 @@ internal class TargetUiStateMapperImplTest { persistentListOf(conversation(icon = null)), ).single() - assertEquals(null, result.avatarUri) + assertNull(result.avatarUri) } @Test @@ -180,7 +183,7 @@ internal class TargetUiStateMapperImplTest { persistentListOf(conversation(icon = " ")), ).single() - assertEquals(null, result.avatarUri) + assertNull(result.avatarUri) } @Test @@ -196,7 +199,7 @@ internal class TargetUiStateMapperImplTest { ), ).single() - assertEquals(null, result.details) + assertNull(result.details) } @Test From 941f605b9d1bd87b545f84b8330fd1e8dde0ed54 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 25 Jun 2026 17:19:14 +0200 Subject: [PATCH 34/39] Lock conversation swipe to horizontal dominant gestures --- .../ui/SwipeableConversationListItem.kt | 103 ++++++++++++++---- 1 file changed, 79 insertions(+), 24 deletions(-) diff --git a/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt index 3bbd8e388..b62f4808a 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt @@ -7,14 +7,15 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive -import androidx.compose.material.icons.filled.MarkChatRead import androidx.compose.material.icons.filled.MarkChatUnread import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -30,9 +31,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.layout import androidx.compose.ui.res.stringResource @@ -41,11 +47,11 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel -import kotlin.math.abs -import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt private val SwipeBackgroundShape = RoundedCornerShape(percent = 50) @@ -61,6 +67,8 @@ private const val SWIPE_FLING_VELOCITY_THRESHOLD = 1_000f private const val SWIPE_HORIZONTAL_VELOCITY_BIAS = 1.5f +private const val SWIPE_DIRECTION_BIAS = 1.5f + private const val SWIPE_SETTLE_VISIBILITY_THRESHOLD = 1f private const val ARCHIVE_SLIDE_DURATION_MILLIS = 180 @@ -225,47 +233,90 @@ private fun Modifier.swipeActions( coroutineScope { var settleJob: Job? = null - detectHorizontalDragGestures( - onDragStart = { - velocityTracker.resetTracking() - settleJob?.cancel() - }, - onHorizontalDrag = { change, dragAmount -> - change.consume() - velocityTracker.addPosition(change.uptimeMillis, change.position) - offsetX.floatValue += dragAmount - }, - onDragEnd = { - val velocity = velocityTracker.calculateVelocity() - val width = size.width.toFloat() - settleJob = launch { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + velocityTracker.resetTracking() + settleJob?.cancel() + + var initialOverSlop = 0f + val slopChange = awaitTouchSlopOrCancellation(down.id) { change, overSlop -> + if (isHorizontalDrag(overSlop)) { + change.consume() + initialOverSlop = overSlop.x + } + } + + if (slopChange == null) { + return@awaitEachGesture + } + + velocityTracker.addPosition( + timeMillis = slopChange.uptimeMillis, + position = slopChange.position, + ) + offsetX.floatValue += initialOverSlop + + val completed = awaitHorizontalDragToEnd( + pointerId = slopChange.id, + offsetX = offsetX, + velocityTracker = velocityTracker, + ) + + settleJob = when { + completed -> launch { + val velocity = velocityTracker.calculateVelocity() settleSwipe( offsetX = offsetX, visibilityFraction = visibilityFraction, thresholdPx = thresholdPx, minFlingDistancePx = minFlingDistancePx, - width = width, + width = size.width.toFloat(), velocityX = velocity.x, velocityY = velocity.y, onArchive = onArchive, onToggleRead = onToggleRead, ) } - }, - onDragCancel = { - settleJob = launch { + + else -> launch { animateOffset( offsetX = offsetX, targetValue = 0f, animationSpec = SwipeSettleSpec, ) } - }, - ) + } + } } } } +private suspend fun AwaitPointerEventScope.awaitHorizontalDragToEnd( + pointerId: PointerId, + offsetX: MutableFloatState, + velocityTracker: VelocityTracker, +): Boolean { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + + if (change == null || change.isConsumed) { + return false + } + + if (change.changedToUpIgnoreConsumed()) { + return true + } + + velocityTracker.addPosition( + timeMillis = change.uptimeMillis, + position = change.position, + ) + offsetX.floatValue += change.positionChange().x + change.consume() + } +} + private suspend fun settleSwipe( offsetX: MutableFloatState, visibilityFraction: Animatable, @@ -321,6 +372,10 @@ private suspend fun settleSwipe( } } +private fun isHorizontalDrag(panOffset: Offset): Boolean { + return abs(panOffset.x) > abs(panOffset.y) * SWIPE_DIRECTION_BIAS +} + private fun resolveSettleAction( offset: Float, thresholdPx: Float, From 5bd84f094ee4689414a0f9c15a226cda0fad901a Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Fri, 26 Jun 2026 00:22:43 +0200 Subject: [PATCH 35/39] Stabilize conversation list appearance and insertion animations --- .../ui/AppearanceAnimationTrackerTest.kt | 68 ++++++++++++++++++- .../ui/ConversationListContentTest.kt | 54 +++++++++++++++ .../model/ConversationListSnapshot.kt | 2 + ...versationListOptimisticSnapshotDelegate.kt | 7 ++ .../mapper/ConversationListUiStateMapper.kt | 5 +- .../model/ConversationListUiState.kt | 3 + .../ui/ConversationAppearanceAnimation.kt | 39 +++++++++-- .../ui/ConversationListContent.kt | 34 ++++++++-- .../ui/ConversationListScreen.kt | 2 +- .../ui/SwipeableConversationListItem.kt | 9 ++- 10 files changed, 201 insertions(+), 22 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt index 89f9db5fe..09bf4ad22 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/AppearanceAnimationTrackerTest.kt @@ -12,7 +12,11 @@ class AppearanceAnimationTrackerTest { @Test fun computeEntering_firstFrame_hasNoEnteringConversations() { - val entering = tracker.computeEntering(setOf("a", "b")) + val entering = tracker.computeEntering( + setOf("a", "b"), + isListAtTop = true, + excludedConversationIds = emptySet(), + ) assertTrue(entering.isEmpty()) } @@ -21,11 +25,41 @@ class AppearanceAnimationTrackerTest { fun computeEntering_afterCommit_marksOnlyAddedConversations() { tracker.commitFrame(setOf("a", "b")) - val entering = tracker.computeEntering(setOf("a", "b", "c")) + val entering = tracker.computeEntering( + setOf("a", "b", "c"), + isListAtTop = true, + excludedConversationIds = emptySet(), + ) assertEquals(setOf("c"), entering.keys) } + @Test + fun computeEntering_whenListNotAtTop_marksNoEnteringConversations() { + tracker.commitFrame(setOf("a", "b")) + + val entering = tracker.computeEntering( + setOf("a", "b", "c"), + isListAtTop = false, + excludedConversationIds = emptySet(), + ) + + assertTrue(entering.isEmpty()) + } + + @Test + fun computeEntering_excludedConversation_marksNoEnteringToken() { + tracker.commitFrame(setOf("a", "b")) + + val entering = tracker.computeEntering( + setOf("a", "b", "c"), + isListAtTop = true, + excludedConversationIds = setOf("c"), + ) + + assertTrue(entering.isEmpty()) + } + @Test fun onAnimationFinished_withActiveToken_clearsToken() { tracker.commitFrame(setOf("a")) @@ -36,6 +70,30 @@ class AppearanceAnimationTrackerTest { assertNull(tracker.tokenFor("b", emptyMap())) } + @Test + fun tokenFor_afterFinish_doesNotReplayWhileStillEntering() { + tracker.commitFrame(setOf("a")) + + val entering = tracker.commitFrame(setOf("a", "b")) + val token = entering.getValue("b") + tracker.onAnimationFinished("b", token) + + assertNull(tracker.tokenFor("b", entering)) + } + + @Test + fun tokenFor_afterReentry_returnsFreshToken() { + tracker.commitFrame(setOf("a")) + + val firstToken = tracker.commitFrame(setOf("a", "b")).getValue("b") + tracker.onAnimationFinished("b", firstToken) + tracker.commitFrame(setOf("a")) + + val reentering = tracker.commitFrame(setOf("a", "b")) + val secondToken = reentering.getValue("b") + assertSame(secondToken, tracker.tokenFor("b", reentering)) + } + @Test fun onAnimationFinished_withStaleToken_keepsActiveToken() { tracker.commitFrame(setOf("a")) @@ -52,7 +110,11 @@ class AppearanceAnimationTrackerTest { private fun AppearanceAnimationTracker.commitFrame( conversationIds: Set, ): Map { - val entering = computeEntering(conversationIds) + val entering = computeEntering( + conversationIds, + isListAtTop = true, + excludedConversationIds = emptySet(), + ) commit( currentConversationIds = conversationIds, enteringTokens = entering, diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt index 38b78fc7a..7d100a9e0 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ui/ConversationListContentTest.kt @@ -14,6 +14,7 @@ class ConversationListContentTest { val result = resolvePinChangeScrollRequest( previousItems = items, currentItems = items, + restoredConversationIds = emptySet(), firstVisibleConversationId = "b", firstVisibleItemIndex = 1, firstVisibleItemScrollOffset = 12, @@ -27,6 +28,55 @@ class ConversationListContentTest { val result = resolvePinChangeScrollRequest( previousItems = listOf(item("a"), item("b")), currentItems = listOf(item("a", isPinned = true), item("c")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_newTopItemWhileAtStart_requestsFirstItem() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("c"), item("a"), item("b")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "a", + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + + assertEquals( + ConversationListScrollRequest( + index = 0, + scrollOffset = 0, + ), + result, + ) + } + + @Test + fun resolvePinChangeScrollRequest_newTopItemWhileScrolled_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("c"), item("a"), item("b")), + restoredConversationIds = emptySet(), + firstVisibleConversationId = "b", + firstVisibleItemIndex = 2, + firstVisibleItemScrollOffset = 10, + ) + + assertNull(result) + } + + @Test + fun resolvePinChangeScrollRequest_restoredTopItemWhileAtStart_returnsNull() { + val result = resolvePinChangeScrollRequest( + previousItems = listOf(item("a"), item("b")), + currentItems = listOf(item("c"), item("a"), item("b")), + restoredConversationIds = setOf("c"), firstVisibleConversationId = "a", firstVisibleItemIndex = 0, firstVisibleItemScrollOffset = 0, @@ -40,6 +90,7 @@ class ConversationListContentTest { val result = resolvePinChangeScrollRequest( previousItems = listOf(item("a"), item("b")), currentItems = listOf(item("b", isPinned = true), item("a")), + restoredConversationIds = emptySet(), firstVisibleConversationId = "a", firstVisibleItemIndex = 0, firstVisibleItemScrollOffset = 0, @@ -59,6 +110,7 @@ class ConversationListContentTest { val result = resolvePinChangeScrollRequest( previousItems = listOf(item("a"), item("b"), item("c")), currentItems = listOf(item("b", isPinned = true), item("a"), item("c")), + restoredConversationIds = emptySet(), firstVisibleConversationId = "b", firstVisibleItemIndex = 1, firstVisibleItemScrollOffset = 24, @@ -78,6 +130,7 @@ class ConversationListContentTest { val result = resolvePinChangeScrollRequest( previousItems = listOf(item("a"), item("b"), item("c")), currentItems = listOf(item("a", isPinned = true), item("b"), item("c")), + restoredConversationIds = emptySet(), firstVisibleConversationId = "b", firstVisibleItemIndex = 1, firstVisibleItemScrollOffset = 24, @@ -91,6 +144,7 @@ class ConversationListContentTest { val result = resolvePinChangeScrollRequest( previousItems = listOf(item("a"), item("b")), currentItems = listOf(item("b", isPinned = true), item("a")), + restoredConversationIds = emptySet(), firstVisibleConversationId = null, firstVisibleItemIndex = 1, firstVisibleItemScrollOffset = 24, diff --git a/src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt b/src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt index b34fd5298..f3521e7e8 100644 --- a/src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt +++ b/src/com/android/messaging/data/conversationlist/model/ConversationListSnapshot.kt @@ -2,9 +2,11 @@ package com.android.messaging.data.conversationlist.model import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf internal data class ConversationListSnapshot( val items: ImmutableList, val blockedDestinations: ImmutableSet, val hasFirstSyncCompleted: Boolean, + val restoredConversationIds: ImmutableSet = persistentSetOf(), ) diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt index 18ff124ea..e3cfb9efa 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegate.kt @@ -4,6 +4,7 @@ import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -161,11 +162,17 @@ internal class ConversationListOptimisticSnapshotDelegateImpl @Inject constructo private fun publishSnapshot() { val snapshot = rawSnapshot ?: return + val restoredConversationIds = overrides.archiveById + .filterValues { override -> override is ConversationArchiveOverride.Restoring } + .keys + .toImmutableSet() + _snapshot.value = snapshot.copy( items = reducer.apply( items = snapshot.items, overrides = overrides, ), + restoredConversationIds = restoredConversationIds, ) } } diff --git a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt index d4a07c6b2..f1b300354 100644 --- a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt @@ -58,7 +58,10 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( val content = when { items.isNotEmpty() -> { - ConversationListContentUiState.Items(items) + ConversationListContentUiState.Items( + items = items, + restoredConversationIds = snapshot.restoredConversationIds, + ) } !snapshot.hasFirstSyncCompleted -> { diff --git a/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt index 75139453c..f3c2c093d 100644 --- a/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/model/ConversationListUiState.kt @@ -2,6 +2,8 @@ package com.android.messaging.ui.conversationlist.model import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf @Immutable internal data class ConversationListUiState( @@ -27,5 +29,6 @@ internal sealed interface ConversationListContentUiState { @Immutable data class Items( val items: ImmutableList, + val restoredConversationIds: ImmutableSet = persistentSetOf(), ) : ConversationListContentUiState } diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt index 07b8808a7..5c0852b8a 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationAppearanceAnimation.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversationlist.ui +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable @@ -7,6 +8,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet internal class AppearanceAnimationToken @@ -15,15 +17,25 @@ internal class AppearanceAnimationTracker { private var knownConversationIds: Set? = null private val activeTokens = mutableStateMapOf() + private val consumedTokens = mutableStateMapOf() fun computeEntering( currentConversationIds: Set, + isListAtTop: Boolean, + excludedConversationIds: Set, ): Map { val previousConversationIds = knownConversationIds ?: return emptyMap() - return currentConversationIds - .minus(previousConversationIds) - .associateWith { AppearanceAnimationToken() } + return when { + !isListAtTop -> emptyMap() + + else -> { + currentConversationIds + .minus(previousConversationIds) + .minus(excludedConversationIds) + .associateWith { AppearanceAnimationToken() } + } + } } fun commit( @@ -35,6 +47,9 @@ internal class AppearanceAnimationTracker { activeTokens.keys .filterNot(currentConversationIds::contains) .forEach(activeTokens::remove) + consumedTokens.keys + .filterNot(currentConversationIds::contains) + .forEach(consumedTokens::remove) activeTokens.putAll(enteringTokens) } @@ -42,7 +57,9 @@ internal class AppearanceAnimationTracker { conversationId: String, enteringTokens: Map, ): AppearanceAnimationToken? { - return enteringTokens[conversationId] ?: activeTokens[conversationId] + val token = enteringTokens[conversationId] ?: activeTokens[conversationId] ?: return null + + return token.takeIf { consumedTokens[conversationId] != it } } fun onAnimationFinished( @@ -52,6 +69,7 @@ internal class AppearanceAnimationTracker { if (activeTokens[conversationId] == token) { activeTokens.remove(conversationId) } + consumedTokens[conversationId] = token } } @@ -74,13 +92,22 @@ internal class AppearanceAnimationTokens( @Composable internal fun rememberAppearanceAnimationTokens( items: ImmutableList, + listState: LazyListState, + excludedConversationIds: ImmutableSet, ): AppearanceAnimationTokens { val tracker = remember { AppearanceAnimationTracker() } val currentConversationIds = remember(items) { items.mapTo(HashSet(items.size), ConversationListItemUiModel::conversationId) } - val enteringTokens = remember(currentConversationIds) { - tracker.computeEntering(currentConversationIds) + val enteringTokens = remember(currentConversationIds, excludedConversationIds) { + val isListAtTop = listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 + + tracker.computeEntering( + currentConversationIds = currentConversationIds, + isListAtTop = isListAtTop, + excludedConversationIds = excludedConversationIds, + ) } SideEffect { diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt index 9ae50a342..75472be81 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt @@ -41,6 +41,7 @@ import com.android.messaging.ui.conversationlist.model.ConversationListContentUi import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import com.android.messaging.ui.core.MessagingPreviewTheme import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet private const val CONVERSATION_ROW_CONTENT_TYPE = "conversation_row" @@ -90,6 +91,7 @@ internal fun ConversationListContent( is ConversationListContentUiState.Items -> { ConversationListItems( items = content.items, + restoredConversationIds = content.restoredConversationIds, listState = listState, onAction = onAction, scaffoldContentPadding = scaffoldContentPadding, @@ -105,6 +107,7 @@ internal fun ConversationListContent( @Composable private fun ConversationListItems( items: ImmutableList, + restoredConversationIds: ImmutableSet, listState: LazyListState, onAction: (Action) -> Unit, scaffoldContentPadding: PaddingValues, @@ -112,7 +115,11 @@ private fun ConversationListItems( fabBottomReserve: Dp, pinAnimationController: OverlayReorderAnimationController?, ) { - val appearanceTokens = rememberAppearanceAnimationTokens(items) + val appearanceTokens = rememberAppearanceAnimationTokens( + items = items, + listState = listState, + excludedConversationIds = restoredConversationIds, + ) SideEffect { pinAnimationController?.updateItems(items) @@ -121,6 +128,7 @@ private fun ConversationListItems( KeepViewportStationaryOnPinChange( listState = listState, items = items, + restoredConversationIds = restoredConversationIds, ) LazyColumn( @@ -275,6 +283,7 @@ private fun Modifier.trackPinAnimationBounds( private fun KeepViewportStationaryOnPinChange( listState: LazyListState, items: ImmutableList, + restoredConversationIds: ImmutableSet, ) { val previousItemsState = remember { mutableStateOf(items) } @@ -290,6 +299,7 @@ private fun KeepViewportStationaryOnPinChange( val scrollRequest = resolvePinChangeScrollRequest( previousItems = previousItems, currentItems = items, + restoredConversationIds = restoredConversationIds, firstVisibleConversationId = firstVisibleConversationId, firstVisibleItemIndex = listState.firstVisibleItemIndex, firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset, @@ -314,18 +324,30 @@ internal data class ConversationListScrollRequest( internal fun resolvePinChangeScrollRequest( previousItems: List, currentItems: List, + restoredConversationIds: Set, firstVisibleConversationId: String?, firstVisibleItemIndex: Int, firstVisibleItemScrollOffset: Int, ): ConversationListScrollRequest? { val currentItemsById = currentItems.associateBy(ConversationListItemUiModel::conversationId) - - if (!hasPinReorder(previousItems, currentItemsById)) { - return null - } + val previousConversationIds = previousItems.mapTo(HashSet()) { it.conversationId } + val isAtTop = firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 + val currentTopConversationId = currentItems.firstOrNull()?.conversationId + val isNewTopConversation = currentTopConversationId != null && + currentTopConversationId !in previousConversationIds && + currentTopConversationId !in restoredConversationIds return when { - firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 -> { + isAtTop && isNewTopConversation -> { + ConversationListScrollRequest( + index = 0, + scrollOffset = 0, + ) + } + + !hasPinReorder(previousItems, currentItemsById) -> null + + isAtTop -> { ConversationListScrollRequest( index = 0, scrollOffset = 0, diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt index b8baa1046..1cd7a36d4 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt @@ -259,7 +259,7 @@ private fun ConversationListEffects( } Effect.ScrollToTop -> { - listState.animateScrollToItem(index = 0) + listState.scrollToItem(index = 0) } else -> currentEffectHandler.handle(effect) diff --git a/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt index b62f4808a..d43a62970 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/SwipeableConversationListItem.kt @@ -47,11 +47,11 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import kotlin.math.abs +import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.roundToInt private val SwipeBackgroundShape = RoundedCornerShape(percent = 50) @@ -141,8 +141,7 @@ internal fun SwipeableConversationListItem( .then(gestureModifier) .then(interactionModifier) .collapseVertically { visibilityFraction.value } - .graphicsLayer { alpha = visibilityFraction.value } - .clipToBounds(), + .graphicsLayer { alpha = visibilityFraction.value }, ) { ConversationListSwipeBackground( action = backgroundAction, @@ -209,7 +208,7 @@ private fun Modifier.consumeAllPointerInput(): Modifier { } private fun Modifier.collapseVertically(fraction: () -> Float): Modifier { - return layout { measurable, constraints -> + return clipToBounds().layout { measurable, constraints -> val placeable = measurable.measure(constraints) val height = (placeable.height * fraction().coerceIn(0f, 1f)).roundToInt() From 47dda70aeaa0da185daa434103664a08424d855d Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Fri, 26 Jun 2026 01:24:32 +0200 Subject: [PATCH 36/39] Tidy conversation list empty state and FABs --- .../mapper/ConversationListUiStateMapper.kt | 2 +- .../ui/ConversationListContent.kt | 49 ++++++++++++++++--- .../ui/ConversationListScreen.kt | 4 +- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt index f1b300354..2a07c2970 100644 --- a/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapper.kt @@ -82,7 +82,7 @@ internal class ConversationListUiStateMapperImpl @Inject constructor( return ConversationListUiState( content = content, selection = selection, - isScrollToTopVisible = isScrollToTopVisible, + isScrollToTopVisible = isScrollToTopVisible && items.isNotEmpty(), hasBlockedParticipants = snapshot.blockedDestinations.isNotEmpty(), isDebugEnabled = isDebugEnabled, ) diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt index 75472be81..75b7b4307 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListContent.kt @@ -3,16 +3,24 @@ package com.android.messaging.ui.conversationlist.ui import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,6 +43,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.android.messaging.R +import com.android.messaging.ui.common.components.PrimaryActionButton import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimationController import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState @@ -85,6 +94,13 @@ internal fun ConversationListContent( ConversationListContentUiState.Empty -> { ConversationListStatusMessage( text = stringResource(R.string.conversation_list_empty_text), + actionButton = { + PrimaryActionButton( + text = stringResource(R.string.conversation_list_start_chat), + onClick = { onAction(Action.StartChatClicked) }, + leadingIcon = Icons.AutoMirrored.Rounded.Chat, + ) + }, ) } @@ -418,19 +434,38 @@ private fun Modifier.conversationItemAnimation( } @Composable -private fun ConversationListStatusMessage(text: String) { +private fun ConversationListStatusMessage( + text: String, + actionButton: @Composable () -> Unit = {}, +) { Box( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(horizontal = EmptyTextHorizontalPadding), contentAlignment = Alignment.Center, ) { - Text( - text = text, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + Column( + modifier = Modifier + .widthIn(max = 360.dp) + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.large, + ) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + actionButton() + } } } diff --git a/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt index 1cd7a36d4..f8c358fc6 100644 --- a/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/ui/ConversationListScreen.kt @@ -438,6 +438,8 @@ private fun BoxScope.ConversationListFabs( onAction: (Action) -> Unit, onScrollToTop: () -> Unit, ) { + val hasItems = uiState.content is ConversationListContentUiState.Items + ScrollToTopFab( visible = uiState.isScrollToTopVisible, onClick = onScrollToTop, @@ -448,7 +450,7 @@ private fun BoxScope.ConversationListFabs( ) StartChatFab( - visible = !isSelectionMode, + visible = hasItems && !isSelectionMode, expanded = !uiState.isScrollToTopVisible, onClick = { onAction(Action.StartChatClicked) }, modifier = Modifier From 674ecbb274c0bf0f29c9db094d0b02c810d313f8 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Fri, 26 Jun 2026 12:56:04 +0200 Subject: [PATCH 37/39] Fix compilation after rebase --- .../ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java | 0 src/com/android/messaging/util/DebugUtils.java | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java diff --git a/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/com/android/messaging/util/DebugUtils.java b/src/com/android/messaging/util/DebugUtils.java index cccb473bb..c09fb7b60 100644 --- a/src/com/android/messaging/util/DebugUtils.java +++ b/src/com/android/messaging/util/DebugUtils.java @@ -314,7 +314,7 @@ protected void onPostExecute(final String[] result) { private void receiveFromDumpFile(final String dumpFileName) { if (dumpFileName.startsWith(MmsUtils.SMS_DUMP_PREFIX)) { - final SmsMessage[] messages = DebugUtils.retreiveSmsFromDumpFile(dumpFileName); + final SmsMessage[] messages = DebugUtils.retrieveSmsFromDumpFile(dumpFileName); if (messages != null) { SmsReceiver.deliverSmsMessages(mHost, ParticipantData.DEFAULT_SELF_SUB_ID, 0, messages); From 85d3177f3c048daccd0b3bae47083613807fdaf2 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Fri, 26 Jun 2026 16:35:56 +0200 Subject: [PATCH 38/39] Share conversation list fixtures and update UI tests --- .../support/BlockedParticipantsTestData.kt | 2 + .../conversation/ConversationUserFlowTest.kt | 35 +++---- .../store/ConversationReadStoreTest.kt | 2 + .../OverlayReorderAnimationControllerTest.kt | 8 +- ...res.kt => ConversationListTestFixtures.kt} | 37 +++++-- .../ConversationListViewModelTest.kt | 99 ++++++------------- ...ConversationListActionsDelegateImplTest.kt | 1 + .../ConversationListOptimisticReducerTest.kt | 1 + ...nListOptimisticSnapshotDelegateImplTest.kt | 9 +- ...nversationListSelectionDelegateImplTest.kt | 1 + .../ConversationListUiStateMapperImplTest.kt | 69 +------------ 11 files changed, 91 insertions(+), 173 deletions(-) rename app/src/test/kotlin/com/android/messaging/ui/conversationlist/{delegate/OptimisticTestFixtures.kt => ConversationListTestFixtures.kt} (67%) diff --git a/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt b/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt index da879e07e..a695f6cf7 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt +++ b/app/src/androidTest/java/com/android/messaging/ui/blockedparticipants/screen/support/BlockedParticipantsTestData.kt @@ -79,6 +79,7 @@ internal fun participant( displayName: String = DISPLAY_NAME_1, destination: String? = DESTINATION_1, details: String? = destination, + canShowContact: Boolean = true, ): BlockedParticipantUiState { return BlockedParticipantUiState( participantId = participantId, @@ -90,6 +91,7 @@ internal fun participant( lookupKey = null, normalizedDestination = destination, canCall = false, + canShowContact = canShowContact, isContactSaved = false, ) } diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt index 37a65dcd6..f68a27fc7 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt +++ b/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt @@ -4,20 +4,16 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.ui.test.performClick import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.common.test.rules.AppTestRule import com.android.common.test.rules.MessagingTestRule -import com.android.messaging.R import com.android.messaging.ui.conversationlist.ConversationListActivity +import com.android.messaging.ui.conversationlist.ui.CONVERSATION_LIST_TEST_TAG import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -42,19 +38,20 @@ class ConversationUserFlowTest { ) scenario.use { - onView(withId(android.R.id.list)) - .check(matches(isDisplayed())) + composeRule.waitUntilAtLeastOneExists( + matcher = hasTestTag(testTag = CONVERSATION_LIST_TEST_TAG), + timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS, + ) - onView(withId(R.id.start_new_conversation_button)) - .check(matches(isDisplayed())) + composeRule + .onNodeWithTag(testTag = CONVERSATION_LIST_TEST_TAG) + .assertIsDisplayed() - onView(withId(android.R.id.list)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 0, - click(), - ), - ) + composeRule + .onNodeWithTag(testTag = CONVERSATION_LIST_TEST_TAG) + .onChildren() + .onFirst() + .performClick() composeRule.waitUntilAtLeastOneExists( matcher = hasTestTag(testTag = CONVERSATION_TEXT_FIELD_TEST_TAG), diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt index db56a1249..c90155ad9 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationReadStoreTest.kt @@ -75,8 +75,10 @@ internal class ConversationReadStoreTest { match { arguments -> arguments.contentEquals(arrayOf(CONVERSATION_ID)) }, ) } + assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.READ)) assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.SEEN)) + verify { MessagingContentProvider.notifyMessagesChanged(CONVERSATION_ID) } verify { BugleNotifications.cancel( diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt index 6b5790619..234219d62 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/reorder/OverlayReorderAnimationControllerTest.kt @@ -3,7 +3,6 @@ package com.android.messaging.ui.common.components.reorder import androidx.compose.ui.geometry.Rect import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -42,15 +41,11 @@ class OverlayReorderAnimationControllerTest { ) val animationId = controller.animations.single().animationId - assertNull(controller.startAnimation(animationId)) controller.markCommitted() - val started = controller.startAnimation(animationId) - - assertNotNull(started) - assertTrue(started!!.isStarted) + assertEquals(true, controller.startAnimation(animationId)?.isStarted) assertNull(controller.startAnimation(animationId)) } @@ -64,7 +59,6 @@ class OverlayReorderAnimationControllerTest { ) val animationId = controller.animations.single().animationId - controller.finish(animationId) assertTrue(controller.animations.isEmpty()) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListTestFixtures.kt similarity index 67% rename from app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt rename to app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListTestFixtures.kt index 9f2de2953..70c91d284 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/OptimisticTestFixtures.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListTestFixtures.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversationlist.delegate +package com.android.messaging.ui.conversationlist import com.android.messaging.data.conversationlist.model.ConversationListDraft import com.android.messaging.data.conversationlist.model.ConversationListItem @@ -7,6 +7,7 @@ import com.android.messaging.data.conversationlist.model.ConversationListMessage import com.android.messaging.data.conversationlist.model.ConversationListNotification import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.data.conversationsettings.model.SNOOZE_NEVER_EXPIRES import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -14,6 +15,10 @@ internal fun snapshotOfIds(vararg conversationIds: String): ConversationListSnap return snapshotOfItems(*conversationIds.map(::conversationItem).toTypedArray()) } +internal fun snapshotOf(vararg items: ConversationListItem): ConversationListSnapshot { + return snapshotOfItems(*items) +} + internal fun snapshotOfItems(vararg items: ConversationListItem): ConversationListSnapshot { return ConversationListSnapshot( items = persistentListOf(*items), @@ -24,20 +29,28 @@ internal fun snapshotOfItems(vararg items: ConversationListItem): ConversationLi internal fun conversationItem( conversationId: String, + isArchived: Boolean = false, isPinned: Boolean = false, + isSnoozed: Boolean = false, isRead: Boolean = true, timestamp: Long = 1_000L, + senderName: String? = null, + contactId: Long = -1L, + lookupKey: String? = null, + isDraftVisible: Boolean = false, + draftSnippet: String? = null, + draftSubject: String? = null, ): ConversationListItem { return ConversationListItem( conversationId = conversationId, title = "Title $conversationId", icon = null, subject = null, - isArchived = false, + isArchived = isArchived, isPinned = isPinned, participant = ConversationListParticipant( - contactId = -1L, - lookupKey = null, + contactId = contactId, + lookupKey = lookupKey, otherNormalizedDestination = "+1555000$conversationId", isGroup = false, isEnterprise = false, @@ -50,15 +63,21 @@ internal fun conversationItem( previewContentType = null, status = ConversationListMessageStatus.Normal, isIncoming = true, - senderName = null, + senderName = senderName, ), draft = ConversationListDraft( - isVisible = false, - snippetText = null, + isVisible = isDraftVisible, + snippetText = draftSnippet, previewUri = null, previewContentType = null, - subject = null, + subject = draftSubject, + ), + notification = ConversationListNotification( + isEnabled = true, + snoozedUntilMillis = when { + isSnoozed -> SNOOZE_NEVER_EXPIRES + else -> ConversationListNotification.SNOOZE_NOT_SET + }, ), - notification = ConversationListNotification(isEnabled = true), ) } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt index 4524b29e8..2b81d80a2 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/ConversationListViewModelTest.kt @@ -1,12 +1,6 @@ package com.android.messaging.ui.conversationlist import app.cash.turbine.test -import com.android.messaging.data.conversationlist.model.ConversationListDraft -import com.android.messaging.data.conversationlist.model.ConversationListItem -import com.android.messaging.data.conversationlist.model.ConversationListLatestMessage -import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus -import com.android.messaging.data.conversationlist.model.ConversationListNotification -import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.debug.DebugFeaturesProvider @@ -18,11 +12,12 @@ import com.android.messaging.ui.conversationlist.mapper.ConversationListUiStateM import com.android.messaging.ui.conversationlist.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.model.ConversationListEffect as Effect import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow @@ -37,13 +32,11 @@ class ConversationListViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private val repository = mockk(relaxUnitFun = true) + private val repository = mockk() private val uiStateMapper = mockk() - private val selectionDelegate = - mockk(relaxUnitFun = true) - private val actionsDelegate = mockk(relaxUnitFun = true) - private val optimisticSnapshotDelegate = - mockk(relaxUnitFun = true) + private val selectionDelegate = mockk() + private val actionsDelegate = mockk() + private val optimisticSnapshotDelegate = mockk() private val debugFeaturesProvider = mockk() private val snapshotFlow = MutableStateFlow(null) @@ -64,7 +57,6 @@ class ConversationListViewModelTest { snapshotFlow.value = snapshotOf(conversationItem("a")) val viewModel = createViewModel() - viewModel.onAction(Action.ArchiveClicked) verify { optimisticSnapshotDelegate.archive(listOf("a")) } @@ -83,7 +75,6 @@ class ConversationListViewModelTest { snapshotFlow.value = snapshotOf(conversationItem("a", isRead = false)) val viewModel = createViewModel() - viewModel.onAction(Action.ConversationSwipedToToggleRead("a")) verify { optimisticSnapshotDelegate.markRead(listOf("a"), isRead = true) } @@ -97,7 +88,6 @@ class ConversationListViewModelTest { snapshotFlow.value = snapshotOf(conversationItem("a")) val viewModel = createViewModel() - viewModel.effects.test { viewModel.onAction(Action.PinClicked) @@ -116,7 +106,6 @@ class ConversationListViewModelTest { @Test fun pinAnimationPrepared_commitsPinChangeAndClearsSelection() { val viewModel = createViewModel() - viewModel.onAction( Action.PinAnimationPrepared( conversationIds = persistentListOf("a"), @@ -135,7 +124,6 @@ class ConversationListViewModelTest { snapshotFlow.value = snapshotOf(conversationItem("a")) val viewModel = createViewModel() - viewModel.effects.test { viewModel.onAction(Action.ConversationClicked("a")) @@ -149,7 +137,6 @@ class ConversationListViewModelTest { fun startChatClicked_emitsStartChatEffect() { runTest(context = mainDispatcherRule.testDispatcher) { val viewModel = createViewModel() - viewModel.effects.test { viewModel.onAction(Action.StartChatClicked) @@ -162,7 +149,6 @@ class ConversationListViewModelTest { @Test fun archiveSnackbarDismissed_discardsArchivedItems() { val viewModel = createViewModel() - viewModel.onAction( Action.ArchiveSnackbarDismissed( conversationIds = persistentListOf("a", "b"), @@ -175,7 +161,6 @@ class ConversationListViewModelTest { @Test fun archiveUndoClicked_restoresItemsAndPersistsUnarchivedState() { val viewModel = createViewModel() - viewModel.onAction( Action.ArchiveUndoClicked( conversationIds = persistentListOf("a"), @@ -196,7 +181,6 @@ class ConversationListViewModelTest { @Test fun unarchiveUndoClicked_archivesItemsAndPersistsArchivedState() { val viewModel = createViewModel() - viewModel.onAction( Action.ArchiveUndoClicked( conversationIds = persistentListOf("a"), @@ -215,9 +199,33 @@ class ConversationListViewModelTest { } private fun createViewModel(): ConversationListViewModel { + every { repository.refresh() } just runs + every { repository.setNewestConversationVisible(any()) } just runs + every { optimisticSnapshotDelegate.snapshot } returns snapshotFlow + every { optimisticSnapshotDelegate.bind(any()) } just runs + every { optimisticSnapshotDelegate.archive(any()) } just runs + every { optimisticSnapshotDelegate.discardArchived(any()) } just runs + every { optimisticSnapshotDelegate.restoreArchived(any()) } just runs + every { optimisticSnapshotDelegate.markRead(any(), any()) } just runs + every { optimisticSnapshotDelegate.pin(any(), any()) } just runs + every { selectionDelegate.selectedIds } returns selectedIdsFlow + every { selectionDelegate.bind(any(), any()) } just runs + every { selectionDelegate.toggle(any()) } just runs + every { selectionDelegate.clear() } just runs + every { actionsDelegate.effects } returns emptyFlow() + every { actionsDelegate.bind(any()) } just runs + every { actionsDelegate.setArchived(any(), any(), any()) } just runs + every { actionsDelegate.setPinned(any(), any()) } just runs + every { actionsDelegate.setRead(any(), any()) } just runs + every { actionsDelegate.snooze(any(), any()) } just runs + every { actionsDelegate.unsnooze(any()) } just runs + every { actionsDelegate.delete(any()) } just runs + every { actionsDelegate.block(any(), any()) } just runs + every { actionsDelegate.unblock(any(), any()) } just runs + every { debugFeaturesProvider.isEnabled() } returns false return ConversationListViewModel( @@ -229,51 +237,4 @@ class ConversationListViewModelTest { debugFeaturesProvider = debugFeaturesProvider, ) } - - private fun snapshotOf(vararg items: ConversationListItem): ConversationListSnapshot { - return ConversationListSnapshot( - items = persistentListOf(*items), - blockedDestinations = persistentSetOf(), - hasFirstSyncCompleted = true, - ) - } - - private fun conversationItem( - conversationId: String, - isRead: Boolean = true, - ): ConversationListItem { - return ConversationListItem( - conversationId = conversationId, - title = "Title $conversationId", - icon = null, - subject = null, - isArchived = false, - isPinned = false, - participant = ConversationListParticipant( - contactId = -1L, - lookupKey = null, - otherNormalizedDestination = "+1555000$conversationId", - isGroup = false, - isEnterprise = false, - ), - latestMessage = ConversationListLatestMessage( - isRead = isRead, - timestamp = 1_000L, - snippetText = "Snippet $conversationId", - previewUri = null, - previewContentType = null, - status = ConversationListMessageStatus.Normal, - isIncoming = true, - senderName = null, - ), - draft = ConversationListDraft( - isVisible = false, - snippetText = null, - previewUri = null, - previewContentType = null, - subject = null, - ), - notification = ConversationListNotification(isEnabled = true), - ) - } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt index 9583b6593..cde713008 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt @@ -5,6 +5,7 @@ import com.android.messaging.data.blockedparticipants.repository.BlockedParticip import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.conversationsettings.model.SnoozeOption +import com.android.messaging.ui.conversationlist.conversationItem import com.android.messaging.ui.conversationlist.model.ConversationListEffect import io.mockk.coEvery import io.mockk.coVerify diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt index e346d3d04..f0982510d 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticReducerTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem +import com.android.messaging.ui.conversationlist.conversationItem import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import org.junit.Assert.assertEquals diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt index 0b8fb73f6..32fa9d34d 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListOptimisticSnapshotDelegateImplTest.kt @@ -3,6 +3,9 @@ package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository +import com.android.messaging.ui.conversationlist.conversationItem +import com.android.messaging.ui.conversationlist.snapshotOfIds +import com.android.messaging.ui.conversationlist.snapshotOfItems import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -86,8 +89,8 @@ internal class ConversationListOptimisticSnapshotDelegateImplTest { ), ), ) - val delegate = bindDelegate(rawSnapshot) + val delegate = bindDelegate(rawSnapshot) delegate.markRead( conversationIds = listOf("a"), isRead = true, @@ -100,6 +103,7 @@ internal class ConversationListOptimisticSnapshotDelegateImplTest { ), ) runCurrent() + rawSnapshot.value = snapshotOfItems( conversationItem( conversationId = "a", @@ -158,6 +162,7 @@ internal class ConversationListOptimisticSnapshotDelegateImplTest { conversationItem("b", timestamp = 2_000L), ) runCurrent() + rawSnapshot.value = snapshotOfItems( conversationItem("b", timestamp = 2_000L), conversationItem("a", isPinned = false, timestamp = 1_000L), @@ -175,11 +180,11 @@ internal class ConversationListOptimisticSnapshotDelegateImplTest { @Test fun bind_isIdempotent() = runTest { val repository = mockk() - every { repository.observeInboxSnapshot() } returns MutableStateFlow(snapshotOfIds("a")) val delegate = ConversationListOptimisticSnapshotDelegateImpl( repository = repository, reducer = ConversationListOptimisticReducer(), ) + every { repository.observeInboxSnapshot() } returns MutableStateFlow(snapshotOfIds("a")) delegate.bind(backgroundScope) delegate.bind(backgroundScope) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt index 163b2fb9b..9dd302513 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversationlist.delegate import com.android.messaging.data.conversationlist.model.ConversationListSnapshot +import com.android.messaging.ui.conversationlist.snapshotOfIds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt index 04e0326cc..f595baab4 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListUiStateMapperImplTest.kt @@ -1,22 +1,18 @@ package com.android.messaging.ui.conversationlist.mapper import android.content.Context -import com.android.messaging.data.conversationlist.model.ConversationListDraft -import com.android.messaging.data.conversationlist.model.ConversationListItem -import com.android.messaging.data.conversationlist.model.ConversationListLatestMessage import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus -import com.android.messaging.data.conversationlist.model.ConversationListNotification -import com.android.messaging.data.conversationlist.model.ConversationListParticipant import com.android.messaging.data.conversationlist.model.ConversationListSnapshot -import com.android.messaging.data.conversationsettings.model.SNOOZE_NEVER_EXPIRES import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri import com.android.messaging.domain.conversation.usecase.participant.CanAddContact import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall +import com.android.messaging.ui.conversationlist.conversationItem import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import com.android.messaging.ui.conversationlist.model.ConversationListUiState +import com.android.messaging.ui.conversationlist.snapshotOf import io.mockk.every import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf @@ -288,65 +284,4 @@ internal class ConversationListUiStateMapperImplTest { ): ConversationListItemUiModel { return (state.content as ConversationListContentUiState.Items).items.single() } - - private fun snapshotOf(vararg items: ConversationListItem): ConversationListSnapshot { - return ConversationListSnapshot( - items = persistentListOf(*items), - blockedDestinations = persistentSetOf(), - hasFirstSyncCompleted = true, - ) - } - - private fun conversationItem( - conversationId: String, - isPinned: Boolean = false, - isSnoozed: Boolean = false, - isRead: Boolean = true, - senderName: String? = null, - contactId: Long = -1L, - lookupKey: String? = null, - isDraftVisible: Boolean = false, - draftSnippet: String? = null, - draftSubject: String? = null, - ): ConversationListItem { - return ConversationListItem( - conversationId = conversationId, - title = "Title $conversationId", - icon = null, - subject = null, - isArchived = false, - isPinned = isPinned, - participant = ConversationListParticipant( - contactId = contactId, - lookupKey = lookupKey, - otherNormalizedDestination = "+1555000$conversationId", - isGroup = false, - isEnterprise = false, - ), - latestMessage = ConversationListLatestMessage( - isRead = isRead, - timestamp = 1_000L, - snippetText = "Snippet $conversationId", - previewUri = null, - previewContentType = null, - status = ConversationListMessageStatus.Normal, - isIncoming = true, - senderName = senderName, - ), - draft = ConversationListDraft( - isVisible = isDraftVisible, - snippetText = draftSnippet, - previewUri = null, - previewContentType = null, - subject = draftSubject, - ), - notification = ConversationListNotification( - isEnabled = true, - snoozedUntilMillis = when { - isSnoozed -> SNOOZE_NEVER_EXPIRES - else -> ConversationListNotification.SNOOZE_NOT_SET - }, - ), - ) - } } From a06560921a32844e2931f472dd57ae238ceb3e6c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 27 Jun 2026 23:36:53 +0200 Subject: [PATCH 39/39] Use BugleComponentActivity for forward messages --- .../conversationpicker/host/forward/ForwardMessageActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/com/android/messaging/ui/conversationpicker/host/forward/ForwardMessageActivity.kt b/src/com/android/messaging/ui/conversationpicker/host/forward/ForwardMessageActivity.kt index ffa5823a9..cdf8c9c58 100644 --- a/src/com/android/messaging/ui/conversationpicker/host/forward/ForwardMessageActivity.kt +++ b/src/com/android/messaging/ui/conversationpicker/host/forward/ForwardMessageActivity.kt @@ -1,7 +1,6 @@ package com.android.messaging.ui.conversationpicker.host.forward import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.remember @@ -11,6 +10,7 @@ import com.android.messaging.di.core.ApplicationCoroutineScope import com.android.messaging.di.core.MainDispatcher import com.android.messaging.domain.conversationpicker.usecase.BuildConversationDraftFromMessage import com.android.messaging.domain.conversationpicker.usecase.SendContentToTargets +import com.android.messaging.ui.BugleComponentActivity import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversationpicker.ConversationPickerScreen import com.android.messaging.ui.conversationpicker.model.ConversationPickerLabels @@ -21,7 +21,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @AndroidEntryPoint -class ForwardMessageActivity : ComponentActivity() { +class ForwardMessageActivity : BugleComponentActivity() { @Inject @ApplicationCoroutineScope