From 141b147f4afe511162ec13490f0a0b3610186dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Forlini?= Date: Wed, 25 Sep 2024 13:40:41 +0200 Subject: [PATCH] Update Contacts and Contacts group creation logic MAIlANDR-2147 --- .../android/navigation/route/HomeRoutes.kt | 6 --- .../ContactGroupFormScreen.kt | 8 ++-- .../contactlist/ContactListOperation.kt | 1 + .../contactlist/ContactListReducer.kt | 15 +++++++ .../contactlist/ContactListState.kt | 3 ++ .../contactlist/ContactListViewModel.kt | 4 ++ .../contactlist/ui/ContactListScreen.kt | 44 +++++++++++++------ .../contactlist/ContactListViewModelTest.kt | 40 +++++++++++++++-- 8 files changed, 95 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt index 8a19dff71..7c97ca61c 100644 --- a/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt @@ -387,12 +387,6 @@ internal fun NavGraphBuilder.addContacts( exitWithErrorMessage = { message -> navController.navigateBack() showErrorSnackbar(message) - }, - showNormSnackbar = { message -> - showNormalSnackbar(message) - }, - showErrorSnackbar = { message -> - showErrorSnackbar(message) } ) ) diff --git a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactgroupform/ContactGroupFormScreen.kt b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactgroupform/ContactGroupFormScreen.kt index 75f571cd9..fa80d062c 100644 --- a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactgroupform/ContactGroupFormScreen.kt +++ b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactgroupform/ContactGroupFormScreen.kt @@ -58,11 +58,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION import ch.protonmail.android.mailcommon.presentation.compose.MailDimens -import ch.protonmail.android.uicomponents.dismissKeyboard import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags import ch.protonmail.android.mailcontact.presentation.R import ch.protonmail.android.mailcontact.presentation.model.ContactGroupFormMember @@ -72,6 +72,7 @@ import ch.protonmail.android.mailcontact.presentation.ui.DeleteContactGroupDialo import ch.protonmail.android.mailcontact.presentation.ui.FormInputField import ch.protonmail.android.mailcontact.presentation.ui.IconContactAvatar import ch.protonmail.android.maillabel.presentation.ui.FormDeleteButton +import ch.protonmail.android.uicomponents.dismissKeyboard import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost import me.proton.core.compose.component.ProtonCenteredProgress import me.proton.core.compose.component.ProtonSecondaryButton @@ -79,7 +80,6 @@ import me.proton.core.compose.component.ProtonSnackbarHostState import me.proton.core.compose.component.ProtonSnackbarType import me.proton.core.compose.component.ProtonTextButton import me.proton.core.compose.component.appbar.ProtonTopAppBar -import me.proton.core.compose.flow.rememberAsState import me.proton.core.compose.theme.ProtonDimens import me.proton.core.compose.theme.ProtonTheme import me.proton.core.compose.theme.defaultNorm @@ -97,8 +97,8 @@ fun ContactGroupFormScreen( val context = LocalContext.current val view = LocalView.current val keyboardController = LocalSoftwareKeyboardController.current - val snackbarHostErrorState = ProtonSnackbarHostState(defaultType = ProtonSnackbarType.ERROR) - val state = rememberAsState(flow = viewModel.state, initial = ContactGroupFormViewModel.initialState).value + val snackbarHostErrorState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.ERROR) } + val state = viewModel.state.collectAsStateWithLifecycle().value val selectionSubmitted = remember { mutableStateOf(false) } if (!selectionSubmitted.value) { diff --git a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListOperation.kt b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListOperation.kt index e7f7d2792..44900c6b4 100644 --- a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListOperation.kt +++ b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListOperation.kt @@ -49,4 +49,5 @@ internal sealed interface ContactListEvent : ContactListOperation { data object OpenBottomSheet : ContactListEvent data object OpenContactSearch : ContactListEvent data object DismissBottomSheet : ContactListEvent + data object UpsellingInProgress : ContactListEvent } diff --git a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListReducer.kt b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListReducer.kt index 1423cdce0..0701b20c3 100644 --- a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListReducer.kt +++ b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListReducer.kt @@ -38,6 +38,7 @@ class ContactListReducer @Inject constructor() { is ContactListEvent.OpenContactSearch -> reduceOpenContactSearch(currentState) is ContactListEvent.SubscriptionUpgradeRequiredError -> reduceErrorSubscriptionUpgradeRequired(currentState) is ContactListEvent.OpenUpsellingBottomSheet -> reduceOpenUpsellingBottomSheet(currentState) + is ContactListEvent.UpsellingInProgress -> reduceUpsellingInProgress(currentState) } } @@ -197,4 +198,18 @@ class ContactListReducer @Inject constructor() { ) } } + + private fun reduceUpsellingInProgress(currentState: ContactListState): ContactListState { + val upsellingInProgressEffect = Effect.of(TextUiModel(R.string.upselling_snackbar_upgrade_in_progress)) + return when (currentState) { + is ContactListState.Loading -> currentState + is ContactListState.Loaded.Data -> currentState.copy( + upsellingInProgress = upsellingInProgressEffect + ) + + is ContactListState.Loaded.Empty -> currentState.copy( + upsellingInProgress = upsellingInProgressEffect + ) + } + } } diff --git a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListState.kt b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListState.kt index 3ddc69e19..50a33561e 100644 --- a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListState.kt +++ b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListState.kt @@ -45,6 +45,7 @@ sealed interface ContactListState { val openImportContact: Effect val openContactSearch: Effect val subscriptionError: Effect + val upsellingInProgress: Effect val bottomSheetType: BottomSheetType data class Data( @@ -54,6 +55,7 @@ sealed interface ContactListState { override val openImportContact: Effect = Effect.empty(), override val openContactSearch: Effect = Effect.empty(), override val subscriptionError: Effect = Effect.empty(), + override val upsellingInProgress: Effect = Effect.empty(), override val isContactGroupsCrudEnabled: Boolean = false, override val isContactGroupsUpsellingVisible: Boolean = false, override val isContactSearchEnabled: Boolean = false, @@ -69,6 +71,7 @@ sealed interface ContactListState { override val openImportContact: Effect = Effect.empty(), override val openContactSearch: Effect = Effect.empty(), override val subscriptionError: Effect = Effect.empty(), + override val upsellingInProgress: Effect = Effect.empty(), override val isContactGroupsCrudEnabled: Boolean = false, override val isContactGroupsUpsellingVisible: Boolean = false, override val isContactSearchEnabled: Boolean = false, diff --git a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModel.kt b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModel.kt index 4df1e6f03..b620d63b8 100644 --- a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModel.kt +++ b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModel.kt @@ -30,6 +30,7 @@ import ch.protonmail.android.mailcontact.domain.usecase.featureflags.IsContactSe import ch.protonmail.android.mailcontact.presentation.model.ContactGroupItemUiModelMapper import ch.protonmail.android.mailcontact.presentation.model.ContactListItemUiModelMapper import ch.protonmail.android.mailupselling.domain.model.UpsellingEntryPoint +import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState import ch.protonmail.android.mailupselling.presentation.usecase.ObserveUpsellingVisibility import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow @@ -57,6 +58,7 @@ class ContactListViewModel @Inject constructor( private val contactGroupItemUiModelMapper: ContactGroupItemUiModelMapper, private val isContactGroupsCrudEnabled: IsContactGroupsCrudEnabled, private val observeUpsellingVisibility: ObserveUpsellingVisibility, + private val userUpgradeState: UserUpgradeState, private val isContactSearchEnabled: IsContactSearchEnabled, observePrimaryUserId: ObservePrimaryUserId ) : ViewModel() { @@ -88,6 +90,8 @@ class ContactListViewModel @Inject constructor( } private suspend fun handleOnNewContactGroupClick() { + if (userUpgradeState.isUserPendingUpgrade) return emitNewStateFor(ContactListEvent.UpsellingInProgress) + val shouldShowUpselling = observeUpsellingVisibility(UpsellingEntryPoint.BottomSheet.ContactGroups).first() if (shouldShowUpselling) { diff --git a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ui/ContactListScreen.kt b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ui/ContactListScreen.kt index 692b4cc22..dd7af3e00 100644 --- a/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ui/ContactListScreen.kt +++ b/mail-contact/presentation/src/main/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ui/ContactListScreen.kt @@ -14,11 +14,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect +import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags import ch.protonmail.android.mailcontact.presentation.R import ch.protonmail.android.mailcontact.presentation.contactlist.ContactListState import ch.protonmail.android.mailcontact.presentation.contactlist.ContactListViewAction @@ -28,8 +30,12 @@ import ch.protonmail.android.mailcontact.presentation.utils.ContactFeatureFlags. import ch.protonmail.android.mailupselling.presentation.model.BottomSheetVisibilityEffect import ch.protonmail.android.mailupselling.presentation.ui.bottomsheet.UpsellingBottomSheet import ch.protonmail.android.uicomponents.bottomsheet.bottomSheetHeightConstrainedContent +import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost +import kotlinx.coroutines.launch import me.proton.core.compose.component.ProtonCenteredProgress import me.proton.core.compose.component.ProtonModalBottomSheetLayout +import me.proton.core.compose.component.ProtonSnackbarHostState +import me.proton.core.compose.component.ProtonSnackbarType import me.proton.core.contact.domain.entity.ContactId import me.proton.core.label.domain.entity.LabelId @@ -41,6 +47,8 @@ fun ContactListScreen(listActions: ContactListScreen.Actions, viewModel: Contact skipHalfExpanded = true ) val scope = rememberCoroutineScope() + val snackbarHostState = remember { ProtonSnackbarHostState() } + val state = viewModel.state.collectAsStateWithLifecycle().value var showBottomSheet by remember { mutableStateOf(false) } @@ -48,6 +56,16 @@ fun ContactListScreen(listActions: ContactListScreen.Actions, viewModel: Contact onNewGroupClick = { viewModel.submit(ContactListViewAction.OnNewContactGroupClick) } ) + val bottomSheetActions = UpsellingBottomSheet.Actions.Empty.copy( + onDismiss = { viewModel.submit(ContactListViewAction.OnDismissBottomSheet) }, + onUpgrade = { message -> + scope.launch { snackbarHostState.showSnackbar(ProtonSnackbarType.NORM, message) } + }, + onError = { message -> + scope.launch { snackbarHostState.showSnackbar(ProtonSnackbarType.ERROR, message) } + } + ) + BackHandler(bottomSheetState.isVisible) { viewModel.submit(ContactListViewAction.OnDismissBottomSheet) } @@ -76,13 +94,7 @@ fun ContactListScreen(listActions: ContactListScreen.Actions, viewModel: Contact } ContactListState.BottomSheetType.Upselling -> { - ContactGroupsUpsellingBottomSheet( - actions = UpsellingBottomSheet.Actions.Empty.copy( - onDismiss = { viewModel.submit(ContactListViewAction.OnDismissBottomSheet) }, - onUpgrade = { message -> actions.showNormSnackbar(message) }, - onError = { message -> actions.showErrorSnackbar(message) } - ) - ) + ContactGroupsUpsellingBottomSheet(actions = bottomSheetActions) } } } @@ -133,6 +145,10 @@ fun ContactListScreen(listActions: ContactListScreen.Actions, viewModel: Contact } } } + ConsumableTextEffect(effect = state.upsellingInProgress) { message -> + snackbarHostState.snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(ProtonSnackbarType.NORM, message) + } } when (state) { @@ -172,6 +188,12 @@ fun ContactListScreen(listActions: ContactListScreen.Actions, viewModel: Contact } } } + }, + snackbarHost = { + DismissableSnackbarHost( + modifier = Modifier.testTag(CommonTestTags.SnackbarHost), + protonSnackbarHostState = snackbarHostState + ) } ) } @@ -189,9 +211,7 @@ object ContactListScreen { val onNewGroupClick: () -> Unit, val openImportContact: () -> Unit, val onSubscriptionUpgradeRequired: (String) -> Unit, - val exitWithErrorMessage: (String) -> Unit, - val showNormSnackbar: (String) -> Unit, - val showErrorSnackbar: (String) -> Unit + val exitWithErrorMessage: (String) -> Unit ) { companion object { @@ -206,9 +226,7 @@ object ContactListScreen { openImportContact = {}, onNewGroupClick = {}, onSubscriptionUpgradeRequired = {}, - exitWithErrorMessage = {}, - showNormSnackbar = {}, - showErrorSnackbar = {} + exitWithErrorMessage = {} ) fun fromContactSearchActions( diff --git a/mail-contact/presentation/src/test/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModelTest.kt b/mail-contact/presentation/src/test/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModelTest.kt index 9e1ccbf53..96cdd8af6 100644 --- a/mail-contact/presentation/src/test/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModelTest.kt +++ b/mail-contact/presentation/src/test/kotlin/ch/protonmail/android/mailcontact/presentation/contactlist/ContactListViewModelTest.kt @@ -38,8 +38,8 @@ import ch.protonmail.android.mailcontact.presentation.R import ch.protonmail.android.mailcontact.presentation.model.ContactGroupItemUiModelMapper import ch.protonmail.android.mailcontact.presentation.model.ContactListItemUiModelMapper import ch.protonmail.android.maillabel.presentation.getHexStringFromColor +import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState import ch.protonmail.android.mailupselling.presentation.model.BottomSheetVisibilityEffect -import ch.protonmail.android.mailupselling.domain.usecase.featureflags.IsUpsellingContactGroupsEnabled import ch.protonmail.android.mailupselling.presentation.usecase.ObserveUpsellingVisibility import ch.protonmail.android.test.utils.rule.MainDispatcherRule import ch.protonmail.android.testdata.user.UserIdTestData @@ -112,8 +112,9 @@ class ContactListViewModelTest { private val observeUpsellingVisibilityMock = mockk { every { this@mockk(any()) } returns flowOf(false) } - private val isUpsellingContactGroupsEnabledMock = mockk { - every { this@mockk() } returns true + + private val userUpgradeState = mockk { + every { this@mockk.isUserPendingUpgrade } returns false } private val reducer = ContactListReducer() @@ -134,6 +135,7 @@ class ContactListViewModelTest { contactGroupItemUiModelMapper, isContactGroupsCrudEnabledMock, observeUpsellingVisibilityMock, + userUpgradeState, isContactSearchEnabledMock, observePrimaryUserId ) @@ -303,6 +305,38 @@ class ContactListViewModelTest { } } + @Test + fun `when user subscription upgrade is pending, emit the upselling in progress event`() = runTest { + // Given + expectContactsData() + coEvery { observeUpsellingVisibilityMock(any()) } returns flowOf(false) + every { userUpgradeState.isUserPendingUpgrade } returns true + + // When + contactListViewModel.state.test { + // Then + skipItems(1) + + contactListViewModel.submit(ContactListViewAction.OnNewContactGroupClick) + + val actual = awaitItem() + val expected = ContactListState.Loaded.Data( + contacts = contactListItemUiModelMapper.toContactListItemUiModel( + listOf(defaultTestContact) + ), + contactGroups = contactGroupItemUiModelMapper.toContactGroupItemUiModel( + listOf(defaultTestContact), listOf(defaultTestContactGroupLabel) + ), + isContactGroupsCrudEnabled = true, + isContactGroupsUpsellingVisible = false, + isContactSearchEnabled = true, + upsellingInProgress = Effect.of(TextUiModel(R.string.upselling_snackbar_upgrade_in_progress)) + ) + + assertEquals(expected, actual) + } + } + @Test fun `given contact list, when action open bottom sheet, then emits open state`() = runTest { // Given