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)