diff --git a/android/navi-base/src/main/java/com/navi/base/utils/NaviDateFormatter.kt b/android/navi-base/src/main/java/com/navi/base/utils/NaviDateFormatter.kt index c4db5c63f4..5a299506fc 100644 --- a/android/navi-base/src/main/java/com/navi/base/utils/NaviDateFormatter.kt +++ b/android/navi-base/src/main/java/com/navi/base/utils/NaviDateFormatter.kt @@ -67,12 +67,12 @@ object NaviDateFormatter { .capitalizeMeridiem() } - private fun isDifferenceOneDay(transactionTimestamp: DateTime): Boolean { + fun isDifferenceOneDay(transactionTimestamp: DateTime): Boolean { val currentDate = LocalDate.now() return currentDate.minusDays(1) == transactionTimestamp.toLocalDate() } - private fun getTimeInHoursAndMinutes(transactionTimestamp: DateTime, locale: Locale): String { + fun getTimeInHoursAndMinutes(transactionTimestamp: DateTime, locale: Locale): String { return DateUtils.getFormattedDateTimeAsStringFromDateTimeObject( dateTime = transactionTimestamp, format = DATE_TIME_FORMAT_HOUR_MINUTE, @@ -81,7 +81,7 @@ object NaviDateFormatter { .capitalizeMeridiem() } - private fun isSameDay(transactionTimestamp: DateTime): Boolean { + fun isSameDay(transactionTimestamp: DateTime): Boolean { val currentDate = LocalDate.now() return currentDate == transactionTimestamp.toLocalDate() } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/model/config/NaviPayDefaultConfig.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/model/config/NaviPayDefaultConfig.kt index 346d3eccde..6a8845947d 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/common/model/config/NaviPayDefaultConfig.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/model/config/NaviPayDefaultConfig.kt @@ -81,9 +81,9 @@ data class DefaultConfigContent( "Your transaction has been blocked due to potential fraud. Please review the details or contact customer support for help.", @SerializedName("frequentTransactionMaxColumns") val frequentTransactionsMaxColumns: Int = 4, @SerializedName("frequentTransactionTotalEntries") - val frequentTransactionsTotalEntries: Int = 12, + val frequentTransactionsTotalEntries: Int = 6, @SerializedName("frequentTransactionHeading") - val frequentTransactionHeading: String = "Pay again", + val frequentTransactionHeading: String = "Suggestions", @SerializedName("upiAppLogoS3BaseUrl") val upiAppLogoS3BaseUrl: String = "https://public-assets.prod.navi-sa.in/navi-pay/png/upi-app-logos/", diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayManager.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayManager.kt index 9fc00be623..6c7f106917 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayManager.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayManager.kt @@ -15,6 +15,8 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.navi.base.cache.model.NaviCacheEntity import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.base.sharedpref.PreferenceManager +import com.navi.base.utils.orFalse import com.navi.common.constants.EMPTY import com.navi.common.upi.TYPE import com.navi.common.upi.UpiDataType @@ -46,6 +48,7 @@ import com.navi.pay.onboarding.account.detail.model.view.LinkedAccountEntity import com.navi.pay.onboarding.binding.model.view.NaviPayCustomerOnboardingEntity import com.navi.pay.utils.KEY_UPI_LITE_ACTIVE_ACCOUNT_INFO import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_RCC_LANDING_EXP +import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT import com.navi.pay.utils.RUPEE_SYMBOL import com.navi.pay.utils.UPI_LITE_CONFIG import dagger.Lazy @@ -144,6 +147,8 @@ constructor( refreshGenericOffersUseCase.execute(screenName = SCREEN_NAME) } launch(context = Dispatchers.IO) { storeRccLandingExperiment() } + + launch(context = Dispatchers.IO) { savePayToContactsExperimentData() } } } @@ -287,4 +292,16 @@ constructor( } } } + + private suspend fun savePayToContactsExperimentData() { + val payToContactsExperimentInfo = + litmusExperimentUseCase.execute( + experimentName = LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT + ) + + PreferenceManager.setBooleanPreference( + LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT, + payToContactsExperimentInfo?.variant?.enabled.orFalse(), + ) + } } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayRouter.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayRouter.kt index 68f1ce53e9..08238ee8cc 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayRouter.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/setup/NaviPayRouter.kt @@ -14,6 +14,7 @@ import android.os.Bundle import androidx.core.net.toUri import com.navi.base.deeplink.DeepLinkManager import com.navi.base.model.CtaData +import com.navi.base.sharedpref.PreferenceManager import com.navi.base.utils.EMPTY import com.navi.common.upi.PAYEE_ENTITY import com.navi.common.upi.PIN_ACTION @@ -40,6 +41,7 @@ import com.navi.pay.compose.destinations.NaviPayFaqScreenDestination import com.navi.pay.compose.destinations.OrderDetailsScreenDestination import com.navi.pay.compose.destinations.OrderHistoryScreenDestination import com.navi.pay.compose.destinations.PayToContactsScreenDestination +import com.navi.pay.compose.destinations.PayToContactsScreenV2Destination import com.navi.pay.compose.destinations.QrScannerScreenDestination import com.navi.pay.compose.destinations.SavedBeneficiaryScreenDestination import com.navi.pay.compose.destinations.SendMoneyScreenDestination @@ -57,6 +59,7 @@ import com.navi.pay.onboarding.account.linked.model.view.LinkedAccountsScreenSou import com.navi.pay.utils.ACCOUNT_ID import com.navi.pay.utils.ACTION_PIN_SET import com.navi.pay.utils.IS_FROM_IAN +import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT import com.navi.pay.utils.NAVI_PAY_LOCAL_URI_SCHEME import com.navi.pay.utils.NAVI_PAY_NOTIFICATION_DATA_BUNDLE import com.navi.pay.utils.NAVI_PAY_NOTIFICATION_ID @@ -134,7 +137,17 @@ object NaviPayRouter { NaviPayScreenType.NAVI_PAY_BLOCKED_USERS_SCREEN.name -> BlockedUsersScreenDestination NaviPayScreenType.NAVI_PAY_FAQ.name -> NaviPayFaqScreenDestination(source = sourceScreenName) - NaviPayScreenType.NAVI_PAY_TO_CONTACTS.name -> PayToContactsScreenDestination + NaviPayScreenType.NAVI_PAY_TO_CONTACTS.name -> { + if ( + PreferenceManager.getBooleanPreference( + LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT + ) + ) { + PayToContactsScreenV2Destination + } else { + PayToContactsScreenDestination + } + } NaviPayScreenType.NAVI_PAY_SEND_MONEY_SCREEN.name -> { naviPayActivityDataProvider.setSendMoneyScreenData( payeeEntity = @@ -207,7 +220,17 @@ object NaviPayRouter { return when (screenIdentifier) { NaviPayScreenType.NAVI_PAY_QR_SCANNER_SCREEN.name -> QrScannerScreenDestination NaviPayScreenType.NAVI_PAY_VIA_BANK_ACCOUNT.name -> BankDetailInputScreenDestination - NaviPayScreenType.NAVI_PAY_TO_CONTACTS.name -> PayToContactsScreenDestination + NaviPayScreenType.NAVI_PAY_TO_CONTACTS.name -> { + if ( + PreferenceManager.getBooleanPreference( + LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT + ) + ) { + PayToContactsScreenV2Destination + } else { + PayToContactsScreenDestination + } + } NaviPayScreenType.NAVI_PAY_VIA_UPI_ID.name -> UPIIdInputScreenDestination NaviPayScreenType.NAVI_PAY_FAQ.name -> NaviPayFaqScreenDestination.invoke() NaviPayScreenType.NAVI_PAY_FTUE_ONBOARDING.name -> NaviPayEmptyHomeScreenDestination diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/utils/FrequentOrdersHelperV2.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/utils/FrequentOrdersHelperV2.kt new file mode 100644 index 0000000000..f4c8b673df --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/utils/FrequentOrdersHelperV2.kt @@ -0,0 +1,139 @@ +/* + * + * * Copyright © 2024-2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.common.utils + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.navi.base.cache.model.NaviCacheEntity +import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.base.utils.coroutine.CoroutineManager +import com.navi.pay.common.utils.NaviPayCommonUtils.getValidPhoneNumberOrEmpty +import com.navi.pay.management.common.sendmoney.model.network.TransactionInitiationType +import com.navi.pay.network.di.NaviPayGsonBuilder +import com.navi.pay.onboarding.account.common.repository.AccountsRepository +import com.navi.pay.tstore.list.model.view.FrequentOrderEntityV2 +import com.navi.pay.tstore.list.model.view.OrderEntity +import com.navi.pay.tstore.list.model.view.OrderStatusOfView +import com.navi.pay.tstore.list.model.view.toFrequentOrderEntityListV2 +import com.navi.pay.tstore.list.repository.OrderRepository +import com.navi.pay.utils.NAVI_PAY_SUGGESTED_FREQUENT_ORDER_CACHE_KEY +import com.navi.pay.utils.parallelFilter +import javax.inject.Inject +import kotlinx.coroutines.launch + +/** + * Filtering all successful outgoing pay to contact orders + * + * Grouping filtered orders based on vpa, sorting on basis of count and recency + */ +class FrequentOrdersHelperV2 +@Inject +constructor( + private val orderRepository: OrderRepository, + private val naviCacheRepository: NaviCacheRepository, + private val accountRepository: AccountsRepository, + @NaviPayGsonBuilder private val gson: Gson, +) { + + suspend fun getFrequentOrderList(frequentOrdersTotalEntries: Int): List { + val latestCachedFrequentOrderListFromDB = + naviCacheRepository.get(key = NAVI_PAY_SUGGESTED_FREQUENT_ORDER_CACHE_KEY)?.value?.let { + gson.fromJson>( + it, + object : TypeToken>() {}.type, + ) + } ?: emptyList() + + CoroutineManager.io.launch { + processAndRefreshFrequentOrderCache(frequentOrdersTotalEntries) + } + return latestCachedFrequentOrderListFromDB + } + + suspend fun processAndRefreshFrequentOrderCache(frequentOrdersTotalEntries: Int) { + val aggregatedOrdersGroupedByVpa = + fetchAndAggregateOrders(frequentOrdersTotalEntries = frequentOrdersTotalEntries) + + val finalFrequentEntityList = + aggregatedOrdersGroupedByVpa + .getTopFrequentEntries(frequentOrdersTotalEntries) + .map { it.value.first() } + .toFrequentOrderEntityListV2() + + naviCacheRepository.save( + NaviCacheEntity( + key = NAVI_PAY_SUGGESTED_FREQUENT_ORDER_CACHE_KEY, + value = gson.toJson(finalFrequentEntityList), + version = 1, + ) + ) + } + + private suspend fun fetchAndAggregateOrders( + frequentOrdersTotalEntries: Int + ): Map> { + var offset = 0 + val limit = 500 + val aggregatedOrders = mutableMapOf>() + + do { + val ordersByOffset = + orderRepository.getOrderEntitiesByOffset(limit = limit, offset = offset) + if (ordersByOffset.isNotEmpty()) { + ordersByOffset + .filteredOrderEntityList() + .groupBy { it.orderDescription } + .getTopFrequentEntries(frequentOrdersTotalEntries) + .forEach { (vpa, orders) -> + aggregatedOrders.getOrPut(vpa) { mutableListOf() }.addAll(orders) + } + } + offset += limit + } while (ordersByOffset.isNotEmpty()) + + return aggregatedOrders + } + + private fun Map>.getTopFrequentEntries( + frequentOrdersTotalEntries: Int + ): List>> { + return this.entries + .sortedWith( + compareByDescending>> { it.value.size } + .thenByDescending { it.value.first().orderTimestamp } + ) + .take(frequentOrdersTotalEntries) + } + + private suspend fun List.filteredOrderEntityList(): List { + val userVpaList = accountRepository.getAllVpa() + val acceptedPaymentMode = listOf(TransactionInitiationType.PAY_TO_CONTACT.name) + + val successfulOrderList = + this.parallelFilter { + (it.orderStatusOfView == OrderStatusOfView.Debit || + it.orderStatusOfView == OrderStatusOfView.Credit) + } + + val payToContactOrderList = + successfulOrderList.parallelFilter { + it.naviPayTransactionDetailsMetadata.paymentMode in acceptedPaymentMode && + !userVpaList.any { vpa -> vpa.equals(it.orderDescription, ignoreCase = true) } + } + + val validPhoneNumberOrderList = + payToContactOrderList.parallelFilter { + getValidPhoneNumberOrEmpty( + mobNo = it.naviPayTransactionDetailsMetadata.payeeInfo?.mobNo.orEmpty() + ) + .isNotBlank() + } + + return validPhoneNumberOrderList + } +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/viewmodel/PaymentSummaryViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/viewmodel/PaymentSummaryViewModel.kt index 4ea52f61ce..ad685183d4 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/viewmodel/PaymentSummaryViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/viewmodel/PaymentSummaryViewModel.kt @@ -42,6 +42,7 @@ import com.navi.pay.common.usecase.BankSenseUseCase import com.navi.pay.common.usecase.LinkedAccountsUseCase import com.navi.pay.common.usecase.NaviPayConfigUseCase import com.navi.pay.common.usecase.RefreshGenericOffersUseCase +import com.navi.pay.common.utils.FrequentOrdersHelperV2 import com.navi.pay.common.utils.NaviPayCommonUtils import com.navi.pay.common.utils.NaviPayCommonUtils.getDateTimeObjectFromEpochString import com.navi.pay.common.utils.getMetricInfo @@ -137,6 +138,7 @@ constructor( private val linkedAccountsUseCase: LinkedAccountsUseCase, @NaviPayGsonBuilder private val gson: Gson, private val bankSenseUseCase: BankSenseUseCase, + private val frequentOrdersHelper: FrequentOrdersHelperV2, ) : NaviPayBaseVM() { val naviPayAnalytics: NaviPayAnalytics.NaviPayPaymentSummary = @@ -602,6 +604,7 @@ constructor( type = object : TypeToken() {}.type, ) ?: NaviPayDefaultConfig() } + syncFrequentOrderCache() } private fun logBiometricStrongStatus() { @@ -1154,6 +1157,12 @@ constructor( ) } + private suspend fun syncFrequentOrderCache() { + frequentOrdersHelper.processAndRefreshFrequentOrderCache( + naviPayDefaultConfig.value.config.frequentTransactionsTotalEntries + ) + } + private suspend fun updateGenericOffersInfo() { delay(2.seconds) // delay for segments to update at backend refreshGenericOffersUseCase.execute(screenName = screenName, shouldHardRefresh = true) diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/ui/PayToContactsScreenV2.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/ui/PayToContactsScreenV2.kt new file mode 100644 index 0000000000..0f61ae3aef --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/ui/PayToContactsScreenV2.kt @@ -0,0 +1,1122 @@ +/* + * + * * Copyright © 2024-2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.navi.pay.management.paytocontacts.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Scaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.navi.common.R as CommonR +import com.navi.common.utils.CommonUtils.getDisplayableAmount +import com.navi.common.utils.navigateUp +import com.navi.design.font.FontWeightEnum +import com.navi.design.font.getFontWeight +import com.navi.design.font.naviFontFamily +import com.navi.design.utils.maxScrollFlingBehavior +import com.navi.naviwidgets.R as widgetsR +import com.navi.naviwidgets.extensions.NaviText +import com.navi.naviwidgets.extensions.isAtTopPage +import com.navi.pay.R +import com.navi.pay.analytics.NaviPayToContacts +import com.navi.pay.common.model.view.NaviPayScreenType +import com.navi.pay.common.model.view.NaviPermissionResult +import com.navi.pay.common.model.view.rememberMultiplePermissions +import com.navi.pay.common.setup.NaviPayRouter +import com.navi.pay.common.theme.color.NaviPayColor +import com.navi.pay.common.ui.BottomSheetContentWithIconHeaderDescButton +import com.navi.pay.common.ui.ContactIconView +import com.navi.pay.common.ui.EmptyDataScreen +import com.navi.pay.common.ui.ImageTitleDescriptionShimmerView +import com.navi.pay.common.ui.InputTextFieldWithDescriptionHeader +import com.navi.pay.common.ui.LoadingScreen +import com.navi.pay.common.ui.NaviPayHeader +import com.navi.pay.common.ui.NaviPayLottieAnimation +import com.navi.pay.common.ui.NaviPayModalBottomSheet +import com.navi.pay.common.ui.NaviPaySponsorView +import com.navi.pay.common.ui.SelfTransferCtaView +import com.navi.pay.common.ui.ShadowStrip +import com.navi.pay.common.utils.NaviPayCommonUtils +import com.navi.pay.common.utils.NaviPayCommonUtils.getNormalisedPhoneNumber +import com.navi.pay.entry.NaviPayActivity +import com.navi.pay.management.common.model.view.WarningErrorInfoState +import com.navi.pay.management.common.sendmoney.model.view.UpiTransactionType +import com.navi.pay.management.paytocontacts.model.view.PhoneContactEntity +import com.navi.pay.management.paytocontacts.viewmodel.PayToContactsBottomSheetUIState +import com.navi.pay.management.paytocontacts.viewmodel.PayToContactsUIState +import com.navi.pay.management.paytocontacts.viewmodel.PayToContactsViewModelV2 +import com.navi.pay.navigation.NaviPayRootGraph +import com.navi.pay.permission.utils.PermissionKeys +import com.navi.pay.permission.utils.PermissionUtils +import com.navi.pay.tstore.list.model.network.OrderType +import com.navi.pay.tstore.list.model.view.FrequentOrderEntityV2 +import com.navi.pay.utils.BULLET +import com.navi.pay.utils.LINKED_ACCOUNT_SCREEN_SOURCE +import com.navi.pay.utils.NAVI_PAY_LOADER +import com.navi.pay.utils.NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE +import com.navi.pay.utils.SELF_TRANSFER +import com.navi.pay.utils.contactInitials +import com.navi.pay.utils.customHide +import com.navi.pay.utils.launchPermissionSettingsScreen +import com.navi.pay.utils.noRippleClickable +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.launch + +@OptIn(ExperimentalPermissionsApi::class) +@Destination +@Composable +fun PayToContactsScreenV2( + naviPayActivity: NaviPayActivity, + payToContactsViewModel: PayToContactsViewModelV2 = hiltViewModel(), + navigator: DestinationsNavigator, + naviPayAnalytics: NaviPayToContacts = NaviPayToContacts(), +) { + + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val view = LocalView.current + val contactList by payToContactsViewModel.filteredContactList.collectAsStateWithLifecycle() + val searchQuery by payToContactsViewModel.searchQuery.collectAsStateWithLifecycle() + val keyboardController = LocalSoftwareKeyboardController.current + val uiState by payToContactsViewModel.uiState.collectAsStateWithLifecycle() + val isNewContactVisible by + payToContactsViewModel.isNewContactVisible.collectAsStateWithLifecycle() + val filteredFrequentOrdersList by + payToContactsViewModel.filteredFrequentOrdersList.collectAsStateWithLifecycle() + val showLoader by payToContactsViewModel.showLoader.collectAsStateWithLifecycle() + val showShimmer by payToContactsViewModel.showShimmer.collectAsStateWithLifecycle() + val hideKeyboard by payToContactsViewModel.hideKeyboard.collectAsStateWithLifecycle() + val newContact by payToContactsViewModel.newContact.collectAsStateWithLifecycle() + val invalidState by payToContactsViewModel.isWarningOrErrorState.collectAsStateWithLifecycle() + val isEmptyState by payToContactsViewModel.isEmptyState.collectAsStateWithLifecycle() + val clickedContactPhoneNumber by + payToContactsViewModel.activeQueryContactNumber.collectAsStateWithLifecycle() + val frequentOrdersHeading by + payToContactsViewModel.frequentOrdersHeading.collectAsStateWithLifecycle() + val shouldShowYourContactsTitle by + payToContactsViewModel.shouldShowYourContactsTitle.collectAsStateWithLifecycle() + val showPermissionWidget by + payToContactsViewModel.showPermissionWidget.collectAsStateWithLifecycle() + val isSelfTransferCtaVisible by + payToContactsViewModel.isSelfTransferCtaVisible.collectAsStateWithLifecycle() + + val onBackClick = { + payToContactsViewModel.cancelPaymentRequest() + payToContactsViewModel.updateSearchQueryStringState(searchQuery = "") + navigator.navigateUp(naviPayActivity) + } + + LaunchedEffect(Unit) { + naviPayAnalytics.onSendToContactsLanded( + naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes(), + dropOffFunnelStep = payToContactsViewModel.dropOffFunnelStep, + ) + } + + val onTrailingIconClicked = { + payToContactsViewModel.updateSearchQueryStringState(searchQuery = "") + payToContactsViewModel.cancelPaymentRequest() + } + + LaunchedEffect(key1 = hideKeyboard) { + if (hideKeyboard) { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + payToContactsViewModel.updateHideKeyboard(hideKeyboard = false) + } + } + + LaunchedEffect(Unit) { + payToContactsViewModel.navigateToNextScreen.collect { navigator.navigate(direction = it) } + } + + LaunchedEffect(Unit) { + payToContactsViewModel.apiErrorMessage.collect { + naviPayAnalytics.onSendMoneyUpiLinkError( + errorMessage = it, + naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes(), + ) + } + } + + LaunchedEffect(Unit) { + payToContactsViewModel.navigateToNextScreenFromHelpCta.collect { + it?.let { NaviPayRouter.onCtaClick(naviPayActivity = naviPayActivity, ctaData = it) } + } + } + + BackHandler { onBackClick() } + + val scope = rememberCoroutineScope() + val bottomSheetStateHolder by + payToContactsViewModel.bottomSheetStateHolder.collectAsStateWithLifecycle() + + val bottomSheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { bottomSheetStateHolder.bottomSheetStateChange }, + ) + + val onDismissBottomSheet: () -> Unit = { + scope + .launch { bottomSheetState.hide() } + .invokeOnCompletion { + if (!bottomSheetState.isVisible || !bottomSheetStateHolder.bottomSheetStateChange) { + payToContactsViewModel.updateBottomSheetUIState(showBottomSheet = false) + } + } + } + + val readContactsPermissionsState = + rememberMultiplePermissions( + activity = naviPayActivity, + permissions = + PermissionUtils.getPermissionListFromPermissionKey( + permissionKey = PermissionKeys.CONTACT_PERMISSION_KEY + ), + ) { + when (it) { + NaviPermissionResult.AllGranted -> { + naviPayAnalytics.onPayToContactsPermissionGranted( + naviPaySessionAttributes = + payToContactsViewModel.getNaviPaySessionAttributes() + ) + payToContactsViewModel.updatePermissionResult(permissionResult = it) + } + NaviPermissionResult.HardDenied -> { + naviPayAnalytics.onPayToContactsPermissionDenied( + showRationale = false, + naviPaySessionAttributes = + payToContactsViewModel.getNaviPaySessionAttributes(), + ) + payToContactsViewModel.updatePermissionResult(permissionResult = it) + if (payToContactsViewModel.isPermissionLaunchedFromAllowClick) { + naviPayActivity.launchPermissionSettingsScreen() + naviPayAnalytics.onAppSettingsScreenLaunched( + naviPaySessionAttributes = + payToContactsViewModel.getNaviPaySessionAttributes() + ) + } + } + NaviPermissionResult.ShowRationale -> { + naviPayAnalytics.onPayToContactsPermissionDenied( + showRationale = true, + naviPaySessionAttributes = + payToContactsViewModel.getNaviPaySessionAttributes(), + ) + payToContactsViewModel.updatePermissionResult(permissionResult = it) + } + else -> {} + } + } + + val onAllowPermissionButtonClicked = { + payToContactsViewModel.onPermissionLaunchedFromAllowClick(isFromAllowClick = true) + readContactsPermissionsState.launchMultiplePermissionRequest() + } + + val onNewContactSelected: (PhoneContactEntity) -> Unit = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + payToContactsViewModel.initiatePaymentForNewContact() + } + + val onFrequentOrderSelected: (FrequentOrderEntityV2) -> Unit = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + payToContactsViewModel.initiatePaymentToFrequentOrder(frequentOrderEntity = it) + naviPayAnalytics.onContactSelected( + source = NaviPayScreenType.NAVI_PAY_TO_CONTACTS.name, + inContactList = false, + isFromFrequentOrderList = true, + currentSearchQuery = searchQuery, + naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes(), + isPermissionGranted = readContactsPermissionsState.allPermissionsGranted, + orderOfOrderItem = filteredFrequentOrdersList.indexOf(it), + ) + } + + val onContactSelected: (PhoneContactEntity) -> Unit = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + payToContactsViewModel.initiatePaymentToContact(phoneNumber = it.phoneNumber) + naviPayAnalytics.onContactSelected( + source = NaviPayScreenType.NAVI_PAY_TO_CONTACTS.name, + inContactList = true, + isFromFrequentOrderList = false, + currentSearchQuery = searchQuery, + naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes(), + isPermissionGranted = readContactsPermissionsState.allPermissionsGranted, + orderOfOrderItem = -1, + ) + } + + val onSelfTransferClicked = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + naviPayActivity.intent.putExtra(LINKED_ACCOUNT_SCREEN_SOURCE, SELF_TRANSFER) + payToContactsViewModel.redirectToSelfTransferScreen() + } + + val isContactListNonEmpty by + payToContactsViewModel.isSavedContactListNonEmpty.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = readContactsPermissionsState.allPermissionsGranted) { + if (readContactsPermissionsState.allPermissionsGranted) { + payToContactsViewModel.updateContactPermissionStatus(isContactPermissionGranted = true) + payToContactsViewModel.fetchContacts() + } else { + payToContactsViewModel.updateContactPermissionStatus(isContactPermissionGranted = false) + naviPayAnalytics.onSendToContactsLoaded( + naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes(), + isPermissionGranted = false, + isContactListEmpty = contactList.isEmpty(), + isFrequentOrderListEmpty = filteredFrequentOrdersList.isEmpty(), + ) + + if (!payToContactsViewModel.isPermissionPopupSeenOnLanded) { + payToContactsViewModel.onPermissionPopupSeenOnLanded() + payToContactsViewModel.onPermissionLaunchedFromAllowClick(isFromAllowClick = false) + readContactsPermissionsState.launchMultiplePermissionRequest() + } + } + } + + if (bottomSheetStateHolder.showBottomSheet) { + NaviPayModalBottomSheet( + modifier = Modifier.fillMaxWidth(), + bottomSheetState = bottomSheetState, + onDismissRequest = onDismissBottomSheet, + shouldDismissOnBackPress = true, + bottomSheetContent = { + PayToContactsBottomSheetContent( + bottomSheetUIState = bottomSheetStateHolder.bottomSheetUIState, + onDismissBottomSheet = onDismissBottomSheet, + ) + }, + ) + } + + when (uiState) { + PayToContactsUIState.Loading -> + LoadingScreen(lottieFileName = NAVI_PAY_LOADER, title = null) + PayToContactsUIState.Loaded -> + RenderPayToContactsScreen( + searchQuery = searchQuery, + isNewContactVisible = isNewContactVisible, + contactList = contactList, + onContactSelected = onContactSelected, + onNewContactSelected = onNewContactSelected, + onBackClick = onBackClick, + frequentOrders = filteredFrequentOrdersList, + frequentOrdersHeading = frequentOrdersHeading, + onFrequentOrderSelected = onFrequentOrderSelected, + areAllPermissionsGranted = readContactsPermissionsState.allPermissionsGranted, + onPrimaryButtonClicked = onAllowPermissionButtonClicked, + hasNonZeroContacts = isContactListNonEmpty, + showLoader = showLoader, + clickedContactPhoneNumber = clickedContactPhoneNumber, + showShimmer = showShimmer, + newContact = newContact, + shouldShowYourContactsTitle = shouldShowYourContactsTitle, + onSearchInputValueChange = payToContactsViewModel::updateSearchQueryStringState, + onTrailingIconClicked = onTrailingIconClicked, + invalidState = invalidState, + isEmptyState = isEmptyState, + onHelpCtaClicked = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + payToContactsViewModel.onHelpCtaClicked() + }, + showPermissionWidget = showPermissionWidget, + isSelfTransferCtaVisible = isSelfTransferCtaVisible, + userPhoneNumber = payToContactsViewModel.userPhoneNumber, + onSelfTransferClicked = onSelfTransferClicked, + ) + } +} + +@Composable +fun RenderPayToContactsScreen( + searchQuery: String, + isNewContactVisible: Boolean, + contactList: List, + onContactSelected: (PhoneContactEntity) -> Unit, + onNewContactSelected: (PhoneContactEntity) -> Unit, + onBackClick: () -> Unit, + frequentOrders: List, + frequentOrdersHeading: String, + onFrequentOrderSelected: (FrequentOrderEntityV2) -> Unit, + areAllPermissionsGranted: Boolean, + onPrimaryButtonClicked: () -> Unit, + hasNonZeroContacts: Boolean, + showLoader: Boolean, + clickedContactPhoneNumber: String, + showShimmer: Boolean = false, + newContact: PhoneContactEntity?, + shouldShowYourContactsTitle: Boolean, + onSearchInputValueChange: (String) -> Unit, + onTrailingIconClicked: () -> Unit, + invalidState: WarningErrorInfoState, + isEmptyState: Boolean, + onHelpCtaClicked: () -> Unit, + showPermissionWidget: Boolean, + isSelfTransferCtaVisible: Boolean, + userPhoneNumber: String = "", + onSelfTransferClicked: () -> Unit, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val view = LocalView.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val scrollState = rememberLazyListState() + + Scaffold( + modifier = + Modifier.fillMaxSize() + .nestedScroll( + NaviPayCommonUtils.closeKeyboardOnScroll( + scope = scope, + context = context, + view = view, + keyboardController = keyboardController, + ) + ), + topBar = { + Column(modifier = Modifier.fillMaxWidth()) { + PayToContactsScreenHeader( + navigationIcon = CommonR.drawable.ic_arrow_left_black_v2, + onBackClick = onBackClick, + onHelpCtaClicked = onHelpCtaClicked, + ) + InputTextFieldWithDescriptionHeader( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp) + .focusRequester(focusRequester) + .clickable(!showLoader) {}, + headerString = null, + placeHolderString = stringResource(id = R.string.search_name_or_mobile), + leadingIconId = CommonR.drawable.ic_search, + onLeadingIconClicked = { focusRequester.requestFocus() }, + value = searchQuery, + onValueChangeListener = onSearchInputValueChange, + isTrailingIconEnabled = searchQuery.isNotEmpty(), + onTrailingIconClicked = onTrailingIconClicked, + isLeadingIconEnabled = true, + warningErrorInfoState = invalidState.isErrorState, + enabled = !showLoader, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (invalidState.isWarningState) { + NaviText( + text = invalidState.warningMessage.orEmpty(), + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + fontFamily = naviFontFamily, + fontSize = 12.sp, + color = NaviPayColor.textPrimary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + } + + if (invalidState.isErrorState) { + NaviText( + text = invalidState.errorMessage.orEmpty(), + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + fontFamily = naviFontFamily, + fontSize = 12.sp, + color = NaviPayColor.inputFieldError, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + } + + AnimatedVisibility( + visible = !scrollState.isAtTopPage(), + enter = expandVertically(), + exit = shrinkVertically(), + ) { + ShadowStrip( + modifier = Modifier.fillMaxWidth(), + brush = + Brush.verticalGradient( + colors = + listOf( + NaviPayColor.lightGray.copy(alpha = 0.18f), + NaviPayColor.lightGray.copy(alpha = 0.01f), + NaviPayColor.ctaWhite, + ), + startY = 0f, + endY = 100f, + ), + ) + } + } + }, + content = { + if (isEmptyState) { + EmptyDataScreen( + iconResId = CommonR.drawable.ic_no_result_found, + title = stringResource(R.string.np_no_relevant_result_found), + description = stringResource(R.string.np_please_check_and_try_again), + titleFontSize = 16.sp, + titleFontWeight = FontWeightEnum.NAVI_BODY_DEMI_BOLD, + titleFontColor = NaviPayColor.textSecondary, + ) + } else { + PayToContactScreenScaffoldContent( + modifier = Modifier.padding(it).fillMaxWidth(), + searchQuery = searchQuery, + frequentOrders = frequentOrders, + isNewContactVisible = isNewContactVisible, + contactList = contactList, + onContactSelected = onContactSelected, + onNewContactSelected = onNewContactSelected, + onFrequentOrderSelected = onFrequentOrderSelected, + frequentOrdersHeading = frequentOrdersHeading, + areAllPermissionsGranted = areAllPermissionsGranted, + onPrimaryButtonClicked = onPrimaryButtonClicked, + isSearchState = searchQuery.isNotEmpty(), + hasNonZeroContacts = hasNonZeroContacts, + showLoader = showLoader, + clickedContactPhoneNumber = clickedContactPhoneNumber, + showShimmer = showShimmer, + newContact = newContact, + shouldShowYourContactsTitle = shouldShowYourContactsTitle, + scrollState = scrollState, + showPermissionWidget = showPermissionWidget, + isSelfTransferCtaVisible = isSelfTransferCtaVisible, + userPhoneNumber = userPhoneNumber, + onSelfTransferClicked = onSelfTransferClicked, + ) + } + }, + ) +} + +@Composable +private fun PayToContactsScreenHeader( + navigationIcon: Int, + onBackClick: () -> Unit, + onHelpCtaClicked: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + NaviPayHeader( + navigationIcon = navigationIcon, + title = stringResource(R.string.send_money_to_contacts), + onNavigationIconClick = onBackClick, + modifier = Modifier.fillMaxWidth(), + actionIconText = stringResource(R.string.help), + onActionClick = onHelpCtaClicked, + ) + } +} + +@Composable +private fun PayToContactScreenScaffoldContent( + modifier: Modifier, + searchQuery: String, + frequentOrders: List, + isNewContactVisible: Boolean, + contactList: List, + onContactSelected: (PhoneContactEntity) -> Unit, + onNewContactSelected: (PhoneContactEntity) -> Unit, + onFrequentOrderSelected: (FrequentOrderEntityV2) -> Unit, + frequentOrdersHeading: String, + areAllPermissionsGranted: Boolean, + onPrimaryButtonClicked: () -> Unit, + isSearchState: Boolean, + hasNonZeroContacts: Boolean, + showLoader: Boolean, + clickedContactPhoneNumber: String, + showShimmer: Boolean = false, + newContact: PhoneContactEntity?, + shouldShowYourContactsTitle: Boolean, + scrollState: LazyListState, + showPermissionWidget: Boolean, + userPhoneNumber: String = "", + isSelfTransferCtaVisible: Boolean = false, + onSelfTransferClicked: () -> Unit = {}, +) { + LazyColumn( + modifier = modifier.fillMaxHeight(), + flingBehavior = maxScrollFlingBehavior(), + state = scrollState, + ) { + item { Spacer(modifier = Modifier.height(16.dp)) } + + if (frequentOrders.isNotEmpty()) { + item { + FrequentOrdersSection( + searchQuery = searchQuery, + frequentOrders = frequentOrders, + onFrequentOrderSelected = onFrequentOrderSelected, + frequentOrdersHeading = frequentOrdersHeading, + isSearchState = isSearchState, + showLoader = showLoader, + clickedContactPhoneNumber = clickedContactPhoneNumber, + ) + } + } + + if (showShimmer) { + item { ImageTitleDescriptionShimmerView() } + } + + if (isNewContactVisible && newContact != null) { + item { + PhoneContactView( + index = 0, + phoneContact = newContact, + onContactSelected = onNewContactSelected, + showLoader = showLoader, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } + + if (areAllPermissionsGranted) { + if (shouldShowYourContactsTitle) { + item { + NaviText( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(id = R.string.your_contacts), + fontFamily = naviFontFamily, + fontSize = 16.sp, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviPayColor.textPrimary, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } + if (!hasNonZeroContacts) { + item { + EmptyDataScreen( + iconResId = R.drawable.ic_empty_contacts_list, + description = stringResource(id = R.string.empty_contacts_list_description), + ) + } + } else { + itemsIndexed( + items = contactList, + key = { index, _ -> contactList[index].normalisedPhoneNumber }, + ) { index, item -> + PhoneContactView( + index = index, + phoneContact = item, + onContactSelected = onContactSelected, + showLoader = showLoader, + clickedContactPhoneNumber = clickedContactPhoneNumber, + ) + } + if (isSelfTransferCtaVisible) { + item { + SelfTransferCtaView( + description = userPhoneNumber, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { onSelfTransferClicked() }, + ) + } + } + } + } else if (showPermissionWidget) { + item { PayToContactsPermissionViewV2(onPrimaryButtonClicked = onPrimaryButtonClicked) } + } + + if (!isSearchState) { + item { + Column(modifier = Modifier.fillMaxHeight()) { + Spacer(modifier = Modifier.height(16.dp)) + NaviPaySponsorView( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 32.dp) + ) + } + } + } + } +} + +@Composable +private fun FrequentOrdersSection( + searchQuery: String, + frequentOrders: List, + onFrequentOrderSelected: (FrequentOrderEntityV2) -> Unit, + frequentOrdersHeading: String, + isSearchState: Boolean, + showLoader: Boolean, + clickedContactPhoneNumber: String, +) { + + Column(modifier = Modifier.fillMaxWidth()) { + NaviText( + modifier = Modifier.padding(horizontal = 16.dp), + text = frequentOrdersHeading, + fontFamily = naviFontFamily, + fontSize = 16.sp, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviPayColor.textPrimary, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (searchQuery.isNotEmpty() && isSearchState) { + Column(modifier = Modifier.padding(horizontal = 4.dp)) { + frequentOrders.forEachIndexed { index, frequentOrderEntity -> + FrequentTransactionItemView( + index = index, + frequentOrderEntity = frequentOrderEntity, + onFrequentOrderSelected = onFrequentOrderSelected, + showLoader = showLoader, + clickedContactPhoneNumber = clickedContactPhoneNumber, + ) + } + } + } else { + Column(modifier = Modifier.padding(4.dp)) { + frequentOrders.map { + FrequentTransactionItemView( + index = frequentOrders.indexOf(it), + frequentOrderEntity = it, + onFrequentOrderSelected = onFrequentOrderSelected, + showLoader = showLoader, + clickedContactPhoneNumber = clickedContactPhoneNumber, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun PhoneContactView( + index: Int, + phoneContact: PhoneContactEntity, + onContactSelected: (PhoneContactEntity) -> Unit, + showLoader: Boolean, + clickedContactPhoneNumber: String = "", +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = { if (showLoader.not()) onContactSelected(phoneContact) }) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + if (phoneContact.name.isNotBlank()) { + ContactIconView( + index = index, + contactInitials = phoneContact.name.contactInitials(), + phoneNumber = phoneContact.normalisedPhoneNumber, + vpa = "", + ) + } else { + Image( + modifier = Modifier.size(40.dp), + painter = painterResource(id = R.drawable.ic_payee_default), + contentDescription = null, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + NaviText( + text = phoneContact.name.ifBlank { stringResource(R.string.np_make_payment_to) }, + fontSize = 14.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + color = NaviPayColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + NaviText( + text = phoneContact.normalisedPhoneNumber, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + color = NaviPayColor.textTertiary, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Column(modifier = Modifier.width(24.dp)) { + if (clickedContactPhoneNumber == phoneContact.phoneNumber && showLoader) { + NaviPayLottieAnimation( + lottieFileName = NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE, + modifier = Modifier, + ) + } + } + } +} + +@Composable +fun FrequentOrdersSearchView( + index: Int, + frequentOrderEntity: FrequentOrderEntityV2, + onFrequentOrderSelected: (FrequentOrderEntityV2) -> Unit, + showLoader: Boolean, + clickedContactPhoneNumber: String, +) { + + if (frequentOrderEntity.orderType != UpiTransactionType.SELF_PAY.name) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .clickable( + onClick = { + if (showLoader.not()) onFrequentOrderSelected(frequentOrderEntity) + } + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + ContactIconView( + index = index, + contactInitials = frequentOrderEntity.payeeInfo?.name.orEmpty().contactInitials(), + phoneNumber = + getNormalisedPhoneNumber(phoneNumber = frequentOrderEntity.payeeInfo?.mobNo), + vpa = frequentOrderEntity.payeeInfo?.vpa.orEmpty(), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + NaviText( + text = frequentOrderEntity.payeeInfo?.name.orEmpty(), + fontSize = 14.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + color = NaviPayColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + NaviText( + text = frequentOrderItemDetail(frequentOrderEntity), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + color = NaviPayColor.textTertiary, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Column(modifier = Modifier.width(24.dp)) { + if (clickedContactPhoneNumber == frequentOrderEntity.payeeInfo?.vpa && showLoader) { + NaviPayLottieAnimation( + lottieFileName = NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE, + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +fun FrequentOrdersItemView( + index: Int, + frequentOrderEntity: FrequentOrderEntityV2, + onFrequentOrderSelected: (FrequentOrderEntityV2) -> Unit, + showLoader: Boolean, + clickedContactPhoneNumber: String, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.width(74.dp) + .height(IntrinsicSize.Min) + .clickable( + onClick = { if (showLoader.not()) onFrequentOrderSelected(frequentOrderEntity) } + ) + .padding(top = 12.dp, start = 2.dp, end = 2.dp), + ) { + ContactIconView( + index = index, + contactInitials = frequentOrderEntity.payeeInfo?.name.orEmpty().contactInitials(), + phoneNumber = + getNormalisedPhoneNumber(phoneNumber = frequentOrderEntity.payeeInfo?.mobNo), + vpa = frequentOrderEntity.payeeInfo?.vpa.orEmpty(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (clickedContactPhoneNumber == frequentOrderEntity.payeeInfo?.vpa && showLoader) { + NaviPayLottieAnimation( + modifier = Modifier.size(24.dp), + lottieFileName = NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE, + ) + } else { + NaviText( + modifier = Modifier.padding(bottom = 8.dp), + text = frequentOrderEntity.payeeInfo?.name.orEmpty(), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + color = NaviPayColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + lineHeight = 18.sp, + ) + } + } +} + +@Composable +fun FrequentTransactionItemView( + index: Int, + frequentOrderEntity: FrequentOrderEntityV2, + onFrequentOrderSelected: (FrequentOrderEntityV2) -> Unit, + showLoader: Boolean, + clickedContactPhoneNumber: String, +) { + + val transactionInfo = remember { + if (frequentOrderEntity.orderType == OrderType.RECEIVE_MONEY.name) + frequentOrderEntity.payerInfo + else frequentOrderEntity.payeeInfo + } + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable( + onClick = { if (showLoader.not()) onFrequentOrderSelected(frequentOrderEntity) } + ) + ) { + ContactIconView( + index = index, + contactInitials = transactionInfo?.name.orEmpty().contactInitials(), + phoneNumber = + getNormalisedPhoneNumber(phoneNumber = frequentOrderEntity.payeeInfo?.mobNo), + vpa = transactionInfo?.vpa.orEmpty(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + Column( + horizontalAlignment = Alignment.Start, + modifier = + Modifier.wrapContentWidth().height(IntrinsicSize.Min).padding(horizontal = 16.dp), + ) { + NaviText( + text = transactionInfo?.name.orEmpty(), + fontSize = 14.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + color = NaviPayColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + lineHeight = 18.sp, + ) + + FrequentContactDescriptionView(frequentOrderEntity) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (clickedContactPhoneNumber == transactionInfo?.vpa.orEmpty() && showLoader) { + NaviPayLottieAnimation( + modifier = Modifier.size(24.dp), + lottieFileName = NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE, + ) + } + } +} + +private fun frequentOrderItemDetail(frequentOrderEntity: FrequentOrderEntityV2) = + if (frequentOrderEntity.payeeInfo?.mobNo.orEmpty().isNotEmpty()) { + getNormalisedPhoneNumber(phoneNumber = frequentOrderEntity.payeeInfo?.mobNo) + } else { + frequentOrderEntity.orderDescription + } + +@Composable +fun PayToContactsPermissionViewV2(onPrimaryButtonClicked: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) { + NaviText( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(id = R.string.your_contacts), + fontFamily = naviFontFamily, + fontSize = 16.sp, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviPayColor.textPrimary, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .background( + color = NaviPayColor.bgNonEditable, + shape = RoundedCornerShape(size = 4.dp), + ) + .border( + width = 1.dp, + color = NaviPayColor.borderDefault, + shape = RoundedCornerShape(size = 4.dp), + ) + .noRippleClickable { onPrimaryButtonClicked() } + ) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(id = CommonR.drawable.ic_phone_book_permission), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + NaviText( + text = stringResource(id = R.string.np_contact_permission_title), + color = NaviPayColor.textPrimary, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Image( + modifier = Modifier.size(24.dp).align(Alignment.CenterVertically), + painter = painterResource(id = widgetsR.drawable.chevron_icon_black), + contentDescription = null, + ) + } + } + } +} + +@Composable +private fun PayToContactsBottomSheetContent( + bottomSheetUIState: PayToContactsBottomSheetUIState?, + onDismissBottomSheet: () -> Unit, +) { + when (bottomSheetUIState) { + PayToContactsBottomSheetUIState.InvalidVpa -> { + BottomSheetContentWithIconHeaderDescButton( + iconId = CommonR.drawable.ic_exclamation_red_border, + headerTextId = R.string.np_saved_beneficiary_invalid_vpa_header, + descriptionTextId = R.string.np_invalid_vpa_pay_to_contact, + buttonTextId = R.string.np_okay_got_it, + onButtonClicked = onDismissBottomSheet, + ) + } + PayToContactsBottomSheetUIState.ContactNotLinked -> { + BottomSheetContentWithIconHeaderDescButton( + iconId = CommonR.drawable.ic_exclamation_red_border, + headerTextId = R.string.np_phone_contact_not_linked_header, + descriptionTextId = R.string.np_phone_contact_not_linked_description, + buttonTextId = R.string.np_okay_got_it, + onButtonClicked = onDismissBottomSheet, + ) + } + else -> {} + } +} + +@Composable +private fun FrequentContactDescriptionView(frequentOrderEntity: FrequentOrderEntityV2) { + + Row(modifier = Modifier.wrapContentWidth()) { + NaviText( + text = + stringResource( + R.string.rupee_symbol_x, + frequentOrderEntity.paymentAmount.getDisplayableAmount(), + ), + color = + if (frequentOrderEntity.orderType == OrderType.RECEIVE_MONEY.name) + NaviPayColor.onSurfaceHighlight + else NaviPayColor.textTertiary, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + ) + + Spacer(modifier = Modifier.width(4.dp)) + + NaviText( + text = BULLET, + color = NaviPayColor.textTertiary, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + ) + + Spacer(modifier = Modifier.width(4.dp)) + + NaviText( + text = frequentOrderEntity.formattedTimestampText, + color = NaviPayColor.textTertiary, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + ) + } +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/viewmodel/PayToContactsViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/viewmodel/PayToContactsViewModel.kt index a6de55bd8b..41f56266bf 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/viewmodel/PayToContactsViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/viewmodel/PayToContactsViewModel.kt @@ -8,7 +8,9 @@ package com.navi.pay.management.paytocontacts.viewmodel import androidx.lifecycle.viewModelScope +import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import com.navi.base.cache.repository.NaviCacheRepository import com.navi.base.model.CtaData import com.navi.base.utils.QA import com.navi.base.utils.ResourceProvider @@ -18,6 +20,7 @@ import com.navi.common.constants.EMPTY import com.navi.common.extensions.removeSpaces import com.navi.common.network.models.RepoResult import com.navi.common.network.models.isSuccessWithData +import com.navi.common.usecase.LitmusExperimentsUseCase import com.navi.pay.BuildConfig import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics.Companion.NAVI_PAY_SEND_MONEY_TO_CONTACTS_SCREEN_V2 @@ -48,6 +51,7 @@ import com.navi.pay.management.paytocontacts.PhoneContactManager import com.navi.pay.management.paytocontacts.model.network.PayToContactRequest import com.navi.pay.management.paytocontacts.model.view.PayToContactsBottomSheetStateHolder import com.navi.pay.management.paytocontacts.model.view.PhoneContactEntity +import com.navi.pay.network.di.NaviPayGsonBuilder import com.navi.pay.tstore.list.model.view.FrequentOrderEntity import com.navi.pay.utils.DEFAULT_CONFIG import com.navi.pay.utils.FUNNEL_STEP @@ -94,6 +98,9 @@ constructor( private val validateVpaUseCase: ValidateVpaUseCase, private val naviPayActivityDataProvider: NaviPayActivityDataProvider, private val naviPayAnalytics: NaviPayToContacts, + private val litmusExperimentsUseCase: LitmusExperimentsUseCase, + @NaviPayGsonBuilder private val gsonBuilder: Gson, + private val naviCacheRepository: NaviCacheRepository, ) : NaviPayBaseVM() { private var naviPayDefaultConfig = NaviPayDefaultConfig() diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/viewmodel/PayToContactsViewModelV2.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/viewmodel/PayToContactsViewModelV2.kt new file mode 100644 index 0000000000..9cdb5e2539 --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/viewmodel/PayToContactsViewModelV2.kt @@ -0,0 +1,817 @@ +/* + * + * * Copyright © 2024-2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.management.paytocontacts.viewmodel + +import androidx.lifecycle.viewModelScope +import com.google.gson.reflect.TypeToken +import com.navi.base.model.CtaData +import com.navi.base.utils.QA +import com.navi.base.utils.ResourceProvider +import com.navi.base.utils.orFalse +import com.navi.base.utils.orTrue +import com.navi.common.constants.EMPTY +import com.navi.common.extensions.removeSpaces +import com.navi.common.network.models.RepoResult +import com.navi.common.network.models.isSuccessWithData +import com.navi.pay.BuildConfig +import com.navi.pay.R +import com.navi.pay.analytics.NaviPayAnalytics.Companion.NAVI_PAY_SEND_MONEY_TO_CONTACTS_SCREEN_V2 +import com.navi.pay.analytics.NaviPayToContacts +import com.navi.pay.common.connectivity.NaviPayNetworkConnectivity +import com.navi.pay.common.model.config.NaviPayDefaultConfig +import com.navi.pay.common.model.network.ValidateVpaRequest +import com.navi.pay.common.model.network.ValidateVpaResponse +import com.navi.pay.common.model.view.NaviPaySessionHelper +import com.navi.pay.common.model.view.NaviPermissionResult +import com.navi.pay.common.usecase.NaviPayConfigUseCase +import com.navi.pay.common.usecase.ValidateVpaUseCase +import com.navi.pay.common.utils.FrequentOrdersHelperV2 +import com.navi.pay.common.utils.NaviPayCommonUtils.getHelpCtaData +import com.navi.pay.common.utils.NaviPayCommonUtils.getNormalisedPhoneNumber +import com.navi.pay.common.utils.NaviPayCommonUtils.getValidPhoneNumberOrEmpty +import com.navi.pay.common.utils.fetchUserPhoneNumber +import com.navi.pay.common.viewmodel.NaviPayBaseVM +import com.navi.pay.compose.destinations.DevModeScreenDestination +import com.navi.pay.compose.destinations.LinkedAccountsScreenDestination +import com.navi.pay.compose.destinations.SendMoneyScreenDestination +import com.navi.pay.entry.NaviPayActivityDataProvider +import com.navi.pay.management.common.model.view.WarningErrorInfoState +import com.navi.pay.management.common.sendmoney.model.network.TransactionInitiationType +import com.navi.pay.management.common.sendmoney.model.network.getTransactionInitiationType +import com.navi.pay.management.common.sendmoney.model.view.PayeeEntity +import com.navi.pay.management.common.sendmoney.model.view.PayeeSeverity +import com.navi.pay.management.common.sendmoney.model.view.SendMoneyScreenSource +import com.navi.pay.management.common.sendmoney.model.view.UpiTransactionType +import com.navi.pay.management.paytocontacts.PhoneContactManager +import com.navi.pay.management.paytocontacts.model.network.PayToContactRequest +import com.navi.pay.management.paytocontacts.model.view.PayToContactsBottomSheetStateHolder +import com.navi.pay.management.paytocontacts.model.view.PhoneContactEntity +import com.navi.pay.tstore.list.model.network.OrderType +import com.navi.pay.tstore.list.model.view.FrequentOrderEntityV2 +import com.navi.pay.utils.DEFAULT_CONFIG +import com.navi.pay.utils.FUNNEL_STEP +import com.navi.pay.utils.INDIA_COUNTRY_CODE_WITH_PLUS +import com.navi.pay.utils.INVALID_VPA +import com.navi.pay.utils.NAVI_PAY_SEARCH_QUERY_API_DELAY +import com.navi.pay.utils.NAVI_PAY_WIDGET_CLICKED_KEY +import com.navi.pay.utils.NOT_LINKED_TO_UPI +import com.navi.pay.utils.PHONE_NUMBER_LENGTH +import com.navi.pay.utils.isAsciiDigit +import com.navi.pay.utils.isValidPhoneNumberLength +import com.navi.pay.utils.isValidSearchQuery +import com.navi.pay.utils.phoneNumberWithoutCountryCode +import com.ramcosta.composedestinations.spec.Direction +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel +class PayToContactsViewModelV2 +@Inject +constructor( + private val contactManager: PhoneContactManager, + private val naviPayNetworkConnectivity: NaviPayNetworkConnectivity, + private val naviPayConfigUseCase: NaviPayConfigUseCase, + private val naviPaySessionHelper: NaviPaySessionHelper, + private val frequentOrdersHelper: FrequentOrdersHelperV2, + private val resourceProvider: ResourceProvider, + private val validateVpaUseCase: ValidateVpaUseCase, + private val naviPayActivityDataProvider: NaviPayActivityDataProvider, + private val naviPayAnalytics: NaviPayToContacts, +) : NaviPayBaseVM() { + + private var naviPayDefaultConfig = NaviPayDefaultConfig() + + private val _uiState = MutableStateFlow(PayToContactsUIState.Loading) + val uiState = _uiState.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery = _searchQuery.asStateFlow() + + private val _shouldShowYourContactsTitle = MutableStateFlow(true) + val shouldShowYourContactsTitle = _shouldShowYourContactsTitle.asStateFlow() + + private val _showLoader = MutableStateFlow(false) + val showLoader = _showLoader.asStateFlow() + + private val _showShimmer = MutableStateFlow(false) + val showShimmer = _showShimmer.asStateFlow() + + private val _isWarningOrErrorState = + MutableStateFlow(WarningErrorInfoState(isWarningState = false, isErrorState = false)) + val isWarningOrErrorState = _isWarningOrErrorState.asStateFlow() + + private val _newContact = MutableStateFlow(null) + val newContact = _newContact.asStateFlow() + + private val _hideKeyboard = MutableStateFlow(false) + val hideKeyboard = _hideKeyboard.asStateFlow() + + private val _activeQueryContactNumber = MutableStateFlow("") + val activeQueryContactNumber = _activeQueryContactNumber.asStateFlow() + + private val frequentOrders = MutableStateFlow>(emptyList()) + + private val _navigateToNextScreen = MutableSharedFlow() + val navigateToNextScreen = _navigateToNextScreen.asSharedFlow() + + private val _apiErrorMessage = MutableSharedFlow() + val apiErrorMessage = _apiErrorMessage.asSharedFlow() + + private val _isSavedContactListNonEmpty = MutableStateFlow(true) + val isSavedContactListNonEmpty = _isSavedContactListNonEmpty.asStateFlow() + + private val _isNewContactVisible = MutableStateFlow(false) + val isNewContactVisible = _isNewContactVisible.asStateFlow() + + private val _permissionResult = + MutableStateFlow(NaviPermissionResult.None) + val permissionResult = _permissionResult.asStateFlow() + + val showPermissionWidget = + permissionResult + .map { permissionResult != NaviPermissionResult.None } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + private val _navigateToNextScreenFromHelpCta = MutableSharedFlow() + val navigateToNextScreenFromHelpCta = _navigateToNextScreenFromHelpCta.asSharedFlow() + + private val _isSelfTransferCtaVisible = MutableStateFlow(false) + val isSelfTransferCtaVisible = _isSelfTransferCtaVisible.asStateFlow() + + private val _bottomSheetStateHolder = + MutableStateFlow( + PayToContactsBottomSheetStateHolder( + showBottomSheet = false, + bottomSheetStateChange = false, + bottomSheetUIState = null, + ) + ) + val bottomSheetStateHolder = _bottomSheetStateHolder.asStateFlow() + + // TODO: Make it private after using proper testing framework + val allContactList = MutableStateFlow>(emptyList()) + + private val frequentOrdersTotalEntries = + MutableStateFlow(naviPayDefaultConfig.config.frequentTransactionsTotalEntries) + + private val _frequentOrdersHeading = + MutableStateFlow(naviPayDefaultConfig.config.frequentTransactionHeading) + val frequentOrdersHeading = _frequentOrdersHeading.asStateFlow() + + private var apiCallJob: Job? = null + + private var paymentJob: Job? = null + + private var newContactResponse: RepoResult? = null + + private var lastSearchQuery = "" + + var isPermissionPopupSeenOnLanded = false + + var isPermissionLaunchedFromAllowClick = false + + private var isContactPermissionGranted = false + + private val helpCtaData = getHelpCtaData(screenName = screenName) + + val dropOffFunnelStep = naviPayActivityDataProvider.getString(FUNNEL_STEP) + + val userPhoneNumber = fetchUserPhoneNumber() + + val filteredContactList = + combine(searchQuery, allContactList) { searchQuery, allContactList -> + apiCallJob?.cancel() + var filteredContacts = + allContactList.filter { contact -> + contact.name.contains(searchQuery, ignoreCase = true) || + phoneNumberContainSearchQuery( + phoneNumber = contact.normalisedPhoneNumber, + searchQuery = searchQuery, + ) + } + + if (searchQuery == lastSearchQuery) { + return@combine filteredContacts + } + lastSearchQuery = searchQuery + + updateSelfTransferCtaVisibility(false) + + if (searchQuery.isNotEmpty()) { + val filteredContactsListSizeBefore = filteredContacts.size + filteredContacts = + filteredContacts.filter { contact -> + contact.phoneNumber.removeSpaces().contains(userPhoneNumber).not() + } + val filteredContactsListSizeAfter = filteredContacts.size + if ( + filteredContactsListSizeBefore != filteredContactsListSizeAfter || + userPhoneNumber.contains(searchQuery.phoneNumberWithoutCountryCode()) + ) { + updateSelfTransferCtaVisibility(true) + } + } + filteredContacts + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = allContactList.value, + ) + + val filteredFrequentOrdersList = + combine(searchQuery, frequentOrders) { searchQuery, frequentOrders -> + var filteredFrequentOrder = + frequentOrders.filter { frequentOrder -> + val payeeInfo = frequentOrder.payeeInfo ?: return@filter false + (payeeInfo.name.orEmpty().contains(searchQuery, ignoreCase = true) || + phoneNumberContainSearchQuery( + phoneNumber = payeeInfo.mobNo.orEmpty(), + searchQuery = searchQuery, + )) + } + filteredFrequentOrder + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList(), + ) + + val isEmptyState = + combine( + searchQuery, + isNewContactVisible, + filteredContactList, + isWarningOrErrorState, + filteredFrequentOrdersList, + ) { + searchQuery, + isNewContactVisible, + filteredContactList, + invalidInfoState, + filteredFrequentOrdersList -> + updateShouldShowYourContactsTitle() + filteredContactList.isEmpty() && + filteredFrequentOrdersList.isEmpty() && + searchQuery.removeSpaces().isNotEmpty() && + !isNewContactVisible && + !invalidInfoState.isWarningState && + !userPhoneNumber.contains(searchQuery.phoneNumberWithoutCountryCode()) + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + init { + viewModelScope.launch(context = Dispatchers.IO) { + Timber.tag("test").d("init viewModel") + initializeSynchronously() + initializeAsynchronously() + } + } + + private suspend fun initializeSynchronously() { + updateNaviPaySessionId() + updateNaviPayDefaultConfig() + } + + private suspend fun initializeAsynchronously() { + coroutineScope { + launch { triggerEventForShortcutWidget() } + launch { fetchFrequentOrders() } + launch { observeAndHandleSearchResults() } + } + } + + private fun triggerEventForShortcutWidget() { + naviPayActivityDataProvider.getString(NAVI_PAY_WIDGET_CLICKED_KEY)?.let { + naviPayAnalytics.onSendToContactShortcutClicked( + eventName = it, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + } + } + + private suspend fun observeAndHandleSearchResults() { + combine(searchQuery, filteredContactList, filteredFrequentOrdersList) { + searchQuery, + filteredContactList, + filteredFrequentOrdersList -> + Triple(searchQuery, filteredContactList, filteredFrequentOrdersList) + } + .collectLatest { (searchQuery, filteredContactList, filteredFrequentOrdersList) -> + if ( + userPhoneNumber.contains(searchQuery.phoneNumberWithoutCountryCode()) && + searchQuery.isNotEmpty() + ) { + updateSelfTransferCtaVisibility(true) + } else { + updateSelfTransferCtaVisibility(false) + } + updateShouldShowYourContactsTitle() + updateIsWarningOrErrorState( + isError = false, + isWarning = + filteredContactList.isEmpty() && + filteredFrequentOrdersList.isEmpty() && + searchQuery.removeSpaces().isValidSearchQuery() && + searchQuery.removeSpaces().isValidPhoneNumberLength().not() && + !userPhoneNumber.contains(searchQuery.phoneNumberWithoutCountryCode()), + warningMessage = + resourceProvider.getString(R.string.np_please_enter_valid_number), + ) + + _isNewContactVisible.update { + filteredContactList.isEmpty() && + filteredFrequentOrdersList.isEmpty() && + searchQuery.removeSpaces().isValidSearchQuery() && + searchQuery.removeSpaces().isValidPhoneNumberLength() && + !userPhoneNumber.contains(searchQuery.phoneNumberWithoutCountryCode()) + } + + if (isNewContactVisible.value) { + updateShimmerState(showShimmer = true) + updateNewContact(newContactEntity = null) + fetchNewContactDetails(searchQuery) + } else { + updateShimmerState(showShimmer = false) + } + } + } + + private suspend fun updateNaviPayDefaultConfig() { + naviPayDefaultConfig = + naviPayConfigUseCase.execute( + configKey = DEFAULT_CONFIG, + type = object : TypeToken() {}.type, + ) ?: NaviPayDefaultConfig() + + updateFrequentOrderTotalEntries( + naviPayDefaultConfig.config.frequentTransactionsTotalEntries + ) + updateFrequentOrderHeading(naviPayDefaultConfig.config.frequentTransactionHeading) + } + + private suspend fun updateNaviPaySessionId() { + naviPaySessionHelper.createNewSessionId() + } + + fun getNaviPaySessionAttributes(): Map { + return naviPaySessionHelper.getNaviPaySessionAttributes() + } + + private fun updateFrequentOrderTotalEntries(totalEntries: Int) { + frequentOrdersTotalEntries.update { totalEntries } + } + + private fun updateFrequentOrderHeading(heading: String) { + _frequentOrdersHeading.update { heading } + } + + fun updateSearchQueryStringState(searchQuery: String = "") { + if (searchQuery.length <= naviPayDefaultConfig.config.contactSearchQueryMaxLength) { + _searchQuery.update { searchQuery } + } + } + + private fun updateUiState(uiState: PayToContactsUIState) { + _uiState.update { uiState } + } + + fun updateHideKeyboard(hideKeyboard: Boolean) { + _hideKeyboard.update { hideKeyboard } + } + + private fun updateShimmerState(showShimmer: Boolean) { + _showShimmer.update { showShimmer } + } + + private fun updateActiveQueryContactNumber(phoneNumber: String) { + _activeQueryContactNumber.update { phoneNumber } + } + + fun cancelPaymentRequest() { + updateShowLoader(showLoader = false) + paymentJob?.cancel() + updateShimmerState(showShimmer = false) + } + + fun onPermissionPopupSeenOnLanded() { + isPermissionPopupSeenOnLanded = true + } + + fun onPermissionLaunchedFromAllowClick(isFromAllowClick: Boolean) { + isPermissionLaunchedFromAllowClick = isFromAllowClick + } + + fun updatePermissionResult(permissionResult: NaviPermissionResult) { + _permissionResult.update { permissionResult } + } + + private fun updateIsWarningOrErrorState( + isError: Boolean? = null, + isWarning: Boolean? = null, + warningMessage: String? = null, + errorMessage: String? = null, + ) { + _isWarningOrErrorState.update { + it.copy( + isErrorState = isError ?: it.isErrorState, + isWarningState = isWarning ?: it.isWarningState, + warningMessage = warningMessage ?: it.warningMessage, + errorMessage = errorMessage ?: it.errorMessage, + ) + } + } + + private fun updateShouldShowYourContactsTitle() { + _shouldShowYourContactsTitle.update { + (isNewContactVisible.value.not() && + !(searchQuery.value.isNotEmpty() && filteredContactList.value.isEmpty())) || + isSelfTransferCtaVisible.value + } + } + + private fun updateNewContact(newContactEntity: PhoneContactEntity?) { + _newContact.update { newContactEntity } + } + + fun updateBottomSheetUIState( + showBottomSheet: Boolean, + bottomSheetStateChange: Boolean? = null, + bottomSheetUIState: PayToContactsBottomSheetUIState? = null, + ) { + _bottomSheetStateHolder.update { + it.copy( + showBottomSheet = showBottomSheet, + bottomSheetStateChange = bottomSheetStateChange ?: it.bottomSheetStateChange, + bottomSheetUIState = bottomSheetUIState ?: it.bottomSheetUIState, + ) + } + } + + private suspend fun updateNextScreenDestinationState(direction: Direction) { + _navigateToNextScreen.emit(direction) + } + + fun initiatePaymentForNewContact() { + if (isNewContactVisible.value) { + initiatePaymentToContact( + phoneNumber = searchQuery.value, + newContactResponse = newContactResponse, + isNewContact = true, + ) + } + } + + fun initiatePaymentToFrequentOrder(frequentOrderEntity: FrequentOrderEntityV2) { + val transactionInfo = + if (frequentOrderEntity.orderType == OrderType.RECEIVE_MONEY.name) + frequentOrderEntity.payerInfo + else frequentOrderEntity.payeeInfo + paymentJob = + viewModelScope.launch(Dispatchers.IO) { + if (!naviPayNetworkConnectivity.isInternetConnected()) { + updateHideKeyboard(hideKeyboard = true) + notifyError(getNoInternetErrorConfig()) + return@launch + } + + updateShowLoader(showLoader = true) + if (frequentOrderEntity.orderType == UpiTransactionType.SELF_PAY.name) { + redirectToSelfTransferScreen() + } else { + updateActiveQueryContactNumber(phoneNumber = transactionInfo?.vpa.orEmpty()) + + val vpaToBeValidated = transactionInfo?.vpa.orEmpty() + + val response = + validateVpaUseCase.execute( + request = ValidateVpaRequest(vpa = vpaToBeValidated), + screenName = screenName, + ) + + // Temporary fix for mobile number + val payeeVpaToDisplay = + getValidPhoneNumberOrEmpty(mobNo = frequentOrderEntity.payeeInfo?.mobNo) + .ifBlank { frequentOrderEntity.orderDescription } + + if (response == null || response.isSuccessWithData()) { + handleAPIResponseSuccessAndUpdateNextDestination( + validateVpaResponseData = response?.data, + phoneNumber = transactionInfo?.mobNo.orEmpty(), + vpa = vpaToBeValidated, + payeeVpaToDisplay = payeeVpaToDisplay, + transactionInitiationType = + getTransactionInitiationType( + transactionInitiationType = frequentOrderEntity.paymentMode + ), + ) + } else { + handleApiResponseErrorsForInitiatingPayment(response = response) + } + } + } + } + + fun initiatePaymentToContact( + phoneNumber: String, + newContactResponse: RepoResult? = null, + isNewContact: Boolean = false, + ) { + paymentJob = + viewModelScope.launch(Dispatchers.IO) { + if (!naviPayNetworkConnectivity.isInternetConnected()) { + updateHideKeyboard(hideKeyboard = true) + notifyError(getNoInternetErrorConfig()) + return@launch + } + + val response = + newContactResponse + ?: run { + updateShowLoader(showLoader = true) + updateActiveQueryContactNumber(phoneNumber = phoneNumber) + validateVpaUseCase.execute( + request = PayToContactRequest(payeeMobileNumber = phoneNumber), + screenName = screenName, + ) + } + if (response == null || response.isSuccessWithData()) { + handleAPIResponseSuccessAndUpdateNextDestination( + validateVpaResponseData = response?.data, + phoneNumber = phoneNumber, + payeeVpaToDisplay = getNormalisedPhoneNumber(phoneNumber), + transactionInitiationType = TransactionInitiationType.PAY_TO_CONTACT, + isNewContact = isNewContact, + ) + } else { + handleApiResponseErrorsForInitiatingPayment(response = response) + } + } + } + + private suspend fun handleApiResponseErrorsForInitiatingPayment( + response: RepoResult + ) { + updateShowLoader(showLoader = false) + updateActiveQueryContactNumber(phoneNumber = "") + updateHideKeyboard(hideKeyboard = true) + val error = getError(response) + naviPayAnalytics.onVpaVerificationFailure( + errorMessage = error.title, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + when (response.errors?.getOrNull(index = 0)?.code) { + INVALID_VPA -> { + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = true, + bottomSheetUIState = PayToContactsBottomSheetUIState.InvalidVpa, + ) + } + NOT_LINKED_TO_UPI -> { + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = true, + bottomSheetUIState = PayToContactsBottomSheetUIState.ContactNotLinked, + ) + } + else -> { + notifyError(response = response) + } + } + } + + private suspend fun handleAPIResponseSuccessAndUpdateNextDestination( + validateVpaResponseData: ValidateVpaResponse?, + phoneNumber: String, + vpa: String = "", + payeeVpaToDisplay: String, + transactionInitiationType: TransactionInitiationType, + isNewContact: Boolean = false, + ) { + + val payeeEntity = + PayeeEntity( + externalId = validateVpaResponseData?.saExternalCustomerId, + name = + validateVpaResponseData?.name.orEmpty().ifBlank { + resourceProvider.getString(R.string.np_make_payment_to) + }, + vpa = validateVpaResponseData?.vpa ?: vpa, + isMerchant = validateVpaResponseData?.isMerchant.orFalse(), + isVerifiedVpa = validateVpaResponseData != null, + mcc = validateVpaResponseData?.mcc, + severity = + validateVpaResponseData?.riskParams?.severity ?: PayeeSeverity.NO_RISK.name, + bankCode = validateVpaResponseData?.bankCode, + bankIfsc = validateVpaResponseData?.ifsc, + phoneNumber = phoneNumber, + upiNumber = getUpiNumber(validateVpaResponseData, phoneNumber), + isNpciData = validateVpaResponseData?.isNpciData.orFalse(), + featureTags = validateVpaResponseData?.featureTags, + merchantCustomerId = validateVpaResponseData?.merchantCustomerId, + maskedAccountNumber = validateVpaResponseData?.maskedAccountNumber, + bankName = validateVpaResponseData?.bankName, + accountType = validateVpaResponseData?.bankAccountType, + bankAccountUniqueId = validateVpaResponseData?.bankAccountUniqueId, + ) + val sendMoneyScreenSource = + SendMoneyScreenSource.PhoneContact( + payeeVpaToDisplay = payeeVpaToDisplay, + transactionInitiationMode = transactionInitiationType, + isNumberPresentInContactsOrTransactedBefore = !isNewContact, + isContactPermissionGranted = isContactPermissionGranted, + ) + naviPayActivityDataProvider.setSendMoneyScreenData( + payeeEntity = payeeEntity, + sendMoneyScreenSource = sendMoneyScreenSource, + ) + updateNextScreenDestinationState(SendMoneyScreenDestination()) + updateShowLoader(showLoader = false) + updateActiveQueryContactNumber(phoneNumber = "") + updateSearchQueryStringState(searchQuery = EMPTY) + } + + private suspend fun fetchFrequentOrders() { + val getFrequentOrderList = + frequentOrdersHelper.getFrequentOrderList( + frequentOrdersTotalEntries = frequentOrdersTotalEntries.value + ) + updateFrequentOrderEntityList(getFrequentOrderList) + + naviPayAnalytics.onSendToContactsLoaded( + naviPaySessionAttributes = getNaviPaySessionAttributes(), + isPermissionGranted = true, + isContactListEmpty = allContactList.value.isEmpty(), + isFrequentOrderListEmpty = frequentOrders.value.isEmpty(), + ) + updateUiState(uiState = PayToContactsUIState.Loaded) + } + + fun fetchContacts() { + viewModelScope.launch(Dispatchers.IO) { + val savedContactListJob = async { contactManager.getPhoneContacts() } + allContactList.update { savedContactListJob.await() } + _isSavedContactListNonEmpty.update { allContactList.value.isNotEmpty() } + } + } + + private fun updateFrequentOrderEntityList( + frequentOrderEntityList: List + ) { + // Initialize formattedTimestampText for each entity before updating the list + frequentOrderEntityList.forEach { it.formattedTimestampText } + frequentOrders.update { frequentOrderEntityList } + } + + private fun updateShowLoader(showLoader: Boolean) { + _showLoader.update { showLoader } + } + + private fun updateSelfTransferCtaVisibility(isVisible: Boolean) { + _isSelfTransferCtaVisible.update { isVisible } + } + + private fun fetchNewContactDetails(phoneNumber: String) { + apiCallJob = + viewModelScope.launch(Dispatchers.IO) { + if (!naviPayNetworkConnectivity.isInternetConnected()) { + updateShimmerState(showShimmer = false) + updateHideKeyboard(hideKeyboard = true) + notifyError(getNoInternetErrorConfig()) + return@launch + } + delay(NAVI_PAY_SEARCH_QUERY_API_DELAY) + validateVpaUseCase + .execute( + request = PayToContactRequest(payeeMobileNumber = phoneNumber), + screenName = screenName, + ) + .also { newContactResponse = it } + updateShimmerState(showShimmer = false) + if (newContactResponse?.isSuccessWithData().orTrue()) { + handleAPIResultSuccessForNewContact(phoneNumber = phoneNumber) + } else { + handleAPIResponseErrorsForNewContact(phoneNumber = phoneNumber) + } + } + } + + private suspend fun handleAPIResponseErrorsForNewContact(phoneNumber: String) { + if (newContactResponse == null) { + notifyError() + return + } + if (newContactResponse?.errors?.getOrNull(0)?.code == NOT_LINKED_TO_UPI) { + updateIsWarningOrErrorState( + isError = phoneNumber == searchQuery.value, + errorMessage = resourceProvider.getString(R.string.np_not_linked_to_upi_error), + ) + } else if (phoneNumber == searchQuery.value) { + updateHideKeyboard(hideKeyboard = true) + notifyError(newContactResponse!!) + newContactResponse?.errors?.firstOrNull()?.message?.let { errorMessage -> + _apiErrorMessage.emit(errorMessage) + } + } + } + + private fun handleAPIResultSuccessForNewContact(phoneNumber: String) { + if (newContactResponse?.data?.isMerchant.orFalse()) { + updateIsWarningOrErrorState( + isError = phoneNumber == searchQuery.value, + errorMessage = resourceProvider.getString(R.string.merchant_vpa_send_money_error), + ) + } else { + updateNewContact( + PhoneContactEntity( + name = newContactResponse?.data?.name.orEmpty(), + phoneNumber = phoneNumber, + normalisedPhoneNumber = phoneNumber, + ) + ) + } + } + + private fun phoneNumberContainSearchQuery(phoneNumber: String, searchQuery: String): Boolean { + val processedContactNumber = + INDIA_COUNTRY_CODE_WITH_PLUS + getNormalisedPhoneNumber(phoneNumber) + val processedSearchQuery = searchQuery.removeSpaces().trimStart('0') + return processedContactNumber.contains(processedSearchQuery) + } + + fun onHelpCtaClicked() { + viewModelScope.launch(Dispatchers.Default) { + if (BuildConfig.FLAVOR == QA && BuildConfig.DEBUG) { + _navigateToNextScreen.emit(DevModeScreenDestination) + } else { + _navigateToNextScreenFromHelpCta.emit(helpCtaData) + } + } + } + + fun updateContactPermissionStatus(isContactPermissionGranted: Boolean) { + this.isContactPermissionGranted = isContactPermissionGranted + } + + private fun getUpiNumber( + validateVpaResponseData: ValidateVpaResponse?, + phoneNumber: String, + ): String? { + return validateVpaResponseData?.upiNumber.orEmpty().ifBlank { + if (validateVpaResponseData?.isUpiNumber.orFalse()) + phoneNumber.filter { it.isAsciiDigit() }.takeLast(PHONE_NUMBER_LENGTH) + else null + } + } + + fun redirectToSelfTransferScreen() { + viewModelScope.launch(Dispatchers.IO) { + updateShowLoader(showLoader = true) + updateNextScreenDestinationState( + LinkedAccountsScreenDestination(shouldNavigateUp = true) + ) + updateShowLoader(showLoader = false) + updateSearchQueryStringState(searchQuery = EMPTY) + } + } + + override val screenName: String + get() = NAVI_PAY_SEND_MONEY_TO_CONTACTS_SCREEN_V2 +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/dao/VpaDao.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/dao/VpaDao.kt index 327d25c76f..cee03dbd7b 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/dao/VpaDao.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/dao/VpaDao.kt @@ -51,4 +51,7 @@ interface VpaDao { @Query("SELECT * FROM $NAVI_PAY_DATABASE_VPA_TABLE_NAME") suspend fun getAllVpaEntities(): List + + @Query("SELECT vpa from $NAVI_PAY_DATABASE_VPA_TABLE_NAME") + suspend fun getAllVpa(): List } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/repository/AccountsRepository.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/repository/AccountsRepository.kt index 4a04b00a3e..54cd507504 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/repository/AccountsRepository.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/repository/AccountsRepository.kt @@ -314,4 +314,8 @@ constructor( suspend fun getName(): String? { return getAllAccounts().firstOrNull()?.name } + + suspend fun getAllVpa(): List { + return vpaDao.getAllVpa() + } } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/model/view/FrequentOrderEntityV2.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/model/view/FrequentOrderEntityV2.kt new file mode 100644 index 0000000000..c4f5950025 --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/model/view/FrequentOrderEntityV2.kt @@ -0,0 +1,83 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.tstore.list.model.view + +import com.navi.base.utils.DateUtils +import com.navi.base.utils.NaviDateFormatter.TWENTY_FOUR_HOURS_IN_MILLIS +import com.navi.base.utils.NaviDateFormatter.capitalizeMeridiem +import com.navi.base.utils.NaviDateFormatter.isDifferenceOneDay +import com.navi.base.utils.NaviDateFormatter.isSameDay +import com.navi.base.utils.TrustedTimeAccessor +import com.navi.pay.tstore.details.ui.upi.UserTxnInfo +import com.navi.pay.tstore.list.model.network.OrderType +import com.navi.pay.utils.DATE_TIME_FORMAT_DATE_MONTH_NAME_YEAR +import com.navi.pay.utils.parallelMap +import org.joda.time.DateTime + +data class FrequentOrderEntityV2( + val orderTitle: String, + val orderDescription: String, + val orderTimestamp: DateTime, + val payeeInfo: UserTxnInfo?, + val payerInfo: UserTxnInfo?, + val paymentMode: String?, + val paymentAmount: String, + val orderType: String, +) { + + val formattedTimestampText: String + get() { + val transactionTimeInMillis = orderTimestamp.millis + val difference = TrustedTimeAccessor.getCurrentTimeMillis() - transactionTimeInMillis + + val isSendMoney = orderType == OrderType.SEND_MONEY.name + val within24Hours = difference < TWENTY_FOUR_HOURS_IN_MILLIS + val isOneDayDifference = + difference < 2 * TWENTY_FOUR_HOURS_IN_MILLIS && isDifferenceOneDay(orderTimestamp) + + return when { + within24Hours && isSameDay(orderTimestamp) -> { + if (isSendMoney) "Paid today" else "Received today" + } + + (within24Hours && !isSameDay(orderTimestamp)) || isOneDayDifference -> { + if (isSendMoney) "Paid yesterday" else "Received yesterday" + } + + else -> { + val formattedDate = + DateUtils.getFormattedDateTimeAsStringFromDateTimeObject( + dateTime = orderTimestamp, + format = DATE_TIME_FORMAT_DATE_MONTH_NAME_YEAR, + ) + .capitalizeMeridiem() + + if (isSendMoney) { + "Paid on $formattedDate" + } else { + "Received on $formattedDate" + } + } + } + } +} + +suspend fun List.toFrequentOrderEntityListV2(): List { + return this.parallelMap { orderEntity -> + FrequentOrderEntityV2( + orderTitle = orderEntity.orderTitle, + orderDescription = orderEntity.orderDescription, + orderTimestamp = orderEntity.orderTimestamp, + payeeInfo = orderEntity.naviPayTransactionDetailsMetadata.payeeInfo, + payerInfo = orderEntity.naviPayTransactionDetailsMetadata.payerInfo, + paymentMode = orderEntity.naviPayTransactionDetailsMetadata.paymentMode, + paymentAmount = orderEntity.amount, + orderType = orderEntity.orderType, + ) + } +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/utils/NaviPayConstants.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/utils/NaviPayConstants.kt index 4475f80a74..14af7a97f7 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/utils/NaviPayConstants.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/utils/NaviPayConstants.kt @@ -176,6 +176,7 @@ const val NAVI_PAY_SIM_PROVIDER_CACHE_KEY = "naviPaySimProviderKey" const val NAVI_PAY_PACKAGE_NAME_CACHE_KEY = "naviPayPackageNameKey" const val NAVI_PAY_SSID_CACHE_KEY = "naviPaySsidKey" const val NAVI_PAY_FREQUENT_ORDER_CACHE_KEY = "naviPayFrequentOrderCacheKey" +const val NAVI_PAY_SUGGESTED_FREQUENT_ORDER_CACHE_KEY = "naviPaySuggestedFrequentOrderCacheKey" const val NAVI_PAY_FUNNEL_DROPOFF_RETRY_CACHE_KEY = "naviPayFunnelDropoffRetryCacheKey" const val NAVI_PAY_FUNNEL_STEP_CACHE_KEY = "naviPayFunnelStepCacheKey" @@ -209,6 +210,7 @@ const val LITMUS_EXPERIMENT_NAVIPAY_REVERSE_SMS_BINDING = "NaviPay-exp-reverse-s const val LITMUS_EXPERIMENT_NAVIPAY_RCC_LANDING_EXP = "NaviPay-rcc-landing-experience" const val LITMUS_EXPERIMENT_NAVIPAY_ONBOARDING_COMMS = "NaviPay-exp-onb-comms" const val LITMUS_EXPERIMENT_NAVIPAY_MULTIBANK = "NaviPay-exp-multibank" +const val LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT = "NaviPay-exp-p2c-frequent-contacts" val NAVI_PAY_LITMUS_EXPERIMENTS = listOf( LITMUS_EXPERIMENT_NAVIPAY_TRANSACTION_LEDGER, @@ -228,6 +230,7 @@ val NAVI_PAY_LITMUS_EXPERIMENTS = LITMUS_EXPERIMENT_HPC_MM_ROLLOUT, LITMUS_EXPERIMENT_NAVIPAY_ONBOARDING_COMMS, LITMUS_EXPERIMENT_NAVIPAY_MULTIBANK, + LITMUS_EXPERIMENT_NAVI_PAY_FREQUENT_CONTACT, ) // Generic diff --git a/android/navi-pay/src/main/res/values/strings.xml b/android/navi-pay/src/main/res/values/strings.xml index 93bf201a8c..42f6901015 100644 --- a/android/navi-pay/src/main/res/values/strings.xml +++ b/android/navi-pay/src/main/res/values/strings.xml @@ -1196,4 +1196,10 @@ Unfortunately, your QR has been blocked due to suspicious activity. For help, contact us. Select bank account to re-link %s as UPI number UPI number can only be re-linked to a bank account with UPI ID ending in @naviaxis. + Paid yesterday + Paid today + Paid on %s + Received today + Received yesterday + Received on %s \ No newline at end of file