From 8555740ea70f0115eff1a279417764961713e7e1 Mon Sep 17 00:00:00 2001 From: dan <64416644+brittle-bones@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:41:05 +0100 Subject: [PATCH] Make conversation swipe actions configurable and add Delete action. Combine swipe and selection flow for read/unread. Add jetpack stuff for the settings view. Add repo stuff for conversation swipe options. Split enums for ConversationSwipeOption and ConversationSwipeKind (model and UI). Impl mapper class. Use enum for conversation swipe prefs. Add tests and fix regressions. Formatting. Put block conversation ID and destination into dataclass. (the block ID and destination are always modified together, so easy win to pass detekt). --- .../general/ui/AppSettingsScreenTest.kt | 4 + .../AppSettingsRepositoryImplTest.kt | 78 +++++++++ .../AppSettingsUiStateMapperImplTest.kt | 10 ++ ...ionListNewestConversationVisibilityTest.kt | 9 +- .../chats/ConversationListViewModelTest.kt | 16 +- res/values/constants.xml | 2 + res/values/strings.xml | 8 + .../data/appsettings/model/AppSettings.kt | 1 + .../model/ConversationSwipeOption.kt | 7 + .../model/ConversationSwipePref.kt | 11 ++ .../model/ConversationSwipeSettings.kt | 13 ++ .../repository/AppSettingsRepository.kt | 46 ++++++ .../appsettings/common/SettingsComponents.kt | 150 ++++++++++++++++++ .../general/AppSettingsViewModel.kt | 8 + .../general/delegate/AppSettingsDelegate.kt | 31 ++++ .../mapper/AppSettingsUiStateMapper.kt | 1 + .../general/model/AppSettingsAction.kt | 10 ++ .../general/model/AppSettingsUiState.kt | 2 + .../general/ui/AppSettingsScreen.kt | 46 ++++++ .../ArchivedConversationListScreen.kt | 1 + .../chats/ConversationListContent.kt | 44 +++-- .../chats/ConversationListDialogs.kt | 18 +-- .../chats/ConversationListScreen.kt | 38 +++-- .../chats/ConversationListViewModel.kt | 85 ++++++---- .../mapper/ConversationListSwipeSpecMapper.kt | 21 +++ .../chats/model/ConversationListAction.kt | 9 +- .../chats/model/ConversationListEffect.kt | 4 + .../chats/model/ConversationListUiState.kt | 4 + .../item/SwipeableConversationListItem.kt | 6 + 29 files changed, 599 insertions(+), 84 deletions(-) create mode 100644 src/com/android/messaging/data/appsettings/model/ConversationSwipeOption.kt create mode 100644 src/com/android/messaging/data/appsettings/model/ConversationSwipePref.kt create mode 100644 src/com/android/messaging/data/appsettings/model/ConversationSwipeSettings.kt create mode 100644 src/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListSwipeSpecMapper.kt diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt index d7c1616b8..a638740d5 100644 --- a/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt @@ -112,9 +112,11 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) val debugTitle = composeTestRule.activity.getString(R.string.debug_category_pref_title) + scrollToText(text = debugTitle) composeTestRule.onNodeWithText(debugTitle).assertIsDisplayed() val dumpSmsTitle = composeTestRule.activity.getString(R.string.dump_sms_pref_title) + scrollToText(text = dumpSmsTitle) composeTestRule.onNodeWithText(dumpSmsTitle).assertIsDisplayed() val dumpMmsTitle = composeTestRule.activity.getString(R.string.dump_mms_pref_title) @@ -132,6 +134,7 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) val title = composeTestRule.activity.getString(R.string.dump_sms_pref_title) + scrollToText(text = title) composeTestRule.onNodeWithText(title).performClick() verify(exactly = 1) { @@ -162,6 +165,7 @@ class AppSettingsScreenTest { setContent() val title = composeTestRule.activity.getString(R.string.menu_license) + scrollToText(text = title) composeTestRule.onNodeWithText(title).performClick() verify(exactly = 1) { diff --git a/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt index f5d54c76c..cea942e8a 100644 --- a/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt @@ -5,6 +5,9 @@ import android.content.res.Resources import com.android.messaging.Factory import com.android.messaging.R import com.android.messaging.data.appsettings.model.AppBooleanPref +import com.android.messaging.data.appsettings.model.ConversationSwipeOption +import com.android.messaging.data.appsettings.model.ConversationSwipePref +import com.android.messaging.data.appsettings.model.ConversationSwipeSettings import com.android.messaging.data.appsettings.repository.AppSettingsRepositoryImpl import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.datamodel.data.ParticipantData @@ -70,6 +73,12 @@ internal class AppSettingsRepositoryImplTest { every { context.getString(R.string.youtube_link_previews_pref_key) } returns YOUTUBE_LINK_PREVIEWS_PREF_KEY + every { + context.getString(R.string.conversation_swipe_start_to_end_pref_key) + } returns SWIPE_START_TO_END_PREF_KEY + every { + context.getString(R.string.conversation_swipe_end_to_start_pref_key) + } returns SWIPE_END_TO_START_PREF_KEY every { resources.getBoolean(R.bool.youtube_link_previews_pref_default) } returns YOUTUBE_LINK_PREVIEWS_DEFAULT @@ -108,6 +117,8 @@ internal class AppSettingsRepositoryImplTest { YOUTUBE_LINK_PREVIEWS_DEFAULT, ) } returns true + every { appPrefs.getString(SWIPE_START_TO_END_PREF_KEY, null) } returns "Delete" + every { appPrefs.getString(SWIPE_END_TO_START_PREF_KEY, null) } returns null val result = createRepository( ioDispatcher = UnconfinedTestDispatcher(testScheduler), @@ -118,6 +129,13 @@ internal class AppSettingsRepositoryImplTest { assertFalse(result.sendSoundEnabled) assertTrue(result.inConversationSoundEnabled) assertTrue(result.youTubeLinkPreviewsEnabled) + assertEquals( + ConversationSwipeSettings( + startToEnd = ConversationSwipeOption.Delete, + endToStart = ConversationSwipeOption.Archive, + ), + result.conversationSwipeSettings, + ) assertTrue(result.isDebugEnabled) assertTrue(result.dumpSmsEnabled) assertFalse(result.dumpMmsEnabled) @@ -191,6 +209,64 @@ internal class AppSettingsRepositoryImplTest { } } + @Test + fun setConversationSwipeOptions_writesExpectedStringToEachPreferenceKey() { + runTest { + every { appPrefs.putString(any(), any()) } just runs + val repository = createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ) + + repository.setConversationSwipeOption( + pref = ConversationSwipePref.START_TO_END, + option = ConversationSwipeOption.Archive, + ) + repository.setConversationSwipeOption( + pref = ConversationSwipePref.END_TO_START, + option = ConversationSwipeOption.ToggleRead, + ) + + verify(exactly = 1) { + appPrefs.putString(SWIPE_START_TO_END_PREF_KEY, "Archive") + appPrefs.putString(SWIPE_END_TO_START_PREF_KEY, "ToggleRead") + } + } + } + + @Test + fun getConversationSwipeSettings_readsStoredOptions() { + runTest { + every { appPrefs.getString(SWIPE_START_TO_END_PREF_KEY, null) } returns "Archive" + every { appPrefs.getString(SWIPE_END_TO_START_PREF_KEY, null) } returns "Delete" + + val result = createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ).getConversationSwipeSettings() + + assertEquals( + ConversationSwipeSettings( + startToEnd = ConversationSwipeOption.Archive, + endToStart = ConversationSwipeOption.Delete, + ), + result, + ) + } + } + + @Test + fun getConversationSwipeSettings_fallsBackToDefaultsWhenMissingOrUnknown() { + runTest { + every { appPrefs.getString(SWIPE_START_TO_END_PREF_KEY, null) } returns null + every { appPrefs.getString(SWIPE_END_TO_START_PREF_KEY, null) } returns "UnknownValue" + + val result = createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ).getConversationSwipeSettings() + + assertEquals(ConversationSwipeSettings.Default, result) + } + } + @Test fun isYouTubeLinkPreviewsEnabled_readsPreferenceWithResourceDefault() { runTest { @@ -242,6 +318,8 @@ internal class AppSettingsRepositoryImplTest { private const val IN_CONVERSATION_SOUND_PREF_KEY = "in_conversation_sound" private const val SEND_SOUND_DEFAULT = true private const val SEND_SOUND_PREF_KEY = "send_sound" + private const val SWIPE_END_TO_START_PREF_KEY = "conversation_swipe_end_to_start" + private const val SWIPE_START_TO_END_PREF_KEY = "conversation_swipe_start_to_end" private const val YOUTUBE_LINK_PREVIEWS_DEFAULT = false private const val YOUTUBE_LINK_PREVIEWS_PREF_KEY = "youtube_link_previews" } diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt index 0229f220d..d66778c03 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt @@ -3,6 +3,8 @@ package com.android.messaging.ui.appsettings.general.mapper.appsettingsuistatema import android.content.Context import com.android.messaging.R import com.android.messaging.data.appsettings.model.AppSettings +import com.android.messaging.data.appsettings.model.ConversationSwipeOption +import com.android.messaging.data.appsettings.model.ConversationSwipeSettings import com.android.messaging.ui.appsettings.general.mapper.AppSettingsUiStateMapperImpl import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState import io.mockk.every @@ -30,6 +32,10 @@ internal class AppSettingsUiStateMapperImplTest { sendSoundEnabled = false, inConversationSoundEnabled = true, youTubeLinkPreviewsEnabled = true, + conversationSwipeSettings = ConversationSwipeSettings( + startToEnd = ConversationSwipeOption.Delete, + endToStart = ConversationSwipeOption.ToggleRead, + ), isDebugEnabled = true, dumpSmsEnabled = true, dumpMmsEnabled = false, @@ -43,6 +49,10 @@ internal class AppSettingsUiStateMapperImplTest { sendSoundEnabled = false, inConversationSoundEnabled = true, youTubeLinkPreviewsEnabled = true, + conversationSwipeSettings = ConversationSwipeSettings( + startToEnd = ConversationSwipeOption.Delete, + endToStart = ConversationSwipeOption.ToggleRead, + ), isDebugEnabled = true, dumpSmsEnabled = true, dumpMmsEnabled = false, diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt index 5d026c115..1a316fa2c 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import com.android.common.test.helpers.targetContext import com.android.messaging.FactoryTestAccess +import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.data.conversation.event.ConversationArchiveEventsImpl import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.repository.ConversationListRepository @@ -51,7 +52,8 @@ internal class ConversationListNewestConversationVisibilityTest { @get:Rule val composeTestRule = createComposeRule() - private val repository = mockk(relaxed = true) + private val conversationListRepository = mockk(relaxed = true) + private val appSettingsRepository = mockk(relaxed = true) private val visibilityUpdates = mutableListOf() private lateinit var lifecycleOwner: TestLifecycleOwner @@ -60,7 +62,7 @@ internal class ConversationListNewestConversationVisibilityTest { @Before fun setUp() { installTestFactory(context = targetContext) - every { repository.setNewestConversationVisible(any()) } answers { + every { conversationListRepository.setNewestConversationVisible(any()) } answers { visibilityUpdates += firstArg() } } @@ -180,7 +182,8 @@ internal class ConversationListNewestConversationVisibilityTest { every { selectionDelegate.selectedIds } returns MutableStateFlow(persistentListOf()) return ConversationListViewModel( - repository = repository, + conversationListRepository = conversationListRepository, + appSettingsRepository = appSettingsRepository, uiStateMapper = uiStateMapper, selectionDelegate = selectionDelegate, actionsDelegate = mockk(relaxed = true), diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt index 581b66377..d19a06b5a 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt @@ -1,6 +1,8 @@ package com.android.messaging.ui.conversationlist.chats import app.cash.turbine.test +import com.android.messaging.data.appsettings.model.ConversationSwipeSettings +import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.data.conversation.event.ConversationArchiveEventsImpl import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.model.ConversationListSnapshot @@ -43,7 +45,8 @@ class ConversationListViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private val repository = mockk() + private val conversationListRepository = mockk() + private val appSettingsRepository = mockk() private val uiStateMapper = mockk() private val selectionDelegate = mockk() private val actionsDelegate = mockk() @@ -271,8 +274,8 @@ class ConversationListViewModelTest { } private fun createViewModel(): ConversationListViewModel { - every { repository.refresh() } just runs - every { repository.setNewestConversationVisible(any()) } just runs + every { conversationListRepository.refresh() } just runs + every { conversationListRepository.setNewestConversationVisible(any()) } just runs every { optimisticSnapshotDelegate.snapshot } returns snapshotFlow every { optimisticSnapshotDelegate.bind(any(), any()) } just runs @@ -299,15 +302,18 @@ class ConversationListViewModelTest { every { debugFeaturesProvider.isEnabled() } returns false every { resolveContactAction(any(), any(), any()) } returns ResolveContactActionResult.Unavailable + coEvery { appSettingsRepository.getConversationSwipeSettings() } returns + ConversationSwipeSettings.Default return ConversationListViewModel( - repository = repository, + conversationListRepository = conversationListRepository, + appSettingsRepository = appSettingsRepository, uiStateMapper = uiStateMapper, selectionDelegate = selectionDelegate, actionsDelegate = actionsDelegate, optimisticSnapshotDelegate = optimisticSnapshotDelegate, - debugFeaturesProvider = debugFeaturesProvider, resolveContactAction = resolveContactAction, + debugFeaturesProvider = debugFeaturesProvider, conversationArchiveEvents = conversationArchiveEvents, defaultDispatcher = mainDispatcherRule.testDispatcher, mainDispatcher = mainDispatcherRule.testDispatcher, diff --git a/res/values/constants.xml b/res/values/constants.xml index 01e328ede..a6d21530a 100644 --- a/res/values/constants.xml +++ b/res/values/constants.xml @@ -23,6 +23,8 @@ false youtube_link_previews false + conversation_swipe_start_to_end + conversation_swipe_end_to_start diff --git a/res/values/strings.xml b/res/values/strings.xml index 25f011cc1..bb74fec9a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -364,6 +364,14 @@ MMS Advanced + + Conversation Swipe Actions + + Swipe right + + Swipe left + + Mark as read/unread Debug diff --git a/src/com/android/messaging/data/appsettings/model/AppSettings.kt b/src/com/android/messaging/data/appsettings/model/AppSettings.kt index 98227fc3b..738369050 100644 --- a/src/com/android/messaging/data/appsettings/model/AppSettings.kt +++ b/src/com/android/messaging/data/appsettings/model/AppSettings.kt @@ -6,6 +6,7 @@ internal data class AppSettings( val sendSoundEnabled: Boolean, val inConversationSoundEnabled: Boolean, val youTubeLinkPreviewsEnabled: Boolean, + val conversationSwipeSettings: ConversationSwipeSettings, val isDebugEnabled: Boolean, val dumpSmsEnabled: Boolean, val dumpMmsEnabled: Boolean, diff --git a/src/com/android/messaging/data/appsettings/model/ConversationSwipeOption.kt b/src/com/android/messaging/data/appsettings/model/ConversationSwipeOption.kt new file mode 100644 index 000000000..abe3c5267 --- /dev/null +++ b/src/com/android/messaging/data/appsettings/model/ConversationSwipeOption.kt @@ -0,0 +1,7 @@ +package com.android.messaging.data.appsettings.model + +internal enum class ConversationSwipeOption { + ToggleRead, + Archive, + Delete, +} diff --git a/src/com/android/messaging/data/appsettings/model/ConversationSwipePref.kt b/src/com/android/messaging/data/appsettings/model/ConversationSwipePref.kt new file mode 100644 index 000000000..35b3e7fc3 --- /dev/null +++ b/src/com/android/messaging/data/appsettings/model/ConversationSwipePref.kt @@ -0,0 +1,11 @@ +package com.android.messaging.data.appsettings.model + +import androidx.annotation.StringRes +import com.android.messaging.R + +internal enum class ConversationSwipePref( + @param:StringRes val keyResId: Int, +) { + START_TO_END(R.string.conversation_swipe_start_to_end_pref_key), + END_TO_START(R.string.conversation_swipe_end_to_start_pref_key), +} diff --git a/src/com/android/messaging/data/appsettings/model/ConversationSwipeSettings.kt b/src/com/android/messaging/data/appsettings/model/ConversationSwipeSettings.kt new file mode 100644 index 000000000..ad43abab3 --- /dev/null +++ b/src/com/android/messaging/data/appsettings/model/ConversationSwipeSettings.kt @@ -0,0 +1,13 @@ +package com.android.messaging.data.appsettings.model + +internal data class ConversationSwipeSettings( + val startToEnd: ConversationSwipeOption, + val endToStart: ConversationSwipeOption, +) { + companion object { + val Default = ConversationSwipeSettings( + startToEnd = ConversationSwipeOption.ToggleRead, + endToStart = ConversationSwipeOption.Archive, + ) + } +} diff --git a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt index 1042ededd..b389dc28c 100644 --- a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt +++ b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt @@ -4,6 +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.appsettings.model.ConversationSwipeOption +import com.android.messaging.data.appsettings.model.ConversationSwipePref +import com.android.messaging.data.appsettings.model.ConversationSwipeSettings import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.BuglePrefs @@ -16,7 +19,12 @@ import kotlinx.coroutines.withContext internal interface AppSettingsRepository { suspend fun getAppSettings(): AppSettings suspend fun isYouTubeLinkPreviewsEnabled(): Boolean + suspend fun getConversationSwipeSettings(): ConversationSwipeSettings suspend fun setBooleanPref(pref: AppBooleanPref, enabled: Boolean) + suspend fun setConversationSwipeOption( + pref: ConversationSwipePref, + option: ConversationSwipeOption, + ) } internal class AppSettingsRepositoryImpl @Inject constructor( @@ -43,6 +51,7 @@ internal class AppSettingsRepositoryImpl @Inject constructor( resources.getBoolean(R.bool.in_conversation_sound_pref_default), ), youTubeLinkPreviewsEnabled = readYouTubeLinkPreviewsEnabled(), + conversationSwipeSettings = readConversationSwipeSettings(), isDebugEnabled = debugFeaturesProvider.isEnabled(), dumpSmsEnabled = appPrefs.getBoolean( context.getString(R.string.dump_sms_pref_key), @@ -62,6 +71,12 @@ internal class AppSettingsRepositoryImpl @Inject constructor( } } + override suspend fun getConversationSwipeSettings(): ConversationSwipeSettings { + return withContext(ioDispatcher) { + readConversationSwipeSettings() + } + } + override suspend fun setBooleanPref( pref: AppBooleanPref, enabled: Boolean, @@ -74,10 +89,41 @@ internal class AppSettingsRepositoryImpl @Inject constructor( } } + override suspend fun setConversationSwipeOption( + pref: ConversationSwipePref, + option: ConversationSwipeOption, + ) { + withContext(ioDispatcher) { + BuglePrefs.getApplicationPrefs().putString( + context.getString(pref.keyResId), + option.name, + ) + } + } + private fun readYouTubeLinkPreviewsEnabled(): Boolean { return BuglePrefs.getApplicationPrefs().getBoolean( context.getString(R.string.youtube_link_previews_pref_key), context.resources.getBoolean(R.bool.youtube_link_previews_pref_default), ) } + + private fun readConversationSwipeSettings(): ConversationSwipeSettings { + val default = ConversationSwipeSettings.Default + + return ConversationSwipeSettings( + startToEnd = readConversationSwipeOption(ConversationSwipePref.START_TO_END) + ?: default.startToEnd, + endToStart = readConversationSwipeOption(ConversationSwipePref.END_TO_START) + ?: default.endToStart, + ) + } + + private fun readConversationSwipeOption(pref: ConversationSwipePref): ConversationSwipeOption? { + val stored = BuglePrefs.getApplicationPrefs().getString( + context.getString(pref.keyResId), + null, + ) + return ConversationSwipeOption.entries.firstOrNull { it.name == stored } + } } diff --git a/src/com/android/messaging/ui/appsettings/common/SettingsComponents.kt b/src/com/android/messaging/ui/appsettings/common/SettingsComponents.kt index 93c62827b..6cda7c81c 100644 --- a/src/com/android/messaging/ui/appsettings/common/SettingsComponents.kt +++ b/src/com/android/messaging/ui/appsettings/common/SettingsComponents.kt @@ -8,6 +8,8 @@ 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.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Block @@ -15,15 +17,24 @@ import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mms import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Swipe +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp @@ -118,6 +129,105 @@ internal fun SettingsSwitchItem( ) } +@Composable +internal fun SettingsListItem( + title: String, + options: List, + selectedOption: T, + optionLabel: @Composable (T) -> String, + onOptionSelected: (T) -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + enabled: Boolean = true, +) { + var isDialogVisible by rememberSaveable { mutableStateOf(false) } + + SettingsClickableItem( + title = title, + summary = optionLabel(selectedOption), + onClick = { isDialogVisible = true }, + modifier = modifier, + icon = icon, + enabled = enabled, + ) + + if (isDialogVisible) { + SettingsSingleChoiceDialog( + title = title, + options = options, + selectedOption = selectedOption, + optionLabel = optionLabel, + onOptionSelected = { option -> + onOptionSelected(option) + isDialogVisible = false + }, + onDismiss = { isDialogVisible = false }, + ) + } +} + +@Composable +private fun SettingsSingleChoiceDialog( + title: String, + options: List, + selectedOption: T, + optionLabel: @Composable (T) -> String, + onOptionSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = title) + }, + text = { + Column(modifier = Modifier.selectableGroup()) { + options.forEach { option -> + SettingsChoiceOption( + text = optionLabel(option), + selected = option == selectedOption, + onClick = { onOptionSelected(option) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun SettingsChoiceOption( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + ) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + @Composable private fun SettingsItemLayout( title: String, @@ -244,3 +354,43 @@ private fun SettingsSwitchItemPreview() { } } } + +@PreviewLightDark +@Composable +private fun SettingsListItemPreview() { + MessagingPreviewColumn { + Column { + SettingsListItem( + title = "Swipe left", + options = listOf("Archive", "Delete", "Mark as read"), + selectedOption = "Archive", + optionLabel = { it }, + onOptionSelected = {}, + icon = Icons.Default.Swipe, + ) + SettingsListItem( + title = "Disabled option", + options = listOf("On", "Off"), + selectedOption = "Off", + optionLabel = { it }, + onOptionSelected = {}, + enabled = false, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun SettingsSingleChoiceDialogPreview() { + MessagingPreviewColumn { + SettingsSingleChoiceDialog( + title = "Swipe left", + options = listOf("Archive", "Delete", "Mark as read"), + selectedOption = "Delete", + optionLabel = { it }, + onOptionSelected = {}, + onDismiss = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt b/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt index 534a2a235..92cc35f1b 100644 --- a/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt +++ b/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt @@ -49,6 +49,14 @@ internal class AppSettingsViewModel @Inject constructor( appSettingsDelegate.onYouTubeLinkPreviewsChanged(action.enabled) } + is Action.ConversationSwipeStartToEndOptionChanged -> { + appSettingsDelegate.onConversationSwipeStartToEndOptionChanged(action.option) + } + + is Action.ConversationSwipeEndToStartOptionChanged -> { + appSettingsDelegate.onConversationSwipeEndToStartOptionChanged(action.option) + } + is Action.NotificationsClicked -> _effects.trySend(Effect.OpenNotificationSettings) is Action.DefaultSmsAppClicked -> { diff --git a/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt b/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt index 06f0b878e..f38d73442 100644 --- a/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt +++ b/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt @@ -1,6 +1,8 @@ package com.android.messaging.ui.appsettings.general.delegate import com.android.messaging.data.appsettings.model.AppBooleanPref +import com.android.messaging.data.appsettings.model.ConversationSwipeOption +import com.android.messaging.data.appsettings.model.ConversationSwipePref import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.ui.appsettings.common.SettingsScreenDelegate import com.android.messaging.ui.appsettings.general.mapper.AppSettingsUiStateMapper @@ -20,6 +22,8 @@ internal interface AppSettingsDelegate : SettingsScreenDelegate Unit) { } } +private fun LazyListScope.swipeSettingsItems( + appSettings: AppSettingsUiState, + onAction: (Action) -> Unit, +) { + item(key = "swipe_divider") { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + item(key = "swipe_category_header") { + SettingsCategoryHeader( + title = stringResource(R.string.swipe_category_pref_title), + ) + } + item(key = "swipe_start_to_end_action") { + SettingsListItem( + title = stringResource(R.string.swipe_start_to_end_pref_title), + options = ConversationSwipeOption.entries, + selectedOption = appSettings.conversationSwipeSettings.startToEnd, + optionLabel = { stringResource(it.labelRes) }, + onOptionSelected = { onAction(Action.ConversationSwipeStartToEndOptionChanged(it)) }, + ) + } + item(key = "swipe_end_to_start_action") { + SettingsListItem( + title = stringResource(R.string.swipe_end_to_start_pref_title), + options = ConversationSwipeOption.entries, + selectedOption = appSettings.conversationSwipeSettings.endToStart, + optionLabel = { stringResource(it.labelRes) }, + onOptionSelected = { onAction(Action.ConversationSwipeEndToStartOptionChanged(it)) }, + ) + } +} + +private val ConversationSwipeOption.labelRes: Int + get() = when (this) { + ConversationSwipeOption.ToggleRead -> R.string.swipe_action_toggle_read + ConversationSwipeOption.Archive -> R.string.action_archive + ConversationSwipeOption.Delete -> R.string.action_delete + } + private fun LazyListScope.debugSettingsItems( appSettings: AppSettingsUiState, onAction: (Action) -> Unit, diff --git a/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListScreen.kt index 924ead5a2..7a47293b9 100644 --- a/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListScreen.kt @@ -338,6 +338,7 @@ private fun ConversationListItemEvent.toArchivedAction(): Action { ConversationSwipeKind.Archive -> unsupportedSwipeKind(kind) ConversationSwipeKind.ToggleRead -> unsupportedSwipeKind(kind) + ConversationSwipeKind.Delete -> unsupportedSwipeKind(kind) } } } diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt index a428c4fd3..1045838b9 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt @@ -18,6 +18,17 @@ import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.ui.common.components.PrimaryActionButton import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimationController import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.AvatarCallClicked +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.AvatarContactClicked +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.AvatarInfoClicked +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.AvatarMessageClicked +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.ConversationClicked +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.ConversationLongClicked +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.ConversationSwipedToArchive +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.ConversationSwipedToDelete +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.ConversationSwipedToToggleRead +import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction.StartChatClicked +import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState import com.android.messaging.ui.conversationlist.common.item.ConversationSwipeKind import com.android.messaging.ui.conversationlist.common.list.ConversationListItemEvent import com.android.messaging.ui.conversationlist.common.list.ConversationListItems @@ -30,11 +41,6 @@ import com.android.messaging.ui.conversationlist.model.ConversationListContentUi import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel as Model import com.android.messaging.ui.core.MessagingPreviewTheme -private val ChatSwipeSpec = ConversationListSwipeSpec( - startToEnd = ConversationSwipeKind.ToggleRead, - endToStart = ConversationSwipeKind.Archive, -) - @Composable internal fun ConversationListContent( content: ConversationListContentUiState, @@ -44,6 +50,7 @@ internal fun ConversationListContent( isSelectionMode: Boolean, fabBottomReserve: Dp, pinAnimationController: OverlayReorderAnimationController?, + swipeSpec: ConversationListSwipeSpec, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -64,7 +71,7 @@ internal fun ConversationListContent( actionButton = { PrimaryActionButton( text = stringResource(R.string.conversation_list_start_chat), - onClick = { onAction(Action.StartChatClicked) }, + onClick = { onAction(StartChatClicked) }, leadingIcon = Icons.AutoMirrored.Rounded.Chat, ) }, @@ -80,7 +87,7 @@ internal fun ConversationListContent( scaffoldContentPadding = scaffoldContentPadding, fabBottomReserve = fabBottomReserve, pinAnimationController = pinAnimationController, - swipeSpec = ChatSwipeSpec, + swipeSpec = swipeSpec, onItemEvent = { onAction(it.toChatAction()) }, ) } @@ -91,36 +98,40 @@ internal fun ConversationListContent( private fun ConversationListItemEvent.toChatAction(): Action { return when (this) { is ConversationListItemEvent.Clicked -> { - Action.ConversationClicked(conversationId) + ConversationClicked(conversationId) } is ConversationListItemEvent.LongClicked -> { - Action.ConversationLongClicked(conversationId) + ConversationLongClicked(conversationId) } is ConversationListItemEvent.AvatarMessageClicked -> { - Action.AvatarMessageClicked(conversationId) + AvatarMessageClicked(conversationId) } is ConversationListItemEvent.AvatarCallClicked -> { - Action.AvatarCallClicked(destination) + AvatarCallClicked(destination) } is ConversationListItemEvent.AvatarContactClicked -> { - Action.AvatarContactClicked(item.avatar) + AvatarContactClicked(item.avatar) } is ConversationListItemEvent.AvatarInfoClicked -> { - Action.AvatarInfoClicked(conversationId) + AvatarInfoClicked(conversationId) } is ConversationListItemEvent.Swiped -> when (kind) { ConversationSwipeKind.ToggleRead -> { - Action.ConversationSwipedToToggleRead(conversationId) + ConversationSwipedToToggleRead(conversationId) } ConversationSwipeKind.Archive -> { - Action.ConversationSwipedToArchive(conversationId) + ConversationSwipedToArchive(conversationId) + } + + ConversationSwipeKind.Delete -> { + ConversationSwipedToDelete(conversationId) } ConversationSwipeKind.Unarchive -> unsupportedSwipeKind(kind) @@ -140,6 +151,7 @@ private fun ConversationListContentEmptyPreview() { isSelectionMode = false, fabBottomReserve = 0.dp, pinAnimationController = null, + swipeSpec = ConversationListUiState().swipeSpec, ) } } @@ -156,6 +168,7 @@ private fun ConversationListContentWaitingForSyncPreview() { isSelectionMode = false, fabBottomReserve = 0.dp, pinAnimationController = null, + swipeSpec = ConversationListUiState().swipeSpec, ) } } @@ -174,6 +187,7 @@ private fun ConversationListContentItemsPreview() { isSelectionMode = false, fabBottomReserve = 0.dp, pinAnimationController = null, + swipeSpec = ConversationListUiState().swipeSpec, ) } } diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListDialogs.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListDialogs.kt index 9db4acb8d..2f072ca28 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListDialogs.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListDialogs.kt @@ -13,11 +13,12 @@ import com.android.messaging.ui.common.components.SnoozeChatDialog import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.common.dialog.ConversationListDeleteDialog import com.android.messaging.ui.core.MessagingPreviewTheme +import kotlinx.collections.immutable.ImmutableList @Composable internal fun ConversationListDialogs( selectedCount: Int, - isDeleteVisible: Boolean, + deleteConversationIds: ImmutableList?, blockConversationId: ConversationId?, blockDestination: String?, isSnoozeVisible: Boolean, @@ -30,17 +31,15 @@ internal fun ConversationListDialogs( DismissSelectionDialogsWithoutSelection( selectedCount = selectedCount, - isDeleteVisible = isDeleteVisible, isSnoozeVisible = isSnoozeVisible, - onDismissDelete = onDismissDelete, onDismissSnooze = onDismissSnooze, ) - if (isDeleteVisible && hasSelectedConversations) { + if (deleteConversationIds != null) { ConversationListDeleteDialog( - selectedCount = selectedCount, + selectedCount = deleteConversationIds.size, onConfirm = { - onAction(Action.DeleteConfirmed) + onAction(Action.DeleteConfirmed(deleteConversationIds)) onDismissDelete() }, onDismiss = onDismissDelete, @@ -78,24 +77,17 @@ internal fun ConversationListDialogs( @Composable private fun DismissSelectionDialogsWithoutSelection( selectedCount: Int, - isDeleteVisible: Boolean, isSnoozeVisible: Boolean, - onDismissDelete: () -> Unit, onDismissSnooze: () -> Unit, ) { LaunchedEffect( selectedCount, - isDeleteVisible, isSnoozeVisible, ) { if (selectedCount > 0) { return@LaunchedEffect } - if (isDeleteVisible) { - onDismissDelete() - } - if (isSnoozeVisible) { onDismissSnooze() } diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt index d5704cab5..28a0cc324 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt @@ -73,6 +73,7 @@ import com.android.messaging.ui.conversationlist.common.support.previewConversat import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel as Model import com.android.messaging.ui.core.MessagingPreviewTheme +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -81,6 +82,11 @@ import kotlinx.coroutines.launch private val FabSpacing = 16.dp private val FabBottomReserve = 72.dp +private data class PendingBlock( + val conversationId: ConversationId, + val destination: String, +) + @Composable internal fun ConversationListScreen( screenModel: ConversationListScreenModel, @@ -97,10 +103,8 @@ internal fun ConversationListScreen( key = Model::conversationId, isSettled = { item, anchorToTop -> item.isPinned == anchorToTop }, ) - - var pendingDelete by remember { mutableStateOf(false) } - var pendingBlockConversationId by remember { mutableStateOf(null) } - var pendingBlockDestination by remember { mutableStateOf(null) } + var pendingDeleteIds by remember { mutableStateOf?>(null) } + var pendingBlock by remember { mutableStateOf(null) } var pendingSnooze by remember { mutableStateOf(false) } LifecycleResumeEffect(screenModel) { @@ -125,9 +129,9 @@ internal fun ConversationListScreen( pinAnimationController = pinAnimationController, onAction = screenModel::onAction, onConfirmBlock = { conversationId, destination -> - pendingBlockConversationId = conversationId - pendingBlockDestination = destination + pendingBlock = PendingBlock(conversationId, destination) }, + onConfirmDelete = { pendingDeleteIds = it }, ) ConversationListScaffoldWithPinOverlay( @@ -136,23 +140,20 @@ internal fun ConversationListScreen( snackbarHostState = snackbarHostState, pinAnimationController = pinAnimationController, onAction = screenModel::onAction, - onDeleteClick = { pendingDelete = true }, + onDeleteClick = { screenModel.onAction(Action.DeleteClicked) }, onSnoozeClick = { pendingSnooze = true }, modifier = modifier.fillMaxSize(), ) ConversationListDialogs( selectedCount = uiState.selection.selectedCount, - isDeleteVisible = pendingDelete, - blockConversationId = pendingBlockConversationId, - blockDestination = pendingBlockDestination, + deleteConversationIds = pendingDeleteIds, + blockConversationId = pendingBlock?.conversationId, + blockDestination = pendingBlock?.destination, isSnoozeVisible = pendingSnooze, onAction = screenModel::onAction, - onDismissDelete = { pendingDelete = false }, - onDismissBlock = { - pendingBlockConversationId = null - pendingBlockDestination = null - }, + onDismissDelete = { pendingDeleteIds = null }, + onDismissBlock = { pendingBlock = null }, onDismissSnooze = { pendingSnooze = false }, ) } @@ -214,6 +215,7 @@ private fun ConversationListEffects( pinAnimationController: OverlayReorderAnimationController, onAction: (Action) -> Unit, onConfirmBlock: (conversationId: ConversationId, destination: String) -> Unit, + onConfirmDelete: (conversationIds: ImmutableList) -> Unit, ) { val context = LocalContext.current val undoLabel = stringResource(R.string.snack_bar_undo) @@ -224,6 +226,7 @@ private fun ConversationListEffects( val currentUndoLabel by rememberUpdatedState(undoLabel) val currentOnAction by rememberUpdatedState(onAction) val currentOnConfirmBlock by rememberUpdatedState(onConfirmBlock) + val currentOnConfirmDelete by rememberUpdatedState(onConfirmDelete) LaunchedEffect(effects) { effects.collect { effect -> @@ -235,6 +238,10 @@ private fun ConversationListEffects( ) } + is Effect.ConfirmDelete -> { + currentOnConfirmDelete(effect.conversationIds) + } + is Effect.ArchiveStatusChanged, is Effect.ConversationBlocked -> { snackbarScope.launchSnackbarForEffect( snackbarHostState = snackbarHostState, @@ -442,6 +449,7 @@ private fun ConversationListScaffold( isSelectionMode = isSelectionMode, fabBottomReserve = FabBottomReserve, pinAnimationController = pinAnimationController, + swipeSpec = uiState.swipeSpec, ) ConversationListFabs( diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt index 05c067be4..0e882a720 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversationlist.chats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.data.conversation.event.ConversationArchiveEvents import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.model.ConversationListItem @@ -16,6 +17,7 @@ import com.android.messaging.domain.conversation.usecase.participant.ResolveCont import com.android.messaging.domain.conversation.usecase.participant.model.ResolveContactActionResult import com.android.messaging.ui.contact.model.AddContactRequest import com.android.messaging.ui.conversationlist.chats.mapper.ConversationListUiStateMapper +import com.android.messaging.ui.conversationlist.chats.mapper.toSwipeSpec import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.chats.model.ConversationListEffect as Effect import com.android.messaging.ui.conversationlist.chats.model.ConversationListNavEvent as NavEvent @@ -52,7 +54,8 @@ internal interface ConversationListScreenModel { @HiltViewModel internal class ConversationListViewModel @Inject constructor( - private val repository: ConversationListRepository, + private val conversationListRepository: ConversationListRepository, + private val appSettingsRepository: AppSettingsRepository, uiStateMapper: ConversationListUiStateMapper, private val selectionDelegate: ConversationListSelectionDelegate, private val actionsDelegate: ConversationListActionsDelegate, @@ -72,6 +75,7 @@ internal class ConversationListViewModel @Inject constructor( private val openedConversationId = MutableStateFlow(value = null) private var isScreenResumed = false private var isListScrolledToTop = false + private val swipeSpec = MutableStateFlow(State().swipeSpec) private val snapshot: StateFlow = optimisticSnapshotDelegate.snapshot @@ -96,6 +100,9 @@ internal class ConversationListViewModel @Inject constructor( isDebugEnabled = isDebugEnabled, ) } + .combine(swipeSpec) { state, swipeSpec -> + state.copy(swipeSpec = swipeSpec) + } .flowOn(defaultDispatcher) .stateIn( scope = viewModelScope, @@ -139,7 +146,7 @@ internal class ConversationListViewModel @Inject constructor( } is Action.DeleteConfirmed -> { - onDeleteConfirmed() + delete(action.conversationIds) } } } @@ -202,17 +209,6 @@ internal class ConversationListViewModel @Inject constructor( selectionDelegate.clear() } - private fun onDeleteConfirmed() { - val selectedItems = currentSelectedItems() - - if (selectedItems.isEmpty()) { - return - } - - actionsDelegate.delete(selectedItems) - selectionDelegate.clear() - } - private fun onArchiveUndoClicked( conversationIds: List, isArchived: Boolean, @@ -235,7 +231,8 @@ internal class ConversationListViewModel @Inject constructor( Action.ScreenResumed -> { isScreenResumed = true isDebugEnabled.value = debugFeaturesProvider.isEnabled() - repository.refresh() + refreshSwipeSpec() + conversationListRepository.refresh() updateNewestConversationVisibility() } @@ -246,6 +243,12 @@ internal class ConversationListViewModel @Inject constructor( } } + private fun refreshSwipeSpec() { + viewModelScope.launch { + swipeSpec.value = appSettingsRepository.getConversationSwipeSettings().toSwipeSpec() + } + } + private fun onListAction(action: Action.ListAction) { when (action) { is Action.AvatarMessageClicked -> { @@ -283,6 +286,10 @@ internal class ConversationListViewModel @Inject constructor( is Action.ConversationSwipedToToggleRead -> { onConversationSwipedToToggleRead(action.conversationId) } + + is Action.ConversationSwipedToDelete -> { + requestDelete(listOf(action.conversationId)) + } } } @@ -309,7 +316,9 @@ internal class ConversationListViewModel @Inject constructor( } private fun updateNewestConversationVisibility() { - repository.setNewestConversationVisible(isScreenResumed && isListScrolledToTop) + conversationListRepository.setNewestConversationVisible( + isScreenResumed && isListScrolledToTop, + ) } private fun onAvatarContactClick(avatar: ConversationListAvatarUiModel) { @@ -389,23 +398,42 @@ internal class ConversationListViewModel @Inject constructor( private fun onConversationSwipedToToggleRead(conversationId: ConversationId) { val item = itemById(conversationId) ?: return + setRead(listOf(conversationId), isRead = !item.latestMessage.isRead) + } - val shouldMarkRead = !item.latestMessage.isRead - val conversationIds = listOf(conversationId) - + private fun setRead(conversationIds: List, isRead: Boolean) { optimisticSnapshotDelegate.markRead( conversationIds = conversationIds, - isRead = shouldMarkRead, + isRead = isRead, ) viewModelScope.launch { actionsDelegate.setRead( conversationIds = conversationIds, - isRead = shouldMarkRead, + isRead = isRead, ) } } + private fun requestDelete(conversationIds: List) { + _effects.trySend(Effect.ConfirmDelete(conversationIds.toImmutableList())) + } + + private fun delete(conversationIds: List) { + val items = conversationIds.mapNotNull(::itemById) + + if (items.isEmpty()) { + return + } + + actionsDelegate.delete(items) + conversationIds.forEach { conversationId -> + _navigationEvents.trySend(NavEvent.CloseConversation(conversationId)) + } + + selectionDelegate.clear() + } + private fun onNavigationAction(action: Action.NavigationAction) { when (action) { Action.ArchivedConversationsClicked -> { @@ -448,6 +476,10 @@ internal class ConversationListViewModel @Inject constructor( onBlockClick() } + is Action.DeleteClicked -> { + withSelectedIds { requestDelete(it) } + } + is Action.MarkReadClicked -> { onMarkRead(isRead = true) } @@ -521,18 +553,7 @@ internal class ConversationListViewModel @Inject constructor( private fun onMarkRead(isRead: Boolean) { withSelectedIds { conversationIds -> - optimisticSnapshotDelegate.markRead( - conversationIds = conversationIds, - isRead = isRead, - ) - - viewModelScope.launch { - actionsDelegate.setRead( - conversationIds = conversationIds, - isRead = isRead, - ) - } - + setRead(conversationIds, isRead) selectionDelegate.clear() } } diff --git a/src/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListSwipeSpecMapper.kt b/src/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListSwipeSpecMapper.kt new file mode 100644 index 000000000..ce6781481 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListSwipeSpecMapper.kt @@ -0,0 +1,21 @@ +package com.android.messaging.ui.conversationlist.chats.mapper + +import com.android.messaging.data.appsettings.model.ConversationSwipeOption +import com.android.messaging.data.appsettings.model.ConversationSwipeSettings +import com.android.messaging.ui.conversationlist.common.item.ConversationSwipeKind +import com.android.messaging.ui.conversationlist.common.list.ConversationListSwipeSpec + +internal fun ConversationSwipeSettings.toSwipeSpec(): ConversationListSwipeSpec { + return ConversationListSwipeSpec( + startToEnd = startToEnd.toSwipeKind(), + endToStart = endToStart.toSwipeKind(), + ) +} + +private fun ConversationSwipeOption.toSwipeKind(): ConversationSwipeKind { + return when (this) { + ConversationSwipeOption.ToggleRead -> ConversationSwipeKind.ToggleRead + ConversationSwipeOption.Archive -> ConversationSwipeKind.Archive + ConversationSwipeOption.Delete -> ConversationSwipeKind.Delete + } +} diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt index 8cc0693ad..412c5dc34 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt @@ -27,7 +27,9 @@ internal sealed interface ConversationListAction { val destination: String, ) : ConfirmationAction - data object DeleteConfirmed : ConfirmationAction + data class DeleteConfirmed( + val conversationIds: ImmutableList, + ) : ConfirmationAction // endregion // region SnackbarAction @@ -87,6 +89,10 @@ internal sealed interface ConversationListAction { data class ConversationSwipedToToggleRead( val conversationId: ConversationId, ) : ListAction + + data class ConversationSwipedToDelete( + val conversationId: ConversationId, + ) : ListAction // endregion // region NavigationAction @@ -102,6 +108,7 @@ internal sealed interface ConversationListAction { data object AddContactClicked : SelectionAction data object ArchiveClicked : SelectionAction data object BlockClicked : SelectionAction + data object DeleteClicked : SelectionAction data object MarkReadClicked : SelectionAction data object MarkUnreadClicked : SelectionAction data object PinClicked : SelectionAction diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListEffect.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListEffect.kt index 755387c4d..0faae019f 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListEffect.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListEffect.kt @@ -37,6 +37,10 @@ internal sealed interface ConversationListEffect { val destination: String, ) : ConversationListEffect + data class ConfirmDelete( + val conversationIds: ImmutableList, + ) : ConversationListEffect + data class ConversationBlocked( val conversationId: ConversationId, val destination: String, diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt index ed3e57090..d7a2b3003 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt @@ -1,6 +1,9 @@ package com.android.messaging.ui.conversationlist.chats.model import androidx.compose.runtime.Immutable +import com.android.messaging.data.appsettings.model.ConversationSwipeSettings +import com.android.messaging.ui.conversationlist.chats.mapper.toSwipeSpec +import com.android.messaging.ui.conversationlist.common.list.ConversationListSwipeSpec import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState @Immutable @@ -10,4 +13,5 @@ internal data class ConversationListUiState( val isScrollToTopVisible: Boolean = false, val hasBlockedParticipants: Boolean = false, val isDebugEnabled: Boolean = false, + val swipeSpec: ConversationListSwipeSpec = ConversationSwipeSettings.Default.toSwipeSpec(), ) diff --git a/src/com/android/messaging/ui/conversationlist/common/item/SwipeableConversationListItem.kt b/src/com/android/messaging/ui/conversationlist/common/item/SwipeableConversationListItem.kt index e5cdbd8d0..e86cfc5dc 100644 --- a/src/com/android/messaging/ui/conversationlist/common/item/SwipeableConversationListItem.kt +++ b/src/com/android/messaging/ui/conversationlist/common/item/SwipeableConversationListItem.kt @@ -17,6 +17,7 @@ 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.Delete import androidx.compose.material.icons.filled.MarkChatRead import androidx.compose.material.icons.filled.MarkChatUnread import androidx.compose.material.icons.filled.Unarchive @@ -98,6 +99,7 @@ private val ItemAppearanceSpec = tween(durationMillis = ITEM_APPEARANCE_D internal enum class ConversationSwipeKind { Archive, Unarchive, + Delete, ToggleRead, } @@ -493,12 +495,14 @@ private fun ConversationListSwipeBackground( val containerColor = when (background) { ConversationSwipeKind.Archive -> MaterialTheme.colorScheme.secondaryContainer ConversationSwipeKind.Unarchive -> MaterialTheme.colorScheme.secondaryContainer + ConversationSwipeKind.Delete -> MaterialTheme.colorScheme.errorContainer ConversationSwipeKind.ToggleRead -> MaterialTheme.colorScheme.tertiaryContainer } val contentColor = when (background) { ConversationSwipeKind.Archive -> MaterialTheme.colorScheme.onSecondaryContainer ConversationSwipeKind.Unarchive -> MaterialTheme.colorScheme.onSecondaryContainer + ConversationSwipeKind.Delete -> MaterialTheme.colorScheme.onErrorContainer ConversationSwipeKind.ToggleRead -> MaterialTheme.colorScheme.onTertiaryContainer } @@ -535,6 +539,7 @@ private fun swipeBackgroundIcon( return when (background) { ConversationSwipeKind.Archive -> Icons.Filled.Archive ConversationSwipeKind.Unarchive -> Icons.Filled.Unarchive + ConversationSwipeKind.Delete -> Icons.Filled.Delete ConversationSwipeKind.ToggleRead -> when { isUnread -> Icons.Filled.MarkChatRead else -> Icons.Filled.MarkChatUnread @@ -550,6 +555,7 @@ private fun swipeBackgroundDescription( return when (background) { ConversationSwipeKind.Archive -> stringResource(R.string.action_archive) ConversationSwipeKind.Unarchive -> stringResource(R.string.action_unarchive) + ConversationSwipeKind.Delete -> stringResource(R.string.action_delete) ConversationSwipeKind.ToggleRead -> when { isUnread -> stringResource(R.string.mark_as_read) else -> stringResource(R.string.mark_as_unread)