From 4b50d5da080ab92d9f254e990656ec7949558ff6 Mon Sep 17 00:00:00 2001 From: Shaurya Rehan Date: Fri, 7 Feb 2025 14:54:31 +0530 Subject: [PATCH] NTP-28338 | UPI offer experience (#14335) --- .../bbps/common/viewmodel/NaviBbpsBaseVM.kt | 11 +- .../category/BillCategoryViewModelV2.kt | 2 - .../ui/BbpsPostPaymentScreenV2.kt | 4 +- .../FirebaseRemoteConfigHelper.kt | 1 + ...vi_common_ic_double_right_arrow_white.xml} | 6 +- .../main/res/xml/default_remote_config.xml | 4 + .../navi/pay/analytics/NaviPayAnalytics.kt | 309 ++++-- .../model/config/NaviPayDefaultConfig.kt | 12 + .../navi/pay/common/setup/NaviPayManager.kt | 6 + .../navi/pay/common/setup/NaviPayRouter.kt | 3 +- .../pay/common/theme/color/NaviPayColor.kt | 2 +- .../navi/pay/common/ui/NaviPayCommonView.kt | 261 +++-- .../usecase/RefreshGenericOffersUseCase.kt | 98 ++ .../navi/pay/entry/ui/NaviPayMainScreen.kt | 87 +- .../model/view/CollectRequestModels.kt | 3 +- .../collectrequest/ui/CollectRequestItem.kt | 40 +- .../ui/CollectRequestsScreen.kt | 38 +- .../viewmodel/CollectRequestViewModel.kt | 96 +- .../PaymentSummaryTransactionDetailSection.kt | 75 +- .../viewmodel/PaymentSummaryViewModel.kt | 8 + .../network/TransactionInitiationType.kt | 4 - .../sendmoney/model/view/SendMoneyModels.kt | 7 - .../ui/SendMoneyAccountSelectionView.kt | 44 +- .../ui/SendMoneyBottomSheetContent.kt | 13 + .../sendmoney/ui/SendMoneyMainScreen.kt | 115 +++ .../common/sendmoney/ui/SendMoneyScreen.kt | 9 + .../sendmoney/util/NaviPayOffersHelper.kt | 230 +++++ .../common/sendmoney/util/SendMoneyUtils.kt | 9 + .../sendmoney/viewmodel/SendMoneyViewModel.kt | 192 +++- .../common/upiid/ui/UPIIdInputScreen.kt | 84 +- .../UPIIDInputScreenBottomSheetHolder.kt | 14 + .../upiid/viewmodel/UPIIdInputViewModel.kt | 81 ++ .../bank/ui/BankDetailInputScreen.kt | 16 +- .../scanpay/ui/QrScannerScreen.kt | 392 ++------ .../scanpay/viewmodel/QrScannerViewModel.kt | 300 +----- .../paytocontacts/ui/PayToContactsScreen.kt | 373 +++---- .../viewmodel/PayToContactsViewModel.kt | 104 +- ...SavedBeneficiaryScreenBottomSheetHolder.kt | 3 + .../ui/SavedBeneficiaryScreen.kt | 636 ++++++------ .../viewmodel/SavedBeneficiaryViewModel.kt | 99 +- .../com/navi/pay/utils/NaviPayConstants.kt | 15 +- .../navi-pay/src/main/res/values/strings.xml | 13 +- .../pay/usecase/NaviPayOffersHelperTest.kt | 139 +++ .../pay/viewmodel/UpiIdViewModelUnitTest.kt | 8 + .../com/navi/rr/common/models/OfferData.kt | 12 +- .../navi/rr/common/models/OfferResponse.kt | 47 +- .../network/retrofit/RetrofitService.kt | 2 - .../com/navi/rr/common/repo/OffersRepo.kt | 9 +- .../rr/common/ui/OfferBottomSheetWidget.kt | 20 +- .../com/navi/rr/common/ui/OfferTicketItem.kt | 948 ++++++++++-------- .../rr/common/ui/OffersCommonComposables.kt | 107 ++ .../com/navi/rr/utils/constants/Constants.kt | 8 + .../mapper/OfferResponseToOfferDataMapper.kt | 32 +- .../navi-rr/src/main/res/values/strings.xml | 1 + .../src/main/res/drawable/navi_coin_12.xml | 221 ++++ 55 files changed, 3369 insertions(+), 2004 deletions(-) rename android/navi-common/src/main/res/drawable/{navi_common_ic_double_right_arrow.xml => navi_common_ic_double_right_arrow_white.xml} (62%) create mode 100644 android/navi-pay/src/main/kotlin/com/navi/pay/common/usecase/RefreshGenericOffersUseCase.kt create mode 100644 android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/NaviPayOffersHelper.kt create mode 100644 android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIDInputScreenBottomSheetHolder.kt create mode 100644 android/navi-pay/src/test/kotlin/com/navi/pay/usecase/NaviPayOffersHelperTest.kt create mode 100644 android/navi-rr/src/main/java/com/navi/rr/common/ui/OffersCommonComposables.kt create mode 100644 android/navi-widgets/src/main/res/drawable/navi_coin_12.xml diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/NaviBbpsBaseVM.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/NaviBbpsBaseVM.kt index 741b4a0c45..df45635d9e 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/NaviBbpsBaseVM.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/NaviBbpsBaseVM.kt @@ -347,7 +347,9 @@ abstract class NaviBbpsBaseVM(open val naviBbpsVmData: NaviBbpsVmData) : BaseVM( // Handle success for both responses if (coinBurnResponse != null) { val offerDataList = - offersResponse?.data?.map { offersMapper.map(it) } ?: emptyList() + offersResponse?.data?.map { + offersMapper.map(offerResponse = it, vertical = ModuleNameV2.BBPS) + } ?: emptyList() val coinBurnData = coinBurnResponse.data // Update both state objects @@ -390,7 +392,12 @@ abstract class NaviBbpsBaseVM(open val naviBbpsVmData: NaviBbpsVmData) : BaseVM( val offerDataList = data.nudgeResponseBundle.mapValues { (_, value) -> value - .map { offerResponse -> mapper.map(offerResponse) } + .map { offerResponse -> + mapper.map( + offerResponse = offerResponse, + vertical = ModuleNameV2.BBPS, + ) + } .filter { it.titlePrefix.isNotNullAndNotEmpty() && it.tags?.contains(BAU)?.not() ?: true diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt index 95d4d77121..0c3a1464fb 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt @@ -32,7 +32,6 @@ import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getBbpsMetricInfo import com.navi.bbps.feature.category.model.view.BillCategoryBottomSheetType import com.navi.bbps.feature.category.model.view.BillCategoryStateV2 import com.navi.bbps.feature.contactlist.PhoneContactManager -import com.navi.bbps.feature.mybills.DismissBillHandler import com.navi.bbps.feature.mybills.MyBillsRepository import com.navi.bbps.feature.mybills.MyBillsSyncJob import com.navi.bbps.network.di.NaviBbpsGsonBuilder @@ -60,7 +59,6 @@ constructor( myBillsRepository: MyBillsRepository, bbpsResourceProvider: ResourceProvider, localJsonDataSource: LocalJsonDataSource, - dismissBillHandler: DismissBillHandler, myBillEntityToBillerDetailsEntityMapper: MyBillEntityToBillerDetailsEntityMapper, billDetailsResponseToEntityMapper: BillDetailsResponseToEntityMapper, myBillEntityToBillCategoryEntityMapper: MyBillEntityToBillCategoryEntityMapper, diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/transactiondetails/ui/BbpsPostPaymentScreenV2.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/transactiondetails/ui/BbpsPostPaymentScreenV2.kt index 3907763783..d8e2b1f881 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/transactiondetails/ui/BbpsPostPaymentScreenV2.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/transactiondetails/ui/BbpsPostPaymentScreenV2.kt @@ -2009,7 +2009,9 @@ private fun SharedTransitionScope.RewardsBottomBarWithStripSection( Image( painter = - painterResource(id = CommonR.drawable.navi_common_ic_double_right_arrow), + painterResource( + id = CommonR.drawable.navi_common_ic_double_right_arrow_white + ), contentDescription = EMPTY, modifier = Modifier.size(20.dp), ) diff --git a/android/navi-common/src/main/java/com/navi/common/firebaseremoteconfig/FirebaseRemoteConfigHelper.kt b/android/navi-common/src/main/java/com/navi/common/firebaseremoteconfig/FirebaseRemoteConfigHelper.kt index ca0dfa9a78..77efbe6012 100644 --- a/android/navi-common/src/main/java/com/navi/common/firebaseremoteconfig/FirebaseRemoteConfigHelper.kt +++ b/android/navi-common/src/main/java/com/navi/common/firebaseremoteconfig/FirebaseRemoteConfigHelper.kt @@ -128,6 +128,7 @@ object FirebaseRemoteConfigHelper { const val NAVI_PAY_REMOVE_APP_ON_INTENT_PAYMENT_SUCCESS = "NAVI_PAY_REMOVE_APP_ON_INTENT_PAYMENT_SUCCESS" const val NAVI_PAY_CL_MAX_RETRY_COUNT = "NAVI_PAY_CL_MAX_RETRY_COUNT" + const val NAVI_PAY_GENERIC_OFFERS_TTL_IN_MILLIS = "NAVI_PAY_GENERIC_OFFERS_TTL_IN_MILLIS" // COMMON const val LITMUS_EXPERIMENTS_CACHE_DURATION_IN_MILLIS = diff --git a/android/navi-common/src/main/res/drawable/navi_common_ic_double_right_arrow.xml b/android/navi-common/src/main/res/drawable/navi_common_ic_double_right_arrow_white.xml similarity index 62% rename from android/navi-common/src/main/res/drawable/navi_common_ic_double_right_arrow.xml rename to android/navi-common/src/main/res/drawable/navi_common_ic_double_right_arrow_white.xml index 6072e36182..69c5383f00 100644 --- a/android/navi-common/src/main/res/drawable/navi_common_ic_double_right_arrow.xml +++ b/android/navi-common/src/main/res/drawable/navi_common_ic_double_right_arrow_white.xml @@ -5,10 +5,10 @@ android:viewportHeight="20"> diff --git a/android/navi-common/src/main/res/xml/default_remote_config.xml b/android/navi-common/src/main/res/xml/default_remote_config.xml index 0bf8817de8..80e484d360 100644 --- a/android/navi-common/src/main/res/xml/default_remote_config.xml +++ b/android/navi-common/src/main/res/xml/default_remote_config.xml @@ -670,4 +670,8 @@ LOW_NETWORK_SIGNAL_THRESHOLD 0 + + NAVI_PAY_GENERIC_OFFERS_TTL_IN_MILLIS + 1800000 + \ No newline at end of file diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/analytics/NaviPayAnalytics.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/analytics/NaviPayAnalytics.kt index dca13595d5..afea5a317e 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/analytics/NaviPayAnalytics.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/analytics/NaviPayAnalytics.kt @@ -48,7 +48,6 @@ import com.navi.pay.onboarding.binding.model.network.ServiceProvider import com.navi.pay.onboarding.faq.model.view.UpiVideoEntity import com.navi.pay.tstore.list.model.view.OrderEntity import com.navi.pay.utils.asString -import kotlin.text.orEmpty import org.json.JSONObject class NaviPayAnalytics private constructor() { @@ -1358,21 +1357,17 @@ class NaviPayAnalytics private constructor() { inner class NaviPayQrScanner { fun onQrScannerLanded( isPermissionGranted: Boolean, - rewardNudgeShown: String = false.toString(), naviPaySessionAttributes: Map, - isFrequentOrderListEmpty: Boolean, ) { NaviTrackEvent.trackEventOnClickStream( eventName = "NaviPay_QrScanner_Landed", eventValues = mapOf( Pair("isPermissionGranted", isPermissionGranted.toString()), - "reward_nudge" to rewardNudgeShown, "naviPaySessionId" to naviPaySessionAttributes["naviPaySessionId"].orEmpty(), "naviPayCustomerStatus" to naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), - "isFrequentTransactionListEmpty" to isFrequentOrderListEmpty.toString(), ), ) } @@ -1561,31 +1556,6 @@ class NaviPayAnalytics private constructor() { ) } - fun onContactSelected( - source: String, - inContactList: Boolean, - isFromFrequentOrderList: Boolean, - currentSearchQuery: String, - naviPaySessionAttributes: Map, - isPermissionGranted: Boolean, - orderOfOrderItem: Int, - ) { - NaviTrackEvent.trackEventOnClickStream( - "NaviPay_SendMoney_SelectContact_ContactClicked", - mapOf( - "source" to source, - "In_contact_list" to inContactList.toString(), - "from_frequent" to isFromFrequentOrderList.toString(), - "search_query" to currentSearchQuery, - "naviPaySessionId" to naviPaySessionAttributes["naviPaySessionId"].orEmpty(), - "naviPayCustomerStatus" to - naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), - "isPermissionGranted" to isPermissionGranted.toString(), - "orderOfTransactionItem" to orderOfOrderItem.toString(), - ), - ) - } - fun onUrlTypeQRScanned(qrContent: String) { NaviTrackEvent.trackEventOnClickStream( eventName = "NaviPay_QrScanner_UrlTypeQRScanned", @@ -1611,6 +1581,47 @@ class NaviPayAnalytics private constructor() { mapOf("message" to message), ) } + + fun onQRProcessingFailure(isQrCodeProcessing: Boolean) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_QrScanner_ProcessingFailure", + eventValues = mapOf("isQrCodeProcessing" to isQrCodeProcessing.toString()), + ) + } + + fun onOffersCalloutLanded( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_QrScanner_OffersCallOut_Landed", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } + + fun onOffersCalloutClicked( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_QrScanner_OffersCallOut_Clicked", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } } inner class NaviPayBankDetailsInput { @@ -1777,18 +1788,48 @@ class NaviPayAnalytics private constructor() { ), ) } + + fun onOffersCalloutLanded( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_UpiIdInput_OffersCallOut_Landed", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } + + fun onOffersCalloutClicked( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_UpiIdInput_OffersCallOut_Clicked", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } } inner class NaviPayToContacts { - fun onSendToContactsLanded( - rewardNudgeShown: String, - naviPaySessionAttributes: Map, - ) { + fun onSendToContactsLanded(naviPaySessionAttributes: Map) { NaviTrackEvent.trackEventOnClickStream( eventName = "NaviPay_SendMoney_SelectContact_Landed", eventValues = mapOf( - "reward_nudge" to rewardNudgeShown, "naviPaySessionId" to naviPaySessionAttributes["naviPaySessionId"].orEmpty(), "naviPayCustomerStatus" to @@ -1798,7 +1839,6 @@ class NaviPayAnalytics private constructor() { } fun onSendToContactsLoaded( - rewardNudgeShown: String, naviPaySessionAttributes: Map, isPermissionGranted: Boolean, isContactListEmpty: Boolean, @@ -1808,7 +1848,6 @@ class NaviPayAnalytics private constructor() { eventName = "NaviPay_SendMoney_SelectContact_Loaded", eventValues = mapOf( - "reward_nudge" to rewardNudgeShown, "naviPaySessionId" to naviPaySessionAttributes["naviPaySessionId"].orEmpty(), "naviPayCustomerStatus" to @@ -1925,6 +1964,40 @@ class NaviPayAnalytics private constructor() { ), ) } + + fun onOffersCalloutLanded( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_SendMoney_SelectContact_OffersCallOut_Landed", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } + + fun onOffersCalloutClicked( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_SendMoney_SelectContact_OffersCallOut_Clicked", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } } inner class NaviPayUpiNumber { @@ -2733,6 +2806,23 @@ class NaviPayAnalytics private constructor() { mapOf("transactionId" to transactionId, "action" to action), ) } + + fun onOffersCalloutLanded( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_PendingCollectRequests_OffersCallOut_Landed", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } } inner class NaviPayPaymentSummary { @@ -3680,6 +3770,45 @@ class NaviPayAnalytics private constructor() { fun onArcNudgeBottomSheetCloseClicked() { NaviTrackEvent.trackEvent("NaviPay_SendMoney_ProtectNudgeBottomSheetCloseClicked") } + + fun onOffersCalloutLanded( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + attributeMap: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_Send_Money_OffersCallOut_Landed", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + "attributeMap" to attributeMap, + ), + ) + } + + fun onOffersCalloutClicked( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + isFromAccountSelectionBottomSheet: Boolean, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_Send_Money_OffersCallOut_Clicked", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + "isFromAccountSelectionBottomSheet" to + isFromAccountSelectionBottomSheet.toString(), + ), + ) + } } inner class NaviPayUPILite { @@ -4412,17 +4541,12 @@ class NaviPayAnalytics private constructor() { } inner class SavedBeneficiaryScreen { - fun onLanded( - tab: String, - rewardNudgeShown: String, - naviPaySessionAttributes: Map, - ) { + fun onLanded(tab: String, naviPaySessionAttributes: Map) { NaviTrackEvent.trackEventOnClickStream( eventName = "NaviPay_SendMoney_UpiOrBank_Landed", eventValues = mapOf( "tab" to tab, - "reward_nudge" to rewardNudgeShown, "naviPaySessionId" to naviPaySessionAttributes["naviPaySessionId"].orEmpty(), "naviPayCustomerStatus" to @@ -4503,36 +4627,6 @@ class NaviPayAnalytics private constructor() { ) } - fun onSavedBeneficiarySearchScreenBackClicked( - naviPaySessionAttributes: Map - ) { - NaviTrackEvent.trackEventOnClickStream( - eventName = "NaviPay_SavedBeneficiaryScreen_SearchScreenBackClicked", - eventValues = - mapOf( - "naviPaySessionId" to - naviPaySessionAttributes["naviPaySessionId"].orEmpty(), - "naviPayCustomerStatus" to - naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), - ), - ) - } - - fun onSavedBeneficiaryListViewSearchAreaClicked( - naviPaySessionAttributes: Map - ) { - NaviTrackEvent.trackEventOnClickStream( - eventName = "NaviPay_SavedBeneficiaryScreen_ListViewSearchAreaClicked", - eventValues = - mapOf( - "naviPaySessionId" to - naviPaySessionAttributes["naviPaySessionId"].orEmpty(), - "naviPayCustomerStatus" to - naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), - ), - ) - } - fun onSavedBeneficiaryValidateVpaInvalid( savedBeneficiaryEntity: SavedBeneficiaryEntity, naviPaySessionAttributes: Map, @@ -4549,6 +4643,40 @@ class NaviPayAnalytics private constructor() { ), ) } + + fun onOffersCalloutLanded( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_SendMoney_UpiOrBank_OffersCallOut_Landed", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } + + fun onOffersCalloutClicked( + numberOfOffers: Int, + naviPaySessionAttributes: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_SendMoney_UpiOrBank_OffersCallOut_Clicked", + eventValues = + mapOf( + "numberOfOffers" to numberOfOffers.toString(), + "naviPaySessionId" to + naviPaySessionAttributes["naviPaySessionId"].orEmpty(), + "naviPayCustomerStatus" to + naviPaySessionAttributes["naviPayCustomerStatus"].orEmpty(), + ), + ) + } } inner class NaviSettingSDK { @@ -5176,6 +5304,41 @@ class NaviPayAnalytics private constructor() { } } + inner class NaviPayOffers { + fun onClearGenericOffersInfoInCache() { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_ClearGenericOffersInfoInCache" + ) + } + + fun onGenericOffersInfoNotRefreshed(currentTimeInMillis: Long, lastUpdatedAt: Long) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_GenericOffersInfoNotRefreshed", + eventValues = + mapOf( + "currentTimeInMillis" to currentTimeInMillis.toString(), + "lastUpdatedAt" to lastUpdatedAt.toString(), + ), + ) + } + + fun onGenericOffersFetchedFromNetwork( + genericOffersList: String, + isPresentInCache: Boolean, + screenName: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviPay_GenericOffersFetchedFromNetwork", + eventValues = + mapOf( + "genericOffersList" to genericOffersList, + "isPresentInCache" to isPresentInCache.toString(), + "screenName" to screenName, + ), + ) + } + } + companion object { val INSTANCE = NaviPayAnalytics() const val NAVI_PAY_ACTIVITY = "navipay_activity" 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 ede7bbade6..b1478ca9d5 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 @@ -9,6 +9,7 @@ package com.navi.pay.common.model.config import com.google.gson.annotations.SerializedName import com.navi.pay.utils.CHECK_BALANCE_AUTO_HIDE_ON_SCREEN_DURATION_IN_MINUTES +import com.navi.rr.common.models.OfferData data class NaviPayDefaultConfig( @SerializedName("config") val config: DefaultConfigContent = DefaultConfigContent(), @@ -107,6 +108,17 @@ data class DefaultConfigContent( @SerializedName("checkBalanceAutoHideOnScreenDurationInMinutes") val checkBalanceAutoHideOnScreenDurationInMinutes: Int = CHECK_BALANCE_AUTO_HIDE_ON_SCREEN_DURATION_IN_MINUTES, + @SerializedName("genericBauOffer") + val genericBauOffer: OfferData = + OfferData( + titlePrefix = "Win up to", + titleSuffix = "1,000", + descriptionPrefix = "on any bill payment ", + descriptionSuffix = "via Navi UPI", + iconUrl = + "https://public-assets.prod.navi-sa.in/navi-pay/png/upi-profile-icons/self_transfer.png", + applicableInfo = listOf("Applicable only if you pay via Navi UPI"), + ), ) data class ConfigMessage( 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 fbc0f6671b..ca23e70347 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 @@ -27,6 +27,7 @@ import com.navi.pay.common.setup.model.NaviPayCustomerStatus import com.navi.pay.common.setup.model.NaviPaySetupStatus 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.usecase.RefreshLinkedAccountsUseCase import com.navi.pay.common.utils.DeviceInfoProvider import com.navi.pay.common.utils.NaviPayCommonUtils @@ -77,6 +78,7 @@ constructor( private val naviPayWidgetManager: Lazy, private val refreshLinkedAccountsUseCase: Lazy, private val dataStoreHelper: Lazy, + private val refreshGenericOffersUseCase: RefreshGenericOffersUseCase, @NaviPayGsonBuilder private val gson: Gson, ) { companion object { @@ -144,6 +146,10 @@ constructor( } sdkInitialized = true handleUpiShortcutWidgetMigration() + + launch(context = Dispatchers.IO) { + refreshGenericOffersUseCase.execute(screenName = "NaviPayManager") + } } } 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 5ed4a6f410..91cfcbdd79 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 @@ -142,8 +142,7 @@ 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(openSearchState = false) + NaviPayScreenType.NAVI_PAY_TO_CONTACTS.name -> PayToContactsScreenDestination NaviPayScreenType.NAVI_PAY_SEND_MONEY_SCREEN.name -> { val payeeEntity = Uri.parse(bundle.getString(NAVI_PAY_UPI_URI_KEY)).getPayeeEntity(true) diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/theme/color/NaviPayColor.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/theme/color/NaviPayColor.kt index 6e25e072eb..97b27a6f7b 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/common/theme/color/NaviPayColor.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/theme/color/NaviPayColor.kt @@ -64,9 +64,9 @@ object NaviPayColor { val rewardsColorGradient1 = Color(0xFF9618C0) val rewardsColorGradient2 = Color(0xFF710097) val rewardsColorGradient3 = Color(0xFF1F002A) - val rewardsExchangeRatioWidgetBg = Color(0xFFD9D9D9) val black = Color(0xFF000000) val gray = Color(0xFF888888) + val lightGray = Color(0xFFCCCCCC) val contactIconBg0 = Color(0xFF7415A7) val contactIconBg1 = Color(0xFFCC5800) val contactIconBg2 = Color(0xFF0047D6) diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/ui/NaviPayCommonView.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/ui/NaviPayCommonView.kt index ef47825139..64acd8c0db 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/common/ui/NaviPayCommonView.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/ui/NaviPayCommonView.kt @@ -36,6 +36,7 @@ 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.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -73,6 +74,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -87,6 +89,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -94,6 +97,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -137,6 +141,7 @@ import com.navi.design.font.getFontWeight import com.navi.design.font.naviFontFamily import com.navi.design.theme.ABABAB import com.navi.design.theme.D1D9E6_30 +import com.navi.naviwidgets.R as WidgetsR import com.navi.naviwidgets.extensions.NaviText import com.navi.pay.R import com.navi.pay.common.model.view.CheckBalanceState @@ -178,6 +183,11 @@ import com.navi.pay.utils.noRippleClickable import com.navi.pay.utils.noRippleClickableWithDebounce import com.navi.pay.utils.shake import com.navi.pay.utils.shimmerEffect +import com.navi.rr.R as rrR +import com.navi.rr.common.models.OfferData +import com.navi.rr.common.models.OfferResponse +import com.navi.rr.common.ui.OfferBottomSheetWidget +import com.navi.rr.utils.constants.Constants.DETAILS import com.navi.uitron.utils.alfredMaskSensitiveComposable import com.navi.uitron.utils.orValue import kotlinx.coroutines.Dispatchers @@ -241,13 +251,13 @@ fun NaviPayHeader( onActionClick?.invoke() }, ) - } else if (actionIconText.isNullOrEmpty().not()) { + } else if (!actionIconText.isNullOrEmpty()) { NaviText( text = actionIconText!!, fontSize = 14.sp, fontFamily = naviFontFamily, fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), - color = titleColor, + color = NaviPayColor.textPrimary, textAlign = TextAlign.Center, modifier = Modifier.padding(end = 16.dp).clickableDebounce { @@ -1412,6 +1422,7 @@ fun InputTextFieldWithDescriptionHeader( headerString: String?, placeHolderString: String, value: String, + headerStringFontSize: TextUnit = 14.sp, warningErrorInfoState: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, isTrailingIconEnabled: Boolean = false, @@ -1449,7 +1460,7 @@ fun InputTextFieldWithDescriptionHeader( headerString?.let { headerString -> NaviText( text = headerString, - fontSize = 14.sp, + fontSize = headerStringFontSize, fontFamily = naviFontFamily, fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), color = NaviPayColor.textPrimary, @@ -2436,99 +2447,6 @@ fun LottieAnimationWithPlaceHolder( ) } -@Composable -fun RewardsNudgeWithoutBg( - modifier: Modifier, - exchangeWidgetBgAlpha: Float = 0.35f, - textColor: Color = NaviPayColor.textPrimary, - nudgeDetailEntity: NudgeDetailEntity, -) { - Row( - modifier = modifier.height(IntrinsicSize.Max), - verticalAlignment = Alignment.CenterVertically, - ) { - NaviText( - text = nudgeDetailEntity.prefixText, - color = textColor, - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), - ) - - Spacer(modifier = Modifier.width(2.dp)) - - Image( - painter = painterResource(id = CommonR.drawable.ic_np_coin), - contentDescription = null, - modifier = Modifier.size(12.dp), - ) - - Spacer(modifier = Modifier.width(2.dp)) - - NaviText( - text = nudgeDetailEntity.formattedAmount, - color = textColor, - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), - ) - - Spacer(modifier = Modifier.width(2.dp)) - - NaviText( - text = nudgeDetailEntity.suffixText, - color = textColor, - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), - ) - - Spacer(modifier = Modifier.width(4.dp)) - - if (nudgeDetailEntity.showExchangeRatio) { - RewardsExchangeRatioWithBg( - exchangeWidgetBgAlpha = exchangeWidgetBgAlpha, - textColor = textColor, - ) - } - } -} - -@Composable -fun RewardsExchangeRatioWithBg( - exchangeWidgetBgAlpha: Float, - textColor: Color = NaviPayColor.textPrimary, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.background( - color = - NaviPayColor.rewardsExchangeRatioWidgetBg.copy( - alpha = exchangeWidgetBgAlpha - ), - shape = RoundedCornerShape(20.dp), - ) - .padding(start = 4.dp, end = 8.dp, top = 1.dp, bottom = 1.dp), - ) { - Image( - painter = painterResource(id = CommonR.drawable.ic_np_coin), - contentDescription = null, - modifier = Modifier.size(12.dp), - ) - - Spacer(modifier = Modifier.width(1.dp)) - - NaviText( - text = NAVI_PAY_REWARDS_EXCHANGE_RATIO, - color = textColor, - fontSize = 10.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), - ) - } -} - @Composable fun ImageTitleDescriptionShimmerView() { Row( @@ -2783,19 +2701,17 @@ fun TitleDescriptionWithLinearProgressBar( } @Composable -fun ShadowStrip(modifier: Modifier = Modifier) { - Box( - modifier = - modifier - .height(16.dp) - .background( - Brush.verticalGradient( - colors = listOf(NaviPayColor.ctaWhite, Color.LightGray.copy(alpha = 0.3f)), - startY = 0f, - endY = 100f, - ) - ) - ) +fun ShadowStrip( + modifier: Modifier = Modifier, + height: Dp = 16.dp, + brush: Brush = + Brush.verticalGradient( + colors = listOf(NaviPayColor.ctaWhite, NaviPayColor.lightGray.copy(alpha = 0.3f)), + startY = 0f, + endY = 100f, + ), +) { + Box(modifier = modifier.height(height = height).background(brush = brush)) } @Composable @@ -3677,3 +3593,130 @@ fun CameraPermissionView( ) } } + +@Composable +fun NaviPayOffersBottomSheet( + offerData: List, + heading: String, + params: Map? = null, + closeSheet: () -> Unit, +) { + Column( + modifier = + Modifier.heightIn( + min = 0.dp, + max = LocalConfiguration.current.screenHeightDp.dp * 0.85f, + ) + .fillMaxWidth(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + ) { + OfferBottomSheetWidget(offerData = offerData, offerListHeading = heading, params = params) { + closeSheet() + } + } +} + +@Composable +fun OfferStripView( + modifier: Modifier = Modifier, + bestOfferData: OfferData, + showLightVariant: Boolean = false, + totalOffers: Int, + showDropDown: Boolean = true, + showOfferAppliedStripText: Boolean = true, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + onOffersStripClicked: (() -> Unit)? = null, + seperatorHeight: Dp = 12.dp, +) { + val titlePrefix = + remember(showOfferAppliedStripText) { + if (showOfferAppliedStripText) + bestOfferData.offerAppliedStrip?.prefixText?.trim().orEmpty() + else bestOfferData.titlePrefix?.trim().orEmpty() + } + + val suffixText = + remember(showOfferAppliedStripText) { + if (showOfferAppliedStripText) + bestOfferData.offerAppliedStrip?.suffixText?.trim().orEmpty() + else bestOfferData.titleSuffix?.trim().orEmpty() + } + + Row( + modifier = + modifier.conditional(onOffersStripClicked != null) { + noRippleClickableWithDebounce { onOffersStripClicked?.invoke() } + }, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + ) { + Image( + painter = painterResource(id = WidgetsR.drawable.ic_offer_purple), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + NaviText( + text = titlePrefix, + color = if (showLightVariant) NaviPayColor.textWhite else NaviPayColor.textSecondary, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 200.dp), + lineHeight = 16.sp, + ) + Spacer(modifier = Modifier.width(2.dp)) + Image( + painter = painterResource(id = WidgetsR.drawable.navi_coin_12), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(2.dp)) + NaviText( + text = suffixText, + color = if (showLightVariant) NaviPayColor.textWhite else NaviPayColor.textSecondary, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), + lineHeight = 16.sp, + ) + if (showDropDown) { + Spacer(modifier = Modifier.width(4.dp)) + VerticalDivider( + modifier = Modifier.height(seperatorHeight).align(Alignment.CenterVertically), + color = + if (showLightVariant) NaviPayColor.textWhite + else NaviPayColor.inputFieldDefault, + thickness = 1.dp, + ) + Spacer(modifier = Modifier.width(4.dp)) + NaviText( + text = + if (totalOffers == 1) DETAILS + else stringResource(rrR.string.n_offers, totalOffers), + color = if (showLightVariant) NaviPayColor.textWhite else ctaPrimary, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + maxLines = 1, + lineHeight = 16.sp, + ) + Image( + painter = + painterResource( + id = + if (showLightVariant) CommonR.drawable.ic_chevron_right + else WidgetsR.drawable.ic_arrow_black_down + ), + contentDescription = null, + modifier = + Modifier.align(Alignment.CenterVertically).conditional(showLightVariant) { + rotate(90f) + }, + ) + } + } +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/usecase/RefreshGenericOffersUseCase.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/usecase/RefreshGenericOffersUseCase.kt new file mode 100644 index 0000000000..428d0a09f5 --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/usecase/RefreshGenericOffersUseCase.kt @@ -0,0 +1,98 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.common.usecase + +import com.google.gson.Gson +import com.navi.base.cache.model.NaviCacheEntity +import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.NAVI_PAY_GENERIC_OFFERS_TTL_IN_MILLIS +import com.navi.pay.analytics.NaviPayAnalytics +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper +import com.navi.pay.network.di.NaviPayGsonBuilder +import com.navi.pay.utils.NAVI_PAY_GENERIC_OFFERS_INFO +import javax.inject.Inject + +class RefreshGenericOffersUseCase +@Inject +constructor( + private val naviCacheRepository: NaviCacheRepository, + private val naviPayOffersHelper: NaviPayOffersHelper, + @NaviPayGsonBuilder private val gson: Gson, +) { + private lateinit var screenName: String + private val naviPayAnalytics = NaviPayAnalytics.INSTANCE.NaviPayOffers() + + suspend fun execute(screenName: String, shouldHardRefresh: Boolean = false) { + this.screenName = screenName + + if (shouldHardRefresh) { + clearGenericOffersInfoInCache() + } + + val currentValueInDb = naviCacheRepository.get(NAVI_PAY_GENERIC_OFFERS_INFO) + + if (currentValueInDb == null) { + fetchOffersFromNetworkAndSaveToCache(isPresentInCache = false) + return + } + + // TTL is yet not breached to refresh the offers + if ( + System.currentTimeMillis() - currentValueInDb.updatedAt < + FirebaseRemoteConfigHelper.getLong( + key = NAVI_PAY_GENERIC_OFFERS_TTL_IN_MILLIS, + defaultValue = 1800000L, + ) + ) { + naviPayAnalytics.onGenericOffersInfoNotRefreshed( + currentTimeInMillis = System.currentTimeMillis(), + lastUpdatedAt = currentValueInDb.updatedAt, + ) + return + } + fetchOffersFromNetworkAndSaveToCache(isPresentInCache = true) + } + + private suspend fun fetchOffersFromNetworkAndSaveToCache(isPresentInCache: Boolean) { + val genericOffersList = naviPayOffersHelper.getOffersFromNetwork(screenName = screenName) + + // Todo: remove dev events after some time / subsequent release + naviPayAnalytics.onGenericOffersFetchedFromNetwork( + genericOffersList = genericOffersList.toString(), + isPresentInCache = isPresentInCache, + screenName = screenName, + ) + + if (genericOffersList.isEmpty()) { + // Clear cache if no offers are available, in order to sync offers on next land + clearGenericOffersInfoInCache() + return + } + + // Save offers to cache + naviCacheRepository.save( + naviCacheEntity = + NaviCacheEntity( + key = NAVI_PAY_GENERIC_OFFERS_INFO, + value = gson.toJson(genericOffersList), + version = 1, + ttl = + FirebaseRemoteConfigHelper.getLong( + key = NAVI_PAY_GENERIC_OFFERS_TTL_IN_MILLIS, + defaultValue = 1800000L, + ), + ) + ) + } + + private suspend fun clearGenericOffersInfoInCache() { + naviPayAnalytics.onClearGenericOffersInfoInCache() + naviCacheRepository.clear(key = NAVI_PAY_GENERIC_OFFERS_INFO) + } +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/entry/ui/NaviPayMainScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/entry/ui/NaviPayMainScreen.kt index 95930446f6..7b9b175b80 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/entry/ui/NaviPayMainScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/entry/ui/NaviPayMainScreen.kt @@ -13,9 +13,6 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.material.navigation.ModalBottomSheetLayout import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState @@ -37,8 +34,6 @@ import com.navi.pay.common.utils.ErrorEventHandler import com.navi.pay.entry.NaviPayActivity import com.navi.pay.utils.ANIMATION_SPEC_DURATION_IN_MILLIS import com.navi.pay.utils.GenericErrorCtaHandler -import com.navi.pay.utils.PAY_TO_CONTACTS_SCREEN_ROUTE -import com.navi.pay.utils.QR_SCANNER_SCREEN_ROUTE import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine @@ -117,17 +112,7 @@ fun NaviPayMainScreen( RootNavGraphDefaultAnimations( enterTransition = { // TODO evaluate animation override at destination - if (shouldEnableVerticalUpTransition()) { - fadeIn() + - slideInVertically( - initialOffsetY = { it }, - animationSpec = - tween( - durationMillis = - ANIMATION_SPEC_DURATION_IN_MILLIS - ), - ) - } else if ( + if ( restrictEnterExitTransitionForLinkedAccountBalanceScreen() || restrictEnterExitTransitionForPaymentSummaryScreen() ) { @@ -149,15 +134,7 @@ fun NaviPayMainScreen( } }, exitTransition = { - if (shouldEnableVerticalUpTransition()) { - fadeOut( - animationSpec = - tween( - durationMillis = - ANIMATION_SPEC_DURATION_IN_MILLIS - ) - ) - } else if ( + if ( restrictEnterExitTransitionForLinkedAccountBalanceScreen() || restrictEnterExitTransitionForPaymentSummaryScreen() ) { @@ -177,15 +154,7 @@ fun NaviPayMainScreen( } }, popEnterTransition = { - if (shouldEnableVerticalDownTransition()) { - fadeIn( - animationSpec = - tween( - durationMillis = - ANIMATION_SPEC_DURATION_IN_MILLIS - ) - ) - } else if (fadeInFadeOutTransition()) { + if (fadeInFadeOutTransition()) { fadeIn(animationSpec = tween(durationMillis = 0)) } else { slideIntoContainer( @@ -202,35 +171,15 @@ fun NaviPayMainScreen( } }, popExitTransition = { - if (shouldEnableVerticalDownTransition()) { - fadeOut() + - slideOutVertically( - targetOffsetY = { it }, - animationSpec = - tween( - durationMillis = - ANIMATION_SPEC_DURATION_IN_MILLIS - ), - ) - } else { - slideOutOfContainer( - towards = - if (shouldEnableVerticalDownTransition()) { - AnimatedContentTransitionScope.SlideDirection - .Companion - .Down - } else { - AnimatedContentTransitionScope.SlideDirection - .Companion - .Right - }, - animationSpec = - tween( - durationMillis = - ANIMATION_SPEC_DURATION_IN_MILLIS - ), - ) - } + slideOutOfContainer( + towards = + AnimatedContentTransitionScope.SlideDirection.Companion + .Right, + animationSpec = + tween( + durationMillis = ANIMATION_SPEC_DURATION_IN_MILLIS + ), + ) }, ) ), @@ -273,18 +222,6 @@ private fun AnimatedContentTransitionScope this.targetState.destination.route?.contains(PAYMENT_SUMMARY_SCREEN).orFalse() && this.initialState.destination.route?.contains(SEND_MONEY_SCREEN).orFalse() -private fun AnimatedContentTransitionScope.shouldEnableVerticalUpTransition(): - Boolean { - return this.initialState.destination.route?.contains(QR_SCANNER_SCREEN_ROUTE).orFalse() && - this.targetState.destination.route?.contains(PAY_TO_CONTACTS_SCREEN_ROUTE).orFalse() -} - -private fun AnimatedContentTransitionScope.shouldEnableVerticalDownTransition(): - Boolean { - return this.targetState.destination.route?.contains(QR_SCANNER_SCREEN_ROUTE).orFalse() && - this.initialState.destination.route?.contains(PAY_TO_CONTACTS_SCREEN_ROUTE).orFalse() -} - private fun AnimatedContentTransitionScope.fadeInFadeOutTransition(): Boolean { return this.targetState.destination.route?.contains(LINK_UPI_NUMBER_SCREEN).orFalse() } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/model/view/CollectRequestModels.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/model/view/CollectRequestModels.kt index e110335164..98a1d745a2 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/model/view/CollectRequestModels.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/model/view/CollectRequestModels.kt @@ -9,6 +9,7 @@ package com.navi.pay.management.collectrequest.model.view import com.navi.pay.management.collectrequest.getExpiryTimestampFormatted import com.navi.pay.management.common.sendmoney.model.view.PayeeEntity +import com.navi.rr.common.models.OfferData import org.joda.time.DateTime /** @@ -26,8 +27,8 @@ data class CollectRequestEntity( val isMarkedSpam: Boolean, val payerVpa: String, val collectType: String, + val bestOfferData: OfferData? = null, ) { - val expiryTimestampFormatted by lazy { getExpiryTimestampFormatted( expiryTimestamp = this.expiryTimestamp, diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestItem.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestItem.kt index 15f8bd0cdc..138fe6dd16 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestItem.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestItem.kt @@ -7,9 +7,16 @@ package com.navi.pay.management.collectrequest.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -27,6 +34,7 @@ import com.navi.naviwidgets.extensions.NaviText import com.navi.pay.R import com.navi.pay.common.theme.color.NaviPayColor import com.navi.pay.common.ui.NaviPayCard +import com.navi.pay.common.ui.OfferStripView import com.navi.pay.common.ui.TextImage import com.navi.pay.common.ui.ThemedRoundedButton import com.navi.pay.common.utils.NaviPayCommonUtils.getIndexedItemBgColor @@ -35,6 +43,7 @@ import com.navi.pay.utils.NAVI_PAY_PRIMARY_CTA_LOADER_LOTTIE import com.navi.pay.utils.clickableDebounce import com.navi.pay.utils.contactInitials import com.navi.pay.utils.getDisplayableAmount +import com.navi.rr.common.models.OfferData @Composable fun CollectRequestItem( @@ -43,6 +52,7 @@ fun CollectRequestItem( onProceedBtnClick: (CollectRequestEntity) -> Unit, showLoader: Boolean, clickedCollectRequestItemId: String, + bestOfferData: OfferData?, ) { NaviPayCard( modifier = @@ -52,7 +62,8 @@ fun CollectRequestItem( borderStroke = BorderStroke(width = 1.dp, color = NaviPayColor.borderAlt), ) { ConstraintLayout(modifier = Modifier.fillMaxWidth()) { - val (userImage, userName, userVpa, amount, timestamp, btnProceed) = createRefs() + val (userImage, userName, userVpa, amount, timestamp, btnProceed, offerStrip) = + createRefs() TextImage( modifier = Modifier.constrainAs(userImage) { @@ -149,7 +160,10 @@ fun CollectRequestItem( Modifier.constrainAs(btnProceed) { top.linkTo(timestamp.bottom, 12.dp) start.linkTo(userImage.end, 12.dp) - bottom.linkTo(parent.bottom, 16.dp) + bottom.linkTo( + if (bestOfferData != null) offerStrip.top else parent.bottom, + 16.dp, + ) } .layoutId(btnProceed), text = stringResource(id = R.string.np_view_details), @@ -162,6 +176,28 @@ fun CollectRequestItem( lottieFileName = NAVI_PAY_PRIMARY_CTA_LOADER_LOTTIE, lottieFileHeight = 32.dp, ) + + AnimatedVisibility( + visible = bestOfferData != null, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = + Modifier.constrainAs(offerStrip) { + start.linkTo(parent.start) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints + } + .layoutId(offerStrip) + .background(NaviPayColor.bgNonEditable) + .padding(6.dp), + ) { + OfferStripView( + modifier = Modifier.fillMaxWidth(), + bestOfferData = bestOfferData!!, + totalOffers = 1, + showDropDown = false, + ) + } } } } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestsScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestsScreen.kt index 6abafcdc14..0732800964 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestsScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/ui/CollectRequestsScreen.kt @@ -10,14 +10,14 @@ package com.navi.pay.management.collectrequest.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -168,31 +168,29 @@ fun CollectRequestsMainScreen( if (collectRequests.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) - LazyColumn( - modifier = Modifier.padding(it).padding(horizontal = 16.dp), - contentPadding = PaddingValues(vertical = 16.dp), + Column( + modifier = + Modifier.padding(it) + .padding(all = 16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(space = 16.dp), ) { - item { - NaviText( - text = stringResource(id = R.string.payment_request), - textAlign = TextAlign.Start, - fontSize = 24.sp, - fontFamily = naviFontFamily, - color = NaviPayColor.textPrimary, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), - ) - } - itemsIndexed( - items = collectRequests, - key = { index, _ -> collectRequests[index].transactionId }, - ) { index, collectRequest -> + NaviText( + text = stringResource(id = R.string.payment_request), + textAlign = TextAlign.Start, + fontSize = 24.sp, + fontFamily = naviFontFamily, + color = NaviPayColor.textPrimary, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + ) + collectRequests.forEachIndexed { index, collectRequest -> CollectRequestItem( index = index, collectRequestEntity = collectRequest, onProceedBtnClick = onProceedRequestClick, showLoader = showLoader, clickedCollectRequestItemId = clickedCollectRequestItemId, + bestOfferData = collectRequest.bestOfferData, ) } } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/viewmodel/CollectRequestViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/viewmodel/CollectRequestViewModel.kt index 9d43f43caa..d4a2b284f1 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/viewmodel/CollectRequestViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/collectrequest/viewmodel/CollectRequestViewModel.kt @@ -10,6 +10,7 @@ package com.navi.pay.management.collectrequest.viewmodel import androidx.lifecycle.viewModelScope import com.navi.base.utils.EMPTY import com.navi.base.utils.ResourceProvider +import com.navi.base.utils.orZero import com.navi.common.network.models.isSuccessWithData import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics @@ -18,6 +19,7 @@ import com.navi.pay.common.model.view.NaviPaySessionHelper import com.navi.pay.common.model.view.SnackBarState import com.navi.pay.common.repository.CommonRepository import com.navi.pay.common.repository.SharedPreferenceRepository +import com.navi.pay.common.usecase.LinkedAccountsUseCase import com.navi.pay.common.utils.DeviceInfoProvider import com.navi.pay.common.utils.getMetricInfo import com.navi.pay.common.viewmodel.NaviPayBaseVM @@ -31,11 +33,21 @@ import com.navi.pay.management.common.sendmoney.model.view.SendMoneyAction import com.navi.pay.management.common.sendmoney.model.view.SendMoneyScreenSource import com.navi.pay.management.common.sendmoney.model.view.SendMoneyUserAction import com.navi.pay.management.common.sendmoney.model.view.UpiTransactionType +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper import com.navi.pay.management.mandate.model.network.MandateDetailRequest +import com.navi.pay.onboarding.account.detail.model.view.LinkedAccountEntity +import com.navi.pay.utils.DEFAULT_UPI_PURPOSE import com.navi.pay.utils.NAVI_PAY_DEFAULT_MCC +import com.navi.pay.utils.PAYEE_VPA +import com.navi.pay.utils.PAYER_VPA import com.navi.pay.utils.PENDING_REQUEST_REFRESHED_REQUIRED import com.navi.pay.utils.REQUEST_TYPE_MANDATE +import com.navi.pay.utils.TXN_AMOUNT +import com.navi.pay.utils.UPI_PAYEE_MCC +import com.navi.pay.utils.UPI_PAYER_BANK_CODE +import com.navi.pay.utils.UPI_PURPOSE_CODE import com.navi.pay.utils.refresh +import com.navi.rr.common.models.OfferRequest import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -46,6 +58,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -60,6 +73,8 @@ constructor( private val naviPaySessionHelper: NaviPaySessionHelper, private val naviPayActivityDataProvider: NaviPayActivityDataProvider, private val resourceProvider: ResourceProvider, + private val naviPayOffersHelper: NaviPayOffersHelper, + private val linkedAccountsUseCase: LinkedAccountsUseCase, ) : NaviPayBaseVM() { private val naviPayAnalytics: NaviPayAnalytics.NaviPayCollectRequests = @@ -188,6 +203,7 @@ constructor( updateCollectRequestUIState( uiState = CollectRequestUIState.Loaded(collectRequests = collectRequests) ) + fetchBestOfferForCollectRequests(collectRequests = collectRequests) } else { updateCollectRequestUIState(uiState = CollectRequestUIState.Loaded(emptyList())) notifyError( @@ -198,6 +214,81 @@ constructor( } } + private suspend fun fetchBestOfferForCollectRequests( + collectRequests: List + ) { + // Fetch linked accounts once for reuse + val linkedAccounts = linkedAccountsUseCase.execute().firstOrNull().orEmpty() + + val offerRequestList = + collectRequests.map { collectRequestEntity -> + OfferRequest( + requestId = collectRequestEntity.transactionId, + attributes = + buildMap { + put( + TXN_AMOUNT, + collectRequestEntity.payeeEntity.amount.takeIf { + it.isNotEmpty() + }, + ) + put( + PAYER_VPA, + collectRequestEntity.payerVpa.takeIf { it.isNotEmpty() }, + ) + put( + UPI_PAYEE_MCC, + collectRequestEntity.payeeEntity.mcc ?: NAVI_PAY_DEFAULT_MCC, + ) + put(UPI_PURPOSE_CODE, DEFAULT_UPI_PURPOSE) + put( + PAYEE_VPA, + collectRequestEntity.payeeEntity.vpa.takeIf { it.isNotEmpty() }, + ) + put( + UPI_PAYER_BANK_CODE, + getPayerBankCodeFromVpa(collectRequestEntity, linkedAccounts) + .takeIf { it.isNotEmpty() }, + ) + } + .filterValues { it != null } as Map, + ) + } + + val bestOfferMap = + naviPayOffersHelper.getBestOfferListForMultipleItems( + screenName = screenName, + offerRequestList = offerRequestList, + ) + + naviPayAnalytics.onOffersCalloutLanded( + numberOfOffers = bestOfferMap?.size.orZero(), + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + + // In order to trigger re-composition, change the list reference + val updatedCollectRequests = + collectRequests.map { collectRequestEntity -> + collectRequestEntity.copy( + bestOfferData = bestOfferMap?.get(collectRequestEntity.transactionId) + ) + } + + updateCollectRequestUIState( + uiState = CollectRequestUIState.Loaded(collectRequests = updatedCollectRequests) + ) + } + + private fun getPayerBankCodeFromVpa( + collectRequestEntity: CollectRequestEntity, + linkedAccounts: List, + ): String { + return linkedAccounts + .firstOrNull { it.vpa == collectRequestEntity.payerVpa } + ?.bankCode + .orEmpty() + } + private fun updatePendingRefreshFlag() { viewModelScope.launch(Dispatchers.IO) { sharedPreferenceRepository.saveBooleanValue( @@ -211,6 +302,9 @@ constructor( _collectRequestUIState.update { uiState } } + fun getNaviPaySessionAttributes(): Map = + naviPaySessionHelper.getNaviPaySessionAttributes() + fun onProceedButtonClicked( collectRequestEntity: CollectRequestEntity, sendMoneyAction: SendMoneyAction?, @@ -236,7 +330,7 @@ constructor( naviPaySessionHelper.createNewSessionId() naviPayAnalytics.onPendingCollectRequestItemClick( - naviPaySessionAttributes = naviPaySessionHelper.getNaviPaySessionAttributes() + naviPaySessionAttributes = getNaviPaySessionAttributes() ) if (collectRequestEntity.collectType == REQUEST_TYPE_MANDATE) { diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/ui/PaymentSummaryTransactionDetailSection.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/ui/PaymentSummaryTransactionDetailSection.kt index 817b63c105..b28eb6d2c9 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/ui/PaymentSummaryTransactionDetailSection.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/paymentsummary/ui/PaymentSummaryTransactionDetailSection.kt @@ -18,7 +18,6 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -84,8 +83,10 @@ import com.navi.pay.management.common.paymentsummary.model.view.PaymentSummaryRe import com.navi.pay.management.common.ui.TransactionInfoCard import com.navi.pay.management.transactionhistory.model.view.TransactionDetailItemProperty import com.navi.pay.management.transactionhistory.model.view.TransactionEntity +import com.navi.pay.utils.COMMA import com.navi.pay.utils.SMALL_FLOAT import com.navi.pay.utils.clickableDebounce +import com.navi.pay.utils.getDisplayableAmount import com.navi.pay.utils.getImageRequestBuilder import com.navi.pay.utils.noRippleClickableWithDebounce import com.navi.rr.scratchcard.model.ScratchCardBackResponse @@ -167,10 +168,6 @@ fun SharedTransitionScope.PaymentSummaryTransactionDetailSection( ctaText = stringResource(id = R.string.np_view_details), onCtaClicked = onViewDetailCtaClicked, ) - RewardsWonGratificationSection( - rewardsGratificationUIState = rewardsGratificationUIState, - onRewardsWonGratificationClicked = onRewardsWonGratificationClicked, - ) Spacer( modifier = Modifier.height( @@ -230,6 +227,8 @@ fun SharedTransitionScope.PaymentSummaryTransactionDetailSection( isFestiveThemeExperimentEnabled = isFestiveThemeExperimentEnabled, festiveCelebrationWithCoinsImageUrl = festiveCelebrationWithCoinsImageUrl, festiveCelebrationWithStripeImageUrl = festiveCelebrationWithStripeImageUrl, + onRewardsWonGratificationClicked = onRewardsWonGratificationClicked, + rewardsGratificationUIState = rewardsGratificationUIState, ) }, ) @@ -255,6 +254,8 @@ private fun SharedTransitionScope.BottomBarSection( isFestiveThemeExperimentEnabled: Boolean, festiveCelebrationWithCoinsImageUrl: String?, festiveCelebrationWithStripeImageUrl: String?, + onRewardsWonGratificationClicked: () -> Unit, + rewardsGratificationUIState: PaymentSummaryRewardsGratificationUIState, ) { Column(modifier = Modifier.padding(top = 16.dp)) { if (isRewardsCtaVisible) { @@ -274,9 +275,16 @@ private fun SharedTransitionScope.BottomBarSection( festiveCelebrationWithStripeImageUrl = festiveCelebrationWithStripeImageUrl, ) } + RewardsWonGratificationSection( + rewardsGratificationUIState = rewardsGratificationUIState, + onRewardsWonGratificationClicked = onRewardsWonGratificationClicked, + ) Spacer(modifier = Modifier.height(16.dp)) Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = + Modifier.fillMaxWidth() + .background(color = NaviPayColor.bgDefault) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { ThemeRoundedButtonWithImage( @@ -422,7 +430,9 @@ private fun SharedTransitionScope.RewardsBottomBarWithStripSection( Image( painter = - painterResource(id = CommonR.drawable.navi_common_ic_double_right_arrow), + painterResource( + id = CommonR.drawable.navi_common_ic_double_right_arrow_white + ), contentDescription = "", modifier = Modifier.size(20.dp), ) @@ -513,56 +523,55 @@ private fun RewardsWonGratificationSection( Row( modifier = Modifier.fillMaxWidth() - .padding(horizontal = 16.dp) - .border( - width = 1.dp, - color = NaviPayColor.borderDefault, - shape = RoundedCornerShape(4.dp), + .background( + color = NaviPayColor.bgNonEditable, + shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp), ) .clickableDebounce { onRewardsWonGratificationClicked() } .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { - Image( - painter = painterResource(id = CommonR.drawable.ic_np_coin), - contentDescription = "", - modifier = Modifier.size(24.dp), - ) - - Spacer(modifier = Modifier.width(8.dp)) - NaviText( text = stringResource(id = R.string.np_rewards_won_prefix), fontFamily = naviFontFamily, fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), fontSize = 14.sp, color = NaviPayColor.ctaPrimary, + lineHeight = 22.sp, ) + Image( + painter = painterResource(id = CommonR.drawable.ic_np_coin), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + + Spacer(modifier = Modifier.width(2.dp)) + NaviText( - text = - stringResource( - id = - if ( - (rewardsGratificationUIState.amount.toDoubleOrNull() ?: 0.0) == - 1.00 - ) - R.string.np_rewards_won_suffix_singular - else R.string.np_rewards_won_suffix, - rewardsGratificationUIState.amount, - ), + text = "${rewardsGratificationUIState.amount.getDisplayableAmount()}$COMMA ", fontFamily = naviFontFamily, fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), fontSize = 14.sp, color = NaviPayColor.ctaPrimary, + lineHeight = 22.sp, ) - Spacer(modifier = Modifier.weight(1f)) + NaviText( + text = stringResource(id = R.string.np_check_now_small), + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + fontSize = 14.sp, + color = NaviPayColor.ctaPrimary, + lineHeight = 22.sp, + ) + + Spacer(modifier = Modifier.width(8.dp)) Icon( painter = painterResource(id = CommonR.drawable.ic_double_right_arrow_v2), contentDescription = "", - modifier = Modifier.size(20.dp), ) } } 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 4b316b5edd..ff8b7b3682 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 @@ -27,6 +27,7 @@ import com.navi.pay.common.model.view.NaviPaySessionHelper import com.navi.pay.common.repository.CommonRepository import com.navi.pay.common.repository.SharedPreferenceRepository import com.navi.pay.common.usecase.NaviPayConfigUseCase +import com.navi.pay.common.usecase.RefreshGenericOffersUseCase import com.navi.pay.common.utils.DeviceInfoProvider import com.navi.pay.common.utils.NaviPayCommonUtils import com.navi.pay.common.utils.getMetricInfo @@ -94,6 +95,7 @@ constructor( private val transactionRepository: TransactionRepository, private val orderRepository: OrderRepository, val festiveThemeHelper: FestiveThemeHelper, + private val refreshGenericOffersUseCase: RefreshGenericOffersUseCase, ) : NaviPayBaseVM() { val naviPayAnalytics: NaviPayAnalytics.NaviPayPaymentSummary = @@ -311,6 +313,7 @@ constructor( launch { checkForNpsCommsEligibility() } launch { onTransactionCompletionTimeReceived() } launch { evaluateButlerPrediction() } + launch { updateGenericOffersInfo() } } } @@ -820,6 +823,11 @@ constructor( ) } + private suspend fun updateGenericOffersInfo() { + delay(2.seconds) // delay for segments to update at backend + refreshGenericOffersUseCase.execute(screenName = screenName, shouldHardRefresh = true) + } + override val screenName: String get() = NAVI_PAY_PAYMENT_STATUS } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/network/TransactionInitiationType.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/network/TransactionInitiationType.kt index e680d3ffd7..b2b2c9a98c 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/network/TransactionInitiationType.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/network/TransactionInitiationType.kt @@ -28,10 +28,6 @@ enum class TransactionInitiationType { getTransactionInitiationType( sendMoneyScreenSource.transactionInitiationMode.name ) - is SendMoneyScreenSource.QrScan -> - getTransactionInitiationType( - sendMoneyScreenSource.transactionInitiationMode.name - ) is SendMoneyScreenSource.TransactionHistoryDetail -> getTransactionInitiationType(sendMoneyScreenSource.transactionInitiationType) is SendMoneyScreenSource.BankDetailInput -> PAY_TO_BANK_ACCOUNT diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/view/SendMoneyModels.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/view/SendMoneyModels.kt index 77a90dcbc4..d694348147 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/view/SendMoneyModels.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/model/view/SendMoneyModels.kt @@ -237,13 +237,6 @@ sealed class SendMoneyScreenSource : Parcelable { val transactionInitiationMode: TransactionInitiationType, ) : SendMoneyScreenSource() - @Parcelize - @Keep - data class QrScan( - val payeeVpaToDisplay: String, - val transactionInitiationMode: TransactionInitiationType, - ) : SendMoneyScreenSource() - @Parcelize @Keep data class TransactionHistoryDetail( diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyAccountSelectionView.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyAccountSelectionView.kt index 3339fda7b4..0f77952418 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyAccountSelectionView.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyAccountSelectionView.kt @@ -23,7 +23,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration @@ -33,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.navi.base.utils.isNull import com.navi.design.font.FontWeightEnum import com.navi.design.font.getFontWeight import com.navi.design.font.naviFontFamily @@ -65,15 +68,29 @@ fun SendMoneyAccountSelectionView( val isAddAccountEnabled by sendMoneyViewModel.isAddAccountEnabled.collectAsStateWithLifecycle() val paymentAmount by sendMoneyViewModel.paymentAmount.collectAsStateWithLifecycle() val showPayButtonLoader by sendMoneyViewModel.showPayButtonLoader.collectAsStateWithLifecycle() + val sortedOffersList by sendMoneyViewModel.sortedOffersList.collectAsStateWithLifecycle() + val shouldShowBottomUpOfferStripTransition by + sendMoneyViewModel.shouldShowBottomUpOfferStripTransition.collectAsStateWithLifecycle() + + val bestOfferStrip by + remember(sortedOffersList) { + derivedStateOf { + sortedOffersList.firstOrNull { evaluatedOffer -> + evaluatedOffer.disabledData.isNull() + } + } + } val sheetHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f - Column( - modifier = Modifier.fillMaxWidth().heightIn(max = sheetHeight).navigationBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = sheetHeight).navigationBarsPadding()) { Row( - Modifier.padding(16.dp), + Modifier.padding( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = if (bestOfferStrip.isNull()) 16.dp else 4.dp, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { @@ -102,6 +119,23 @@ fun SendMoneyAccountSelectionView( ) } + bestOfferStrip?.let { offer -> + RenderOffersStrip( + modifier = Modifier.fillMaxWidth(), + bestOfferInfo = offer, + totalOffers = sortedOffersList.size, + shouldShowBottomUpOfferStripTransition = shouldShowBottomUpOfferStripTransition, + onOffersStripClicked = { + sendMoneyViewModel.onOffersStripClicked( + isFromAccountSelectionBottomSheet = true + ) + }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + HorizontalDivider(thickness = 4.dp, color = NaviPayColor.bgNonEditable) Box { diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyBottomSheetContent.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyBottomSheetContent.kt index b64727cebc..c983c6e017 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyBottomSheetContent.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyBottomSheetContent.kt @@ -16,6 +16,7 @@ import com.navi.pay.common.arc.ui.ArcNudgeBottomSheetContent import com.navi.pay.common.ui.BottomSheetContentWithIconHeaderPrimarySecondaryButtonCtaLoader import com.navi.pay.common.ui.BottomSheetLoadingScreen import com.navi.pay.common.ui.ConfirmationBottomSheetContent +import com.navi.pay.common.ui.NaviPayOffersBottomSheet import com.navi.pay.common.ui.SuccessBottomSheetContent import com.navi.pay.management.common.sendmoney.model.view.PayeeSeverity import com.navi.pay.management.common.sendmoney.util.SendMoneyBottomSheetType @@ -33,6 +34,7 @@ fun SendMoneyBottomSheetContent( onBackClick: () -> Unit, naviPaySessionAttributes: Map, onArcNudgeClicked: () -> Unit, + onOffersBottomSheetDismissClicked: () -> Unit, ) { when (bottomSheetType) { is SendMoneyBottomSheetType.Loading -> @@ -146,5 +148,16 @@ fun SendMoneyBottomSheetContent( ), ) } + is SendMoneyBottomSheetType.OfferList -> { + NaviPayOffersBottomSheet( + offerData = bottomSheetType.offerData, + closeSheet = + if (bottomSheetType.isFromAccountSelectionBottomSheet) + onOffersBottomSheetDismissClicked + else onDismissClicked, + heading = bottomSheetType.heading, + params = bottomSheetType.params, + ) + } } } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyMainScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyMainScreen.kt index b2d3abaebd..e752e04ac3 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyMainScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyMainScreen.kt @@ -7,6 +7,12 @@ package com.navi.pay.management.common.sendmoney.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -16,7 +22,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -29,7 +37,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow @@ -44,6 +54,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.navi.base.utils.EMPTY +import com.navi.base.utils.isNull import com.navi.common.R as CommonR import com.navi.design.font.FontWeightEnum import com.navi.design.font.getFontWeight @@ -56,6 +68,7 @@ import com.navi.pay.analytics.NaviPayAnalytics import com.navi.pay.common.model.view.ShakeConfig import com.navi.pay.common.theme.color.NaviPayColor import com.navi.pay.common.ui.NaviPayHeader +import com.navi.pay.common.ui.OfferStripView import com.navi.pay.common.ui.PayeeDetailsView import com.navi.pay.common.ui.rememberShakeController import com.navi.pay.common.utils.NaviPayCommonUtils @@ -71,6 +84,7 @@ import com.navi.pay.utils.AMOUNT_MAX_LENGTH_RCC import com.navi.pay.utils.conditional import com.navi.pay.utils.customHide import com.navi.pay.utils.shake +import com.navi.rr.common.models.EvaluatedOffer @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -115,6 +129,9 @@ fun MainScreen( val view = LocalView.current val amountFieldShakeController = rememberShakeController() val showPayButtonLoader by sendMoneyViewModel.showPayButtonLoader.collectAsStateWithLifecycle() + val sortedOffersList by sendMoneyViewModel.sortedOffersList.collectAsStateWithLifecycle() + val shouldShowBottomUpOfferStripTransition by + sendMoneyViewModel.shouldShowBottomUpOfferStripTransition.collectAsStateWithLifecycle() sendMoneyViewModel.payeeEntityFromSource?.note?.let { sourceNote -> if (sourceNote.isNotBlank()) { @@ -136,6 +153,15 @@ fun MainScreen( val scrollState = rememberScrollState() val showHeadingInTopBar by remember { derivedStateOf { scrollState.value > 50 } } + val bestOfferInfo by + remember(sortedOffersList) { + derivedStateOf { + sortedOffersList.firstOrNull { evaluatedOffer -> + evaluatedOffer.disabledData.isNull() + } + } + } + Scaffold( modifier = modifier, topBar = { @@ -161,12 +187,14 @@ fun MainScreen( resourceId = R.string.np_send_money_collect_request_header_title, name = payeeEntity.name, ) + transactionType == UpiTransactionType.SELF_PAY -> getHeaderText( showHeadingInTopBar = showHeadingInTopBar, resourceId = R.string.np_self_transfer_to, name = payeeEntity.bankName.orEmpty(), ) + else -> getHeaderText( showHeadingInTopBar = showHeadingInTopBar, @@ -184,6 +212,12 @@ fun MainScreen( titleColor = if (showHeadingInTopBar) NaviPayColor.textPrimary else NaviPayColor.textTertiary, + actionIconText = stringResource(R.string.help), + onActionClick = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + sendMoneyViewModel.onHelpCtaClicked() + }, ) } }, @@ -266,6 +300,25 @@ fun MainScreen( keyboardController?.customHide(context = context, view = view) }, ) + + if (bestOfferInfo != null) { + Spacer(modifier = Modifier.height(16.dp)) + } + + RenderOffersStrip( + modifier = Modifier.fillMaxWidth(), + bestOfferInfo = bestOfferInfo, + totalOffers = sortedOffersList.size, + shouldShowBottomUpOfferStripTransition = shouldShowBottomUpOfferStripTransition, + onOffersStripClicked = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + sendMoneyViewModel.onOffersStripClicked( + isFromAccountSelectionBottomSheet = false + ) + }, + ) + Spacer(modifier = Modifier.height(58.dp)) Spacer(modifier = Modifier.weight(1f)) } @@ -301,6 +354,68 @@ fun MainScreen( ) } +@Composable +fun RenderOffersStrip( + modifier: Modifier = Modifier, + bestOfferInfo: EvaluatedOffer?, + totalOffers: Int, + shouldShowBottomUpOfferStripTransition: Boolean, + onOffersStripClicked: () -> Unit, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, +) { + var isInitialRender by remember { mutableStateOf(true) } + AnimatedContent( + modifier = modifier, + targetState = bestOfferInfo, + transitionSpec = { + val slideDirection = + if (shouldShowBottomUpOfferStripTransition && !isInitialRender) + AnimatedContentTransitionScope.SlideDirection.Up + else AnimatedContentTransitionScope.SlideDirection.Down + + (slideIntoContainer( + towards = slideDirection, + animationSpec = tween(durationMillis = 300), + ) + + fadeIn(animationSpec = tween(durationMillis = 300)).apply { + if (slideDirection == AnimatedContentTransitionScope.SlideDirection.Down) { + Modifier.offset(y = (-8).dp) + } + }) togetherWith + (slideOutOfContainer( + towards = slideDirection, + animationSpec = tween(durationMillis = 300), + ) + + fadeOut(animationSpec = tween(durationMillis = 300)).apply { + if (slideDirection == AnimatedContentTransitionScope.SlideDirection.Up) { + Modifier.offset(y = 8.dp) + } + }) + }, + label = EMPTY, + ) { offerInfo -> + if (offerInfo != null) { + isInitialRender = false + } + Row( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(horizontal = 16.dp), + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + ) { + offerInfo?.let { + OfferStripView( + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + bestOfferData = it.offer, + totalOffers = totalOffers, + onOffersStripClicked = onOffersStripClicked, + ) + } + } + } +} + @Composable fun getHeaderText(showHeadingInTopBar: Boolean, resourceId: Int, name: String): String { return if (showHeadingInTopBar) { diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyScreen.kt index a2f815f934..62133e01a0 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/ui/SendMoneyScreen.kt @@ -46,6 +46,7 @@ import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics import com.navi.pay.common.model.view.NaviPayButtonAction import com.navi.pay.common.model.view.NaviPayScreenType +import com.navi.pay.common.setup.NaviPayRouter import com.navi.pay.common.theme.color.NaviPayColor import com.navi.pay.common.ui.FullScreenLottie import com.navi.pay.common.ui.FullScreenLottieV2 @@ -353,6 +354,12 @@ fun SendMoneyScreen( sendMoneyViewModel.triggerDismissBottomSheet.collectLatest { dismissBottomSheet() } } + LaunchedEffect(Unit) { + sendMoneyViewModel.navigateToNextScreenFromHelpCta.collect { + it?.let { NaviPayRouter.onCtaClick(naviPayActivity = naviPayActivity, ctaData = it) } + } + } + val onPayButtonClick = { payButtonSource: String -> if (sendMoneyViewModel.isAmountValid()) { // TODO: move to single VM function focusManager.clearFocus() @@ -485,6 +492,8 @@ fun SendMoneyScreen( sendMoneyViewModel.onArcNudgeClicked() naviPayAnalytics.onArcNudgeClickedFromBottomSheet() }, + onOffersBottomSheetDismissClicked = + sendMoneyViewModel::onOffersBottomSheetDismissClicked, ) } } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/NaviPayOffersHelper.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/NaviPayOffersHelper.kt new file mode 100644 index 0000000000..68500dc497 --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/NaviPayOffersHelper.kt @@ -0,0 +1,230 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.management.common.sendmoney.util + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.base.utils.isNull +import com.navi.common.model.ModuleNameV2 +import com.navi.pay.common.model.config.NaviPayDefaultConfig +import com.navi.pay.common.usecase.NaviPayConfigUseCase +import com.navi.pay.common.utils.getMetricInfo +import com.navi.pay.network.di.NaviPayGsonBuilder +import com.navi.pay.onboarding.account.detail.model.view.LinkedAccountEntity +import com.navi.pay.utils.BAU +import com.navi.pay.utils.DEFAULT_CONFIG +import com.navi.pay.utils.DEFAULT_UPI_PURPOSE +import com.navi.pay.utils.NAVI_PAY_GENERIC_OFFERS_INFO +import com.navi.pay.utils.NAVI_PAY_UPI_LITE_SEND_MONEY_PURPOSE_CODE +import com.navi.pay.utils.isAccountIdOfTypeUpiLite +import com.navi.rr.common.models.EvaluatedOffer +import com.navi.rr.common.models.OfferData +import com.navi.rr.common.models.OfferRequest +import com.navi.rr.common.models.OfferResponse +import com.navi.rr.common.models.OfferType +import com.navi.rr.common.repo.OffersRepo +import com.navi.rr.utils.constants.Constants.UPI +import com.navi.rr.utils.mapper.OfferResponseToOfferDataMapper +import com.navi.rr.utils.sortOffersWithCondition +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext + +/** + * Helper class for handling offers. It provides functions to fetch generic offers, evaluate the + * best offers, and sort offers based on conditions. + */ +class NaviPayOffersHelper +@Inject +constructor( + private val offersRepo: OffersRepo, + private val naviCacheRepository: NaviCacheRepository, + private val naviPayConfigUseCase: NaviPayConfigUseCase, + @NaviPayGsonBuilder private val gson: Gson, +) { + private var currentBestOfferIndex = Int.MAX_VALUE + + suspend fun getOffersFromNetwork( + screenName: String, + attributeMap: Map? = null, + ): List { + val offersData = + offersRepo + .fetchOffersForProduct( + product = UPI, + attributes = attributeMap, + metricInfo = getMetricInfo(screenName = screenName), + ) + .firstOrNull() + + val offerResponseToOfferDataMapper = OfferResponseToOfferDataMapper() + val offersDataList = + offersData?.data?.map { + offerResponseToOfferDataMapper.map( + offerResponse = it, + vertical = ModuleNameV2.NAVIPAY, + ) + } ?: emptyList() + + val validOffersList = + offersDataList.filter { !it.titlePrefix.isNullOrEmpty() }.toMutableList() + + if (validOffersList.isEmpty()) { + return emptyList() + } + + validOffersList[0] = validOffersList[0].copy(isBestOffer = true) + return validOffersList + } + + suspend fun getCachedOffers(screenName: String): List { + val defaultConfig = getDefaultConfig(screenName = screenName) + + val genericOffersInfo = + naviCacheRepository.get(key = NAVI_PAY_GENERIC_OFFERS_INFO) + ?: return getOfferListFromConfig(defaultConfig = defaultConfig) + + return gson.fromJson(genericOffersInfo.value, object : TypeToken>() {}.type) + } + + /** + * Fetches the best offer list for multiple items. + * + * @param screenName The name of the screen for which offers are to be fetched. + * @param offerRequestList A list of offer requests. + * @return A map of product identifiers to their corresponding best offer data. + */ + suspend fun getBestOfferListForMultipleItems( + screenName: String, + offerRequestList: List, + ): Map? { + val response = + offersRepo + .fetchOffersForProductForMultipleRequests( + product = UPI, + offerRequestList = offerRequestList, + metricInfo = getMetricInfo(screenName), + offerType = OfferType.BEST.name, + ) + .firstOrNull() + + response?.data?.let { multipleOffersResponse -> + val offerResponseToOfferDataMapper = OfferResponseToOfferDataMapper() + return multipleOffersResponse.nudgeResponseBundle.mapValues { (_, responses) -> + responses.firstOrNull()?.let { bestOfferResponse -> + offerResponseToOfferDataMapper + .map(offerResponse = bestOfferResponse, vertical = ModuleNameV2.NAVIPAY) + .takeIf { !it.titlePrefix.isNullOrEmpty() } ?: OfferData() + } ?: OfferData() + } + } + return null + } + + fun evaluateOfferStripAnimation( + sortedOffersList: List, + offersDataList: List, + paymentAmount: String, + ): Boolean { + if (sortedOffersList.isEmpty()) { + return true + } + val bestOffer = sortedOffersList.firstOrNull { it.disabledData.isNull() } ?: return true + val bestOfferIndex = offersDataList.indexOf(bestOffer.offer) + + return if (bestOfferIndex == 0 && currentBestOfferIndex == Int.MAX_VALUE) true + else if (bestOfferIndex < currentBestOfferIndex) { + currentBestOfferIndex = if (paymentAmount.isBlank()) Int.MAX_VALUE else bestOfferIndex + paymentAmount.isNotBlank() + } else { + currentBestOfferIndex = bestOfferIndex + false + } + } + + fun getSortedOffersListWithCondition( + offers: List, + paymentAmount: String, + selectedBankAccount: LinkedAccountEntity?, + payeeMcc: String?, + purposeCode: String?, + getBestOffer: Boolean, + ): List { + val paramsMap = + buildParamsMap( + paymentAmount = paymentAmount, + selectedBankAccount = selectedBankAccount, + payeeMcc = payeeMcc, + purposeCode = purposeCode, + ) + val sortedOffersWithCondition = sortOffersWithCondition(offers = offers, params = paramsMap) + if (getBestOffer) { + val appliedOffer = sortedOffersWithCondition.firstOrNull { it.disabledData.isNull() } + return appliedOffer?.let { listOf(it) } ?: emptyList() + } + return sortedOffersWithCondition + } + + fun buildParamsMap( + paymentAmount: String, + selectedBankAccount: LinkedAccountEntity?, + payeeMcc: String?, + purposeCode: String?, + ): MutableMap { + if (paymentAmount.isBlank()) return mutableMapOf() + + val paramsMap = + mutableMapOf( + OfferResponse.SessionAttributesInfo.Type.TXN_AMOUNT to paymentAmount + ) + + selectedBankAccount?.let { account -> + val isUpiLiteAccount = account.accountId.isAccountIdOfTypeUpiLite() + paramsMap.apply { + this[OfferResponse.SessionAttributesInfo.Type.UPI_PURPOSE_CODE] = + if (isUpiLiteAccount) NAVI_PAY_UPI_LITE_SEND_MONEY_PURPOSE_CODE + else purposeCode ?: DEFAULT_UPI_PURPOSE + this[OfferResponse.SessionAttributesInfo.Type.UPI_PAYER_BANK_CODE] = + account.bankCode.takeUnless { isUpiLiteAccount } + } + } + + payeeMcc + ?.takeIf { it.isNotBlank() } + ?.let { paramsMap[OfferResponse.SessionAttributesInfo.Type.UPI_PAYEE_MCC] = it } + return paramsMap + } + + private fun getOfferListFromConfig(defaultConfig: NaviPayDefaultConfig): List { + val bauOfferInfo = defaultConfig.config.genericBauOffer + return listOf( + OfferData( + titlePrefix = bauOfferInfo.titlePrefix, + titleSuffix = bauOfferInfo.titleSuffix, + descriptionPrefix = bauOfferInfo.descriptionPrefix, + descriptionSuffix = bauOfferInfo.descriptionSuffix, + iconUrl = bauOfferInfo.iconUrl, + applicableInfo = bauOfferInfo.applicableInfo, + isBestOffer = true, + tags = listOf(BAU), + ) + ) + } + + private suspend fun getDefaultConfig(screenName: String): NaviPayDefaultConfig { + return withContext(Dispatchers.IO) { + naviPayConfigUseCase.execute( + configKey = DEFAULT_CONFIG, + type = object : TypeToken() {}.type, + screenName = screenName, + ) ?: NaviPayDefaultConfig() + } + } +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/SendMoneyUtils.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/SendMoneyUtils.kt index 90b16390d3..94bcccf646 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/SendMoneyUtils.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/util/SendMoneyUtils.kt @@ -8,6 +8,8 @@ package com.navi.pay.management.common.sendmoney.util import androidx.annotation.StringRes +import com.navi.rr.common.models.OfferData +import com.navi.rr.common.models.OfferResponse object SendMoneyUtils { @@ -82,4 +84,11 @@ sealed class SendMoneyBottomSheetType { val isLocalArcTransactionCounterWidgetEnabled: Boolean, val localArcTransactionCounterFormatted: String, ) : SendMoneyBottomSheetType() + + data class OfferList( + val offerData: List, + val heading: String, + val params: Map?, + val isFromAccountSelectionBottomSheet: Boolean, + ) : SendMoneyBottomSheetType() } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/viewmodel/SendMoneyViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/viewmodel/SendMoneyViewModel.kt index b599a3ba82..1a559d320b 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/viewmodel/SendMoneyViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/sendmoney/viewmodel/SendMoneyViewModel.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.google.common.util.concurrent.AtomicDouble import com.google.gson.reflect.TypeToken +import com.navi.base.model.CtaData import com.navi.base.utils.ResourceProvider import com.navi.base.utils.isNotNull import com.navi.base.utils.isNotNullAndNotEmpty @@ -81,6 +82,7 @@ import com.navi.pay.common.usecase.UpiRequestIdUseCase import com.navi.pay.common.usecase.ValidateVpaUseCase import com.navi.pay.common.utils.DeviceInfoProvider import com.navi.pay.common.utils.NaviPayCommonUtils +import com.navi.pay.common.utils.NaviPayCommonUtils.getHelpCtaData import com.navi.pay.common.utils.NaviPaySdkUtils import com.navi.pay.common.utils.getDefaultPayeeEntity import com.navi.pay.common.utils.getMetricInfo @@ -118,6 +120,7 @@ import com.navi.pay.management.common.sendmoney.model.view.UpiTransactionType import com.navi.pay.management.common.sendmoney.repository.SendMoneyRepository import com.navi.pay.management.common.sendmoney.util.AccountEligibilityMerchantHelper import com.navi.pay.management.common.sendmoney.util.AccountTypeEligibility +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper import com.navi.pay.management.common.sendmoney.util.SendMoneyBottomSheetType import com.navi.pay.management.common.sendmoney.util.SendMoneyUtils.getValidatedAmountNumber import com.navi.pay.management.common.transaction.model.network.TransactionInstrumentType @@ -161,13 +164,18 @@ import com.navi.pay.utils.INTENT_OR_SCAN_PAY_TRANSACTION_ERROR import com.navi.pay.utils.INVALID_VPA import com.navi.pay.utils.KEY_IS_FIRST_TRANSACTION_SUCCESSFUL import com.navi.pay.utils.LITE_MAX_SEND_MONEY +import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVI_FESTIVE_THEME import com.navi.pay.utils.NAVI_AXIS_UPI_HANDLE +import com.navi.pay.utils.NAVI_PAY_DEFAULT_MCC import com.navi.pay.utils.NAVI_PAY_OF_TYPE_INTENT_TRANSACTION import com.navi.pay.utils.NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE import com.navi.pay.utils.NAVI_PAY_REWARDS_SCRATCH_CARD_RESPONSE_CACHE_KEY +import com.navi.pay.utils.NAVI_PAY_UPI_LITE_SEND_MONEY_PURPOSE_CODE import com.navi.pay.utils.NOTES_REGEX import com.navi.pay.utils.NOTE_MAX_LENGTH +import com.navi.pay.utils.PAYEE_VPA +import com.navi.pay.utils.PAYER_VPA import com.navi.pay.utils.PAY_AGAIN import com.navi.pay.utils.PENDING_REQUEST_REFRESHED_REQUIRED import com.navi.pay.utils.PSP_DOWN @@ -178,7 +186,11 @@ import com.navi.pay.utils.RUPEE_SYMBOL import com.navi.pay.utils.SAVINGS_ONLY_ENABLED_ACCOUNTS import com.navi.pay.utils.SELF_TRANSFER_ERROR import com.navi.pay.utils.SEND_MONEY_BACK_PRESS_BLOCK_TIME_MILLIS +import com.navi.pay.utils.TXN_AMOUNT import com.navi.pay.utils.UPI_LITE_CONFIG +import com.navi.pay.utils.UPI_PAYEE_MCC +import com.navi.pay.utils.UPI_PAYER_BANK_CODE +import com.navi.pay.utils.UPI_PURPOSE_CODE import com.navi.pay.utils.ZERO_STRING import com.navi.pay.utils.getDisplayableAmount import com.navi.pay.utils.getFormattedAmountWithDecimal @@ -186,6 +198,7 @@ import com.navi.pay.utils.getTrimmedValue import com.navi.pay.utils.isAccountIdOfTypeUpiLite import com.navi.pay.utils.isAmountValidForSendMoney import com.navi.pay.utils.toUpiMapperVpa +import com.navi.rr.common.models.OfferData import com.navi.rr.scratchcard.helper.ScratchCardNudgeHelper import com.ramcosta.composedestinations.spec.Direction import dagger.hilt.android.lifecycle.HiltViewModel @@ -249,6 +262,7 @@ constructor( private val upiLiteCommsAndMandateExecutionHandler: UpiLiteCommsAndMandateExecutionHandler, val accountListCheckBalanceUseCase: AccountListCheckBalanceUseCase, private val sendMoneyUseCase: SendMoneyUseCase, + private val naviPayOffersHelper: NaviPayOffersHelper, private val arcNudgeUseCase: ArcNudgeUseCase, private val naviPayPspManager: NaviPayPspManager, naviPayActivityDataProvider: NaviPayActivityDataProvider, @@ -289,12 +303,19 @@ constructor( private val _bankAccountsState = MutableStateFlow(BankAccountsState.Loading) val bankAccountsState = _bankAccountsState.asStateFlow() + private val _navigateToNextScreenFromHelpCta = MutableSharedFlow() + val navigateToNextScreenFromHelpCta = _navigateToNextScreenFromHelpCta.asSharedFlow() + + private val helpCtaData = getHelpCtaData(screenName = screenName) + private val _selectedBankAccount = MutableStateFlow(null) val selectedBankAccount = _selectedBankAccount.asStateFlow() private val _isSelectedAccountEligible = MutableStateFlow(true) val isSelectedAccountEligible = _isSelectedAccountEligible.asStateFlow() + private val offersDataList = MutableStateFlow>(emptyList()) + private val _bottomSheetStateHolder = MutableStateFlow( SendMoneyScreenBottomSheetStateHolder( @@ -531,7 +552,6 @@ constructor( when (source) { is SendMoneyScreenSource.BankDetailInput -> source.displayableAccountNumber is SendMoneyScreenSource.PhoneContact -> source.payeeVpaToDisplay - is SendMoneyScreenSource.QrScan -> source.payeeVpaToDisplay is SendMoneyScreenSource.TransactionHistoryDetail -> source.payeeVpaToDisplay ?: payeeEntity.value.vpa else -> payeeEntity.value.vpa @@ -539,6 +559,8 @@ constructor( private var accountTypeEligibilityMap: Map = mapOf() + private val isOfferExperienceEnabled = MutableStateFlow(false) + private val _isFestiveThemeExperimentEnabled = MutableStateFlow(false) val isFestiveThemeExperimentEnabled = _isFestiveThemeExperimentEnabled.asStateFlow() @@ -546,6 +568,49 @@ constructor( private val startPaymentJobReference = AtomicReference() + private var fetchOffersJob: Job? = null + + val sortedOffersList = + combine(paymentAmount.debounce(200), offersDataList, selectedBankAccount, payeeEntity) { + paymentAmount, + offersDataList, + selectedBankAccount, + payeeEntity -> + if (offersDataList.isEmpty()) { + emptyList() + } else { + naviPayOffersHelper.getSortedOffersListWithCondition( + offers = offersDataList, + paymentAmount = paymentAmount.text, + selectedBankAccount = selectedBankAccount, + payeeMcc = payeeEntity.mcc, + purposeCode = payeeEntity.purpose, + getBestOffer = isAmountReadOnly, + ) + } + } + .flowOn(coroutineDispatcherProvider.default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList(), + ) + + val shouldShowBottomUpOfferStripTransition = + combine(sortedOffersList, offersDataList) { sortedOffersList, offersDataList -> + naviPayOffersHelper.evaluateOfferStripAnimation( + sortedOffersList = sortedOffersList, + offersDataList = offersDataList, + paymentAmount = paymentAmount.value.text, + ) + } + .flowOn(coroutineDispatcherProvider.default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = true, + ) + init { recordScreenLandTime(screenName) setLitmusExperimentValues() @@ -564,9 +629,54 @@ constructor( ?.variant ?.enabled == true } + + isOfferExperienceEnabled.update { + litmusExperimentsUseCase + .execute(experimentName = LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE) + ?.variant + ?.enabled == true + } } } + private fun fetchOffersList() { + fetchOffersJob = + viewModelScope.launch(coroutineDispatcherProvider.io) { + val attributeMap = + mapOf( + UPI_PAYEE_MCC to (payeeEntity.value.mcc ?: NAVI_PAY_DEFAULT_MCC), + PAYEE_VPA to payeeEntity.value.vpa, + UPI_PAYER_BANK_CODE to selectedBankAccount.value?.bankCode.orEmpty(), + UPI_PURPOSE_CODE to + if ( + selectedBankAccount.value + ?.accountId + ?.isAccountIdOfTypeUpiLite() + .orFalse() + ) { + NAVI_PAY_UPI_LITE_SEND_MONEY_PURPOSE_CODE + } else { + payeeEntity.value.purpose + }, + TXN_AMOUNT to paymentAmount.value.text, + PAYER_VPA to selectedBankAccount.value?.vpa.orEmpty(), + ) + .filterValues { it.isNotNullAndNotEmpty() } + + val offers = + naviPayOffersHelper.getOffersFromNetwork( + screenName = screenName, + attributeMap = attributeMap, + ) + naviPayAnalytics.onOffersCalloutLanded( + numberOfOffers = offers.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + attributeMap = attributeMap.toString(), + ) + offersDataList.update { offers } + } + } + private fun autoTriggerCollectRequestActionListener() { viewModelScope.launch(Dispatchers.IO) { // Waiting for all listeners before triggering auto actions @@ -1314,9 +1424,11 @@ constructor( is SendMoneyScreenSource.TransactionHistoryDetail -> { source.payerVpa } + is SendMoneyScreenSource.CollectRequest -> { source.payerVpa } + else -> "" } @@ -1541,6 +1653,11 @@ constructor( return } + // Before proceeding, cancel the fetchOffers job if active + if (fetchOffersJob?.isActive == true) { + fetchOffersJob?.cancel() + } + if (isPaymentFromLiteAccount) { // For further process we shift to actual accountId selectedBankAccount = selectedBankAccount.deepCopy( @@ -1895,6 +2012,11 @@ constructor( executeAutoSelectionOfBankAccount(preferredBankAccountId = preferredBankAccountUniqueId) + // fetch offers post validate vpa success & bank account selection + if (isOfferExperienceEnabled.value) { + fetchOffersList() + } + checkIfPSPIsDown() } @@ -2284,6 +2406,7 @@ constructor( ) ) } + TransactionStatus.DECLINED -> { updateSetDefaultStatusBarColor(setDefaultStatusBarColor = false) updateScreenState( @@ -2291,6 +2414,7 @@ constructor( SendMoneyScreenState.PaymentDeclined(transactionEntity = transactionEntity) ) } + else -> { initiateRewardScratchCardCalculation(transactionEntity = transactionEntity) if (isPaymentThroughLiteAccount) { @@ -2678,7 +2802,7 @@ constructor( } } - fun declineCollectRequest() { + private fun declineCollectRequest() { // TODO: - Check if we need to handle this for mandate decline case viewModelScope.launch(Dispatchers.IO) { if (source !is SendMoneyScreenSource.CollectRequest) { @@ -2953,11 +3077,14 @@ constructor( when { transactionType == UpiTransactionType.SCAN_PAY -> SendMoneyBackPressAction.GoBack + transactionType == UpiTransactionType.INTENT_PAY || isCollectRequestFromNotification() -> SendMoneyBackPressAction.FinishScreen + else -> SendMoneyBackPressAction.GoBack } } + else -> SendMoneyBackPressAction.GoBack } } @@ -3206,6 +3333,12 @@ constructor( } } + fun onHelpCtaClicked() { + viewModelScope.launch(coroutineDispatcherProvider.default) { + _navigateToNextScreenFromHelpCta.emit(helpCtaData) + } + } + fun resetCheckBalanceStates() { viewModelScope.launch(Dispatchers.IO) { delay(300) // Delay added to wait for navigation to complete before state reset @@ -3253,6 +3386,61 @@ constructor( } } + fun onOffersBottomSheetDismissClicked() { + viewModelScope.launch(Dispatchers.IO) { + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetUIState = SendMoneyBottomSheetType.PayAmount, + bottomSheetStateChange = true, + ) + } + } + + fun onOffersStripClicked(isFromAccountSelectionBottomSheet: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + naviPayAnalytics.onOffersCalloutClicked( + numberOfOffers = sortedOffersList.value.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + isFromAccountSelectionBottomSheet = false, + ) + + if ( + offersDataList.value.isEmpty() || + (isAmountReadOnly && sortedOffersList.value.isEmpty()) + ) { + return@launch + } + + val offerData: List = + if (isAmountReadOnly) listOf(sortedOffersList.value.first().offer) + else offersDataList.value + + val params = + naviPayOffersHelper.buildParamsMap( + paymentAmount = paymentAmount.value.text, + selectedBankAccount = selectedBankAccount.value, + payeeMcc = payeeEntity.value.mcc, + purposeCode = payeeEntity.value.purpose, + ) + + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = true, + bottomSheetUIState = + SendMoneyBottomSheetType.OfferList( + offerData = offerData, + heading = + resourceProvider.getString( + resId = R.string.np_n_upi_payment_offers, + if (isAmountReadOnly) 1 else offersDataList.value.size, + ), + params = params, + isFromAccountSelectionBottomSheet = isFromAccountSelectionBottomSheet, + ), + ) + } + } + override val screenName: String get() = NAVI_PAY_SEND_MONEY } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/ui/UPIIdInputScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/ui/UPIIdInputScreen.kt index d36b2ef828..818acaaaea 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/ui/UPIIdInputScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/ui/UPIIdInputScreen.kt @@ -24,15 +24,17 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.IconButton import androidx.compose.material.OutlinedTextField import androidx.compose.material.Scaffold import androidx.compose.material.TextFieldDefaults +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 @@ -67,20 +69,26 @@ import com.navi.pay.common.theme.color.NaviPayColor import com.navi.pay.common.ui.ContactIconView import com.navi.pay.common.ui.ImageTitleDescriptionShimmerView import com.navi.pay.common.ui.NaviPayHeader +import com.navi.pay.common.ui.NaviPayModalBottomSheet +import com.navi.pay.common.ui.NaviPayOffersBottomSheet import com.navi.pay.common.ui.NaviPaySponsorView import com.navi.pay.common.ui.SelfTransferCtaView import com.navi.pay.entry.NaviPayActivity import com.navi.pay.management.common.model.view.WarningErrorInfoState +import com.navi.pay.management.common.upiid.viewmodel.UPIIDInputScreenBottomSheetUIState import com.navi.pay.management.common.upiid.viewmodel.UPIIdInputViewModel import com.navi.pay.utils.LINKED_ACCOUNT_SCREEN_SOURCE import com.navi.pay.utils.SELF_TRANSFER import com.navi.pay.utils.clickableDebounce import com.navi.pay.utils.customHide import com.navi.pay.utils.initials +import com.navi.rr.common.models.OfferData +import com.navi.rr.common.ui.OffersRolodex import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Destination @Composable fun UPIIdInputScreen( @@ -107,10 +115,30 @@ fun UPIIdInputScreen( upiIdInputViewModel.isWarningOrErrorState.collectAsStateWithLifecycle() val isSelfTransferCtaVisible by upiIdInputViewModel.isSelfTransferCtaVisible.collectAsStateWithLifecycle() + val genericOffersList by upiIdInputViewModel.genericOffersList.collectAsStateWithLifecycle() + val bottomSheetStateHolder by + upiIdInputViewModel.bottomSheetStateHolder.collectAsStateWithLifecycle() val keyboardController = LocalSoftwareKeyboardController.current val context = LocalContext.current val view = LocalView.current + val scope = rememberCoroutineScope() + + val bottomSheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { bottomSheetStateHolder.bottomSheetStateChange }, + ) + + val onDismissBottomSheet: () -> Unit = { + scope + .launch { bottomSheetState.hide() } + .invokeOnCompletion { + if (!bottomSheetState.isVisible || !bottomSheetStateHolder.bottomSheetStateChange) { + upiIdInputViewModel.updateBottomSheetUIState(showBottomSheet = false) + } + } + } LaunchedEffect(key1 = hideKeyboard) { if (hideKeyboard) { @@ -136,6 +164,12 @@ fun UPIIdInputScreen( upiIdInputViewModel.onSavedBeneficiaryItemClicked() } + val onOffersRolodexClicked = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + upiIdInputViewModel.onOffersRolodexClicked() + } + RenderUPIIdInputScreen( upiIdInput = upiIdInput, onUpiIdInputValueChanged = { upiIdInputViewModel.onUPIIdInputValueChange(it) }, @@ -148,7 +182,41 @@ fun UPIIdInputScreen( onSavedBeneficiaryItemClicked = onSavedBeneficiaryItemClicked, onTrailingIconClicked = { upiIdInputViewModel.updateUpiIdInput(TextFieldValue()) }, isSelfTransferCtaVisible = isSelfTransferCtaVisible, + genericOffersList = genericOffersList, + onOffersRolodexClicked = onOffersRolodexClicked, ) + + if (bottomSheetStateHolder.showBottomSheet) { + NaviPayModalBottomSheet( + modifier = Modifier.fillMaxWidth(), + bottomSheetState = bottomSheetState, + onDismissRequest = onDismissBottomSheet, + shouldDismissOnBackPress = true, + bottomSheetContent = { + RenderUPIIDScreenBottomSheetUIState( + bottomSheetUIState = bottomSheetStateHolder.bottomSheetUIState, + onDismissBottomSheet = onDismissBottomSheet, + ) + }, + ) + } +} + +@Composable +fun RenderUPIIDScreenBottomSheetUIState( + bottomSheetUIState: UPIIDInputScreenBottomSheetUIState?, + onDismissBottomSheet: () -> Unit, +) { + when (bottomSheetUIState) { + is UPIIDInputScreenBottomSheetUIState.OfferList -> { + NaviPayOffersBottomSheet( + offerData = bottomSheetUIState.offerData, + closeSheet = onDismissBottomSheet, + heading = bottomSheetUIState.heading, + ) + } + else -> {} + } } @Composable @@ -164,6 +232,8 @@ fun RenderUPIIdInputScreen( onSavedBeneficiaryItemClicked: () -> Unit, onTrailingIconClicked: () -> Unit, isSelfTransferCtaVisible: Boolean, + genericOffersList: List, + onOffersRolodexClicked: () -> Unit, ) { val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current @@ -305,7 +375,6 @@ fun RenderUPIIdInputScreen( .clickableDebounce { onSavedBeneficiaryItemClicked() }, ) } else { - Row( verticalAlignment = Alignment.CenterVertically, modifier = @@ -366,6 +435,15 @@ fun RenderUPIIdInputScreen( ImageTitleDescriptionShimmerView() } + OffersRolodex( + modifier = + Modifier.fillMaxWidth() + .padding(top = 24.dp, bottom = 8.dp, start = 16.dp, end = 16.dp), + offerData = genericOffersList, + shouldShowRolodexAnimation = genericOffersList.size > 1, + onClick = onOffersRolodexClicked, + ) + Spacer(modifier = Modifier.weight(1f)) } }, diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIDInputScreenBottomSheetHolder.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIDInputScreenBottomSheetHolder.kt new file mode 100644 index 0000000000..ef9fdcea5c --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIDInputScreenBottomSheetHolder.kt @@ -0,0 +1,14 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.management.common.upiid.viewmodel + +data class UPIIDInputScreenBottomSheetHolder( + val showBottomSheet: Boolean, + val bottomSheetStateChange: Boolean, + val bottomSheetUIState: UPIIDInputScreenBottomSheetUIState?, +) diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIdInputViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIdInputViewModel.kt index e44ff76deb..9ab72d03f5 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIdInputViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/common/upiid/viewmodel/UPIIdInputViewModel.kt @@ -15,6 +15,7 @@ import com.navi.base.utils.ResourceProvider import com.navi.common.di.CoroutineDispatcherProvider import com.navi.common.network.models.RepoResult import com.navi.common.network.models.isSuccessWithData +import com.navi.common.usecase.LitmusExperimentsUseCase import com.navi.common.utils.Constants.AT_THE_RATE import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics @@ -35,11 +36,13 @@ import com.navi.pay.destinations.SendMoneyScreenDestination import com.navi.pay.management.common.model.view.WarningErrorInfoState import com.navi.pay.management.common.sendmoney.model.view.PayeeEntity import com.navi.pay.management.common.sendmoney.model.view.SendMoneyScreenSource +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper import com.navi.pay.utils.DEFAULT_CONFIG import com.navi.pay.utils.INVALID_UPI_ID import com.navi.pay.utils.INVALID_UPI_NUMBER import com.navi.pay.utils.INVALID_VPA import com.navi.pay.utils.INVALID_VPA_HANDLE +import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE import com.navi.pay.utils.MAPPING_DOES_NOT_EXIST import com.navi.pay.utils.MCC_MISMATCH import com.navi.pay.utils.NAVI_PAY_SEARCH_QUERY_API_DELAY @@ -49,6 +52,7 @@ import com.navi.pay.utils.isValidUpiId import com.navi.pay.utils.isValidUpiNumber import com.navi.pay.utils.isValidUpiNumberLength import com.navi.pay.utils.toUpiMapperVpa +import com.navi.rr.common.models.OfferData import com.ramcosta.composedestinations.spec.Direction import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -80,6 +84,8 @@ constructor( private val naviPaySessionHelper: NaviPaySessionHelper, private val validateVpaUseCase: ValidateVpaUseCase, private val resourceProvider: ResourceProvider, + private val naviPayOffersHelper: NaviPayOffersHelper, + private val litmusExperimentsUseCase: LitmusExperimentsUseCase, ) : NaviPayBaseVM() { private val naviPayAnalytics: NaviPayAnalytics.NaviPayUpiIdInput = @@ -106,6 +112,16 @@ constructor( private val _navigateToNextScreen = MutableSharedFlow() val navigateToNextScreen = _navigateToNextScreen.asSharedFlow() + private val _bottomSheetStateHolder = + MutableStateFlow( + UPIIDInputScreenBottomSheetHolder( + showBottomSheet = false, + bottomSheetStateChange = false, + bottomSheetUIState = null, + ) + ) + val bottomSheetStateHolder = _bottomSheetStateHolder.asStateFlow() + private val _showShimmer = MutableStateFlow(false) val showShimmer = _showShimmer.asStateFlow() @@ -117,6 +133,9 @@ constructor( private val userPhoneNumber = fetchUserPhoneNumber() + private val _genericOffersList = MutableStateFlow>(emptyList()) + val genericOffersList = _genericOffersList.asStateFlow() + private val _isSelfTransferCtaVisible = MutableStateFlow(false) val isSelfTransferCtaVisible = _isSelfTransferCtaVisible.asStateFlow() @@ -170,6 +189,7 @@ constructor( } init { + getGenericOffersList() updateNaviPayDefaultConfig() viewModelScope.launch(Dispatchers.IO) { upiIdChangeObserver() } } @@ -185,6 +205,25 @@ constructor( } } + private fun getGenericOffersList() { + viewModelScope.launch(Dispatchers.IO) { + val isOfferExperienceEnabled = + litmusExperimentsUseCase + .execute(experimentName = LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE) + ?.variant + ?.enabled ?: false + + if (isOfferExperienceEnabled) { + val genericOffersList = naviPayOffersHelper.getCachedOffers(screenName = screenName) + naviPayAnalytics.onOffersCalloutLanded( + numberOfOffers = genericOffersList.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + _genericOffersList.update { genericOffersList } + } + } + } + fun updateHideKeyboard(hideKeyboard: Boolean) { _hideKeyboard.update { hideKeyboard } } @@ -441,6 +480,48 @@ constructor( } } + fun updateBottomSheetUIState( + showBottomSheet: Boolean, + bottomSheetStateChange: Boolean? = null, + bottomSheetUIState: UPIIDInputScreenBottomSheetUIState? = null, + ) { + _bottomSheetStateHolder.update { + it.copy( + showBottomSheet = showBottomSheet, + bottomSheetStateChange = bottomSheetStateChange ?: it.bottomSheetStateChange, + bottomSheetUIState = bottomSheetUIState ?: it.bottomSheetUIState, + ) + } + } + + fun onOffersRolodexClicked() { + viewModelScope.launch(Dispatchers.IO) { + naviPayAnalytics.onOffersCalloutClicked( + numberOfOffers = genericOffersList.value.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = true, + bottomSheetUIState = + UPIIDInputScreenBottomSheetUIState.OfferList( + offerData = genericOffersList.value, + heading = + resourceProvider.getString( + resId = R.string.np_n_upi_payment_offers, + genericOffersList.value.size, + ), + ), + ) + } + } + override val screenName: String get() = NAVI_PAY_UPI_ID_INPUT } + +sealed class UPIIDInputScreenBottomSheetUIState { + data class OfferList(val offerData: List, val heading: String) : + UPIIDInputScreenBottomSheetUIState() +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/bank/ui/BankDetailInputScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/bank/ui/BankDetailInputScreen.kt index 406a9926d4..62fbe49f9a 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/bank/ui/BankDetailInputScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/bank/ui/BankDetailInputScreen.kt @@ -195,7 +195,7 @@ fun RenderBankDetailInputScreen( topBar = { NaviPayHeader( navigationIcon = navigationIcon, - title = stringResource(id = R.string.send_money_to_bank), + title = stringResource(id = R.string.np_pay_to_bank_account), onNavigationIconClick = onBackClick, modifier = Modifier, ) @@ -208,18 +208,19 @@ fun RenderBankDetailInputScreen( Spacer(modifier = Modifier.height(16.dp)) NaviText( - text = stringResource(id = R.string.recipient_detail_header), + text = stringResource(id = R.string.np_enter_receiver_details), fontFamily = naviFontFamily, - fontSize = 18.sp, - fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), - color = NaviPayColor.inputFieldFilled, + fontSize = 24.sp, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviPayColor.textPrimary, modifier = Modifier.padding(horizontal = 16.dp), ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) InputTextFieldWithDescriptionHeader( headerString = stringResource(id = R.string.np_account_number), + headerStringFontSize = 16.sp, placeHolderString = stringResource(id = R.string.bank_input_account_num_placeholder), value = accountNumberInput, @@ -240,6 +241,7 @@ fun RenderBankDetailInputScreen( InputTextFieldWithDescriptionHeader( headerString = stringResource(id = R.string.re_enter_account_number), + headerStringFontSize = 16.sp, placeHolderString = stringResource(id = R.string.bank_input_reaccount_num_placeholder), value = reAccountNumberInput, @@ -270,6 +272,7 @@ fun RenderBankDetailInputScreen( InputTextFieldWithDescriptionHeader( headerString = stringResource(id = R.string.ifsc), + headerStringFontSize = 16.sp, placeHolderString = stringResource(id = R.string.bank_input_ifsc_placeholder), value = ifscInput, imeAction = ImeAction.Next, @@ -308,6 +311,7 @@ fun RenderBankDetailInputScreen( InputTextFieldWithDescriptionHeader( headerString = stringResource(id = R.string.recipient_name), + headerStringFontSize = 16.sp, placeHolderString = stringResource(id = R.string.bank_input_recipient_placeholder), imeAction = ImeAction.Done, diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt index aa6d4e97f1..0ee7a9a219 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt @@ -25,28 +25,20 @@ import androidx.camera.lifecycle.ExperimentalCameraProviderConfiguration import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.mlkit.vision.MlKitAnalyzer import androidx.camera.view.PreviewView -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.border 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.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.wrapContentSize -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.SnackbarDuration @@ -57,14 +49,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback @@ -73,8 +63,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -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.compose.ui.viewinterop.AndroidView @@ -104,23 +92,17 @@ import com.navi.naviwidgets.extensions.NaviText import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics import com.navi.pay.common.model.view.NaviPayButtonAction -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.theme.color.NaviPayColor -import com.navi.pay.common.ui.BottomSheetContentWithIconHeaderDescButton import com.navi.pay.common.ui.BottomSheetContentWithIconHeaderPrimarySecondaryButtonCtaLoader import com.navi.pay.common.ui.CameraPermissionView -import com.navi.pay.common.ui.ContactIconView -import com.navi.pay.common.ui.NaviPayLottieAnimation import com.navi.pay.common.ui.NaviPayModalBottomSheet -import com.navi.pay.common.ui.RewardsNudgeWithoutBg -import com.navi.pay.common.ui.StaticSearchBarView +import com.navi.pay.common.ui.NaviPayOffersBottomSheet +import com.navi.pay.common.ui.OfferStripView import com.navi.pay.common.utils.ErrorEventHandler -import com.navi.pay.common.utils.NaviPayCommonUtils.getNormalisedPhoneNumber import com.navi.pay.common.utils.NaviPayEventBus import com.navi.pay.destinations.MandateDetailScreenOfPendingCategoryDestination -import com.navi.pay.destinations.PayToContactsScreenDestination import com.navi.pay.destinations.QrScannerScreenDestination import com.navi.pay.destinations.SendMoneyScreenDestination import com.navi.pay.entry.NaviPayActivity @@ -133,13 +115,12 @@ import com.navi.pay.management.moneytransfer.scanpay.viewmodel.QrScannerBottomSh import com.navi.pay.management.moneytransfer.scanpay.viewmodel.QrScannerViewModel import com.navi.pay.permission.utils.PermissionKeys import com.navi.pay.permission.utils.PermissionUtils -import com.navi.pay.tstore.list.model.view.OrderEntity +import com.navi.pay.utils.BAU import com.navi.pay.utils.DEFAULT_INITIATION_MODE_QR_MANDATE -import com.navi.pay.utils.NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE import com.navi.pay.utils.clearBackStackUpToAndNavigate import com.navi.pay.utils.clickableDebounce -import com.navi.pay.utils.contactInitials import com.navi.pay.utils.launchPermissionSettingsScreen +import com.navi.rr.common.models.OfferData import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlin.time.Duration.Companion.milliseconds @@ -160,16 +141,7 @@ fun QrScannerScreen( ) { val lifecycleOwner = LocalLifecycleOwner.current - - val showLoader by qrScannerViewModel.showLoader.collectAsStateWithLifecycle() - val clickedContactPhoneNumber by - qrScannerViewModel.clickedContactPhoneNumber.collectAsStateWithLifecycle() - val frequentOrdersHeading by - qrScannerViewModel.frequentOrdersHeading.collectAsStateWithLifecycle() - val rewardsNudgeDetailEntity by - qrScannerViewModel.rewardsNudgeDetailEntity.collectAsStateWithLifecycle() - val isFrequentTransactionsEnabled by - qrScannerViewModel.isFrequentTransactionsEnabled.collectAsStateWithLifecycle() + val genericOffersList by qrScannerViewModel.genericOffersList.collectAsStateWithLifecycle() val onBackClick = { navigator.navigateUp(naviPayActivity) } @@ -232,17 +204,16 @@ fun QrScannerScreen( } } - LaunchedEffect(Unit) { - qrScannerViewModel.navigateToNextScreen.collect { navigator.navigate(direction = it) } - } - val bottomSheetStateHolder by qrScannerViewModel.bottomSheetStateHolder.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val bottomSheetState = - rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { false }) + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { bottomSheetStateHolder.bottomSheetStateChange }, + ) DisposableEffect(key1 = lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -269,7 +240,6 @@ fun QrScannerScreen( } val isTorchEnabled by qrScannerViewModel.isTorchEnabled.observeAsState(false) - val frequentOrders by qrScannerViewModel.frequentOrders.collectAsStateWithLifecycle() val cameraPermissionsState = rememberMultiplePermissions( permissions = @@ -284,6 +254,7 @@ fun QrScannerScreen( ) // Camera screen will be shown on recomposition } + NaviPermissionResult.HardDenied -> { naviPayAnalytics.onCameraPermissionDenied( showRationale = false, @@ -294,6 +265,7 @@ fun QrScannerScreen( naviPaySessionAttributes = qrScannerViewModel.getNaviPaySessionAttributes() ) } + NaviPermissionResult.ShowRationale -> { naviPayAnalytics.onCameraPermissionDenied( showRationale = true, @@ -303,25 +275,19 @@ fun QrScannerScreen( } } + LaunchedEffect(Unit) { + naviPayAnalytics.onQrScannerLanded( + isPermissionGranted = cameraPermissionsState.allPermissionsGranted, + naviPaySessionAttributes = qrScannerViewModel.getNaviPaySessionAttributes(), + ) + } + val onAllowPermissionButtonClick = { cameraPermissionsState.launchMultiplePermissionRequest() } val localHapticFeedback = LocalHapticFeedback.current BackHandler { onBackClick() } - LaunchedEffect(Unit) { - qrScannerViewModel.isFrequentOrderListUpdated.collect { - if (it) { - naviPayAnalytics.onQrScannerLanded( - isPermissionGranted = cameraPermissionsState.allPermissionsGranted, - rewardNudgeShown = if (rewardsNudgeDetailEntity != null) "Y" else "N", - naviPaySessionAttributes = qrScannerViewModel.getNaviPaySessionAttributes(), - isFrequentOrderListEmpty = frequentOrders.isEmpty(), - ) - } - } - } - val closeSheet: () -> Unit = { scope .launch { bottomSheetState.hide() } @@ -332,10 +298,6 @@ fun QrScannerScreen( } } - val navigateToPayToContactsScreen = { - navigator.navigate(PayToContactsScreenDestination(openSearchState = true)) - } - val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { @@ -387,6 +349,7 @@ fun QrScannerScreen( ) } } + else -> { closeSheet() } @@ -407,11 +370,12 @@ fun QrScannerScreen( NaviPayModalBottomSheet( modifier = Modifier.fillMaxWidth(), bottomSheetState = bottomSheetState, + shouldDismissOnBackPress = true, onDismissRequest = closeSheet, bottomSheetContent = { QrScannerBottomSheetContent( bottomSheetUIState = bottomSheetStateHolder.bottomSheetUIState, - onInvalidVpaBottomSheetCtaClicked = closeSheet, + closeSheet = closeSheet, onUrlRedirectionPrimaryCtaClicked = { scope.launch { try { @@ -448,46 +412,23 @@ fun QrScannerScreen( naviPaySessionAttributes = qrScannerViewModel.getNaviPaySessionAttributes(), ) } - Column(modifier = Modifier.fillMaxSize()) { - Spacer( - modifier = - Modifier.fillMaxWidth() - .height(144.dp) - .background(color = colorAdjustedContent) - ) - + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier.weight(1f).fillMaxWidth().background(colorAdjustedContent)) if (cameraPermissionsState.allPermissionsGranted) { - rewardsNudgeDetailEntity?.let { - Box( - modifier = - Modifier.fillMaxWidth().background(color = colorAdjustedContent), - contentAlignment = Alignment.Center, - ) { - RewardsNudgeWithoutBg( - modifier = Modifier, - exchangeWidgetBgAlpha = 0.2f, - textColor = NaviPayColor.textWhite, - nudgeDetailEntity = it, - ) - } - } - - Spacer( - modifier = - Modifier.fillMaxWidth() - .height(24.dp) - .background(color = colorAdjustedContent) - ) - Image( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().wrapContentHeight(), painter = painterResource(id = R.drawable.bg_scanner_overlay), contentDescription = stringResource(id = R.string.title_scan_and_pay), contentScale = ContentScale.FillWidth, ) Column( - modifier = Modifier.fillMaxSize().background(color = colorAdjustedContent), + modifier = Modifier.fillMaxWidth().background(colorAdjustedContent), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { Row(verticalAlignment = Alignment.CenterVertically) { TorchButton( @@ -529,209 +470,88 @@ fun QrScannerScreen( onAllowPermissionButtonClick = onAllowPermissionButtonClick, ) } + Box(modifier = Modifier.weight(1f).fillMaxWidth().background(colorAdjustedContent)) } } + NavigationIconWithOtherAppsView( modifier = Modifier.fillMaxWidth().padding(16.dp), needsResult = naviPayActivity.needsResult, onBackClick = onBackClick, cameraPermissionsGranted = cameraPermissionsState.allPermissionsGranted, + genericOffersList = genericOffersList, + onOffersStripClicked = qrScannerViewModel::onOffersStripClicked, ) - - if (isFrequentTransactionsEnabled) { - FrequentOrdersListView( - modifier = - Modifier.fillMaxWidth().height(IntrinsicSize.Min).align(Alignment.BottomCenter), - frequentOrders = frequentOrders, - onFrequentOrderSelected = { orderEntity -> - qrScannerViewModel.initiatePayment(orderEntity = orderEntity) - naviPayAnalytics.onContactSelected( - source = NaviPayScreenType.NAVI_PAY_QR_SCANNER_SCREEN.name, - inContactList = false, - isFromFrequentOrderList = true, - currentSearchQuery = "", - naviPaySessionAttributes = qrScannerViewModel.getNaviPaySessionAttributes(), - isPermissionGranted = cameraPermissionsState.allPermissionsGranted, - orderOfOrderItem = frequentOrders.indexOf(orderEntity), - ) - }, - navigateToPayToContactsScreen = navigateToPayToContactsScreen, - showLoader = showLoader, - clickedContactPhoneNumber = clickedContactPhoneNumber, - frequentOrdersHeading = frequentOrdersHeading, - ) - } } } @Composable -fun FrequentOrdersListView( +fun RenderOffersStrip( + genericOffersList: List, modifier: Modifier = Modifier, - frequentOrders: List, - onFrequentOrderSelected: (OrderEntity) -> Unit, - navigateToPayToContactsScreen: () -> Unit, - showLoader: Boolean, - clickedContactPhoneNumber: String, - frequentOrdersHeading: String, + isCameraPermissionGranted: Boolean, + onOffersStripClicked: () -> Unit, ) { - var verticalDragOffset by remember { mutableFloatStateOf(0f) } - Card( - modifier = - modifier.pointerInput(Unit) { - detectDragGestures( - onDragStart = {}, - onDragEnd = { if (verticalDragOffset < 0) navigateToPayToContactsScreen() }, - onDragCancel = {}, - onDrag = { _, dragAmount -> verticalDragOffset = dragAmount.y }, - ) - }, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - elevation = 24.dp, - ) { - Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Box( + if (genericOffersList.isNotEmpty()) { + val adjustedBgColor = + remember(isCameraPermissionGranted) { + if (isCameraPermissionGranted) NaviPayColor.black.copy(alpha = 0.3f) + else NaviPayColor.bgDefault + } + val adjustedShadowColor = + remember(isCameraPermissionGranted) { + if (isCameraPermissionGranted) NaviPayColor.ctaWhite.copy(alpha = 0.6f) + else NaviPayColor.darkGray.copy(alpha = 0.6f) + } + val bestOfferData = + remember(genericOffersList) { + genericOffersList.firstOrNull { it.tags?.contains(BAU) == true } + } + + if (bestOfferData != null) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Card( modifier = - Modifier.width(32.dp) - .height(4.dp) - .background( - color = NaviPayColor.borderAlt, - shape = RoundedCornerShape(4.dp), + Modifier.shadow( + elevation = 16.dp, + ambientColor = adjustedShadowColor, + spotColor = adjustedShadowColor, + shape = RoundedCornerShape(20.dp), ) - ) - } - Spacer(modifier = Modifier.height(8.dp)) - StaticSearchBarView( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - onSearchBarClicked = navigateToPayToContactsScreen, - placeHolderString = stringResource(id = R.string.search_name_or_mobile), - ) - if (frequentOrders.isNotEmpty()) { - FrequentOrdersSection( - modifier = Modifier.fillMaxWidth().background(color = NaviPayColor.bgDefault), - frequentOrders = frequentOrders, - onFrequentOrderSelected = onFrequentOrderSelected, - showLoader = showLoader, - clickedContactPhoneNumber = clickedContactPhoneNumber, - frequentOrdersHeading = frequentOrdersHeading, - ) - } else { - Spacer(modifier = Modifier.height(30.dp)) - } - } - } -} - -@Composable -fun FrequentOrdersSection( - modifier: Modifier, - frequentOrders: List, - onFrequentOrderSelected: (OrderEntity) -> Unit, - showLoader: Boolean, - clickedContactPhoneNumber: String, - frequentOrdersHeading: String, -) { - Column(modifier = modifier.padding(bottom = 8.dp)) { - Spacer(modifier = Modifier.height(16.dp)) - NaviText( - text = frequentOrdersHeading, - fontSize = 16.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), - color = NaviPayColor.textPrimary, - modifier = Modifier.padding(start = 16.dp).wrapContentSize(), - ) - - AnimatedVisibility( - visible = frequentOrders.isNotEmpty(), - enter = slideInVertically(animationSpec = tween(300)), - exit = ExitTransition.None, - ) { - Row( - modifier = - Modifier.fillMaxWidth() - .horizontalScroll(state = rememberScrollState()) - .padding(start = 8.dp, end = 4.dp) - ) { - frequentOrders.forEachIndexed { index, frequentOrderEntity -> - FrequentOrderItem( - index = index, - frequentOrderEntity = frequentOrderEntity, - onFrequentOrderSelected = onFrequentOrderSelected, - showLoader = showLoader, - clickedContactPhoneNumber = clickedContactPhoneNumber, + .wrapContentWidth() + .border( + width = 0.5.dp, + color = NaviPayColor.inputFieldDefault, + shape = RoundedCornerShape(20.dp), + ), + shape = RoundedCornerShape(20.dp), + backgroundColor = adjustedBgColor, + ) { + OfferStripView( + modifier = + Modifier.wrapContentWidth() + .padding(start = 10.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + bestOfferData = bestOfferData, + showLightVariant = isCameraPermissionGranted, + totalOffers = genericOffersList.size, + showOfferAppliedStripText = false, + onOffersStripClicked = onOffersStripClicked, + seperatorHeight = 16.dp, ) - if (index != remember(frequentOrders) { frequentOrders.size } - 1) { - Spacer(modifier = Modifier.width(8.dp)) - } } - Spacer(modifier = Modifier.width(8.dp)) } } } } -@Composable -fun FrequentOrderItem( - index: Int, - frequentOrderEntity: OrderEntity, - onFrequentOrderSelected: (OrderEntity) -> Unit, - showLoader: Boolean, - clickedContactPhoneNumber: String, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier.width(74.dp) - .height(IntrinsicSize.Min) - .clickableDebounce { onFrequentOrderSelected(frequentOrderEntity) } - .padding(horizontal = 2.dp, vertical = 12.dp), - ) { - ContactIconView( - index = index, - contactInitials = frequentOrderEntity.orderTitle.contactInitials(), - phoneNumber = - getNormalisedPhoneNumber( - phoneNumber = - frequentOrderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.mobNo - ), - vpa = frequentOrderEntity.orderDescription, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - if ( - clickedContactPhoneNumber == - frequentOrderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.vpa && showLoader - ) { - NaviPayLottieAnimation( - modifier = Modifier.size(24.dp), - lottieFileName = NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE, - ) - } else { - NaviText( - text = - frequentOrderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.name.orEmpty(), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), - color = NaviPayColor.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.wrapContentSize().padding(bottom = 8.dp), - textAlign = TextAlign.Center, - lineHeight = 18.sp, - ) - } - } -} - @Composable fun NavigationIconWithOtherAppsView( modifier: Modifier = Modifier, needsResult: Boolean, onBackClick: () -> Unit, cameraPermissionsGranted: Boolean, + genericOffersList: List, + onOffersStripClicked: () -> Unit, ) { val navigationIconId = remember(needsResult, cameraPermissionsGranted) { @@ -750,6 +570,15 @@ fun NavigationIconWithOtherAppsView( NaviPayQrScannerOtherAppView(modifier = Modifier.fillMaxWidth()) } } + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.height(96.dp)) + RenderOffersStrip( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + isCameraPermissionGranted = cameraPermissionsGranted, + genericOffersList = genericOffersList, + onOffersStripClicked = onOffersStripClicked, + ) + } } } @@ -984,29 +813,11 @@ private fun NaviPayQrScannerOtherAppView(modifier: Modifier) { @Composable private fun QrScannerBottomSheetContent( bottomSheetUIState: QrScannerBottomSheetUIState?, - onInvalidVpaBottomSheetCtaClicked: () -> Unit, + closeSheet: () -> Unit, onUrlRedirectionPrimaryCtaClicked: (String) -> Unit, onUrlRedirectionSecondaryCtaClicked: () -> Unit, ) { when (bottomSheetUIState) { - QrScannerBottomSheetUIState.InvalidVpa -> { - BottomSheetContentWithIconHeaderDescButton( - iconId = CommonR.drawable.ic_exclamation_red_border, - headerTextId = R.string.np_saved_beneficiary_invalid_vpa_header, - descriptionTextId = R.string.np_saved_beneficiary_invalid_vpa_description, - buttonTextId = R.string.np_okay_got_it, - onButtonClicked = onInvalidVpaBottomSheetCtaClicked, - ) - } - QrScannerBottomSheetUIState.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 = onInvalidVpaBottomSheetCtaClicked, - ) - } is QrScannerBottomSheetUIState.UrlRedirection -> { val descriptionText = buildAnnotatedString { val descriptionInfo = @@ -1060,6 +871,15 @@ private fun QrScannerBottomSheetContent( onSecondaryButtonClicked = onUrlRedirectionSecondaryCtaClicked, ) } + + is QrScannerBottomSheetUIState.OfferList -> { + NaviPayOffersBottomSheet( + offerData = bottomSheetUIState.offerData, + closeSheet = closeSheet, + heading = bottomSheetUIState.heading, + ) + } + else -> { Spacer(modifier = Modifier.height(1.dp)) } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt index f64f79e5ff..2834d006ae 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt @@ -17,52 +17,33 @@ import com.navi.common.di.CoroutineDispatcherProvider import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.NAVI_PAY_AUTO_FLASH_DISABLED import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.NAVI_PAY_AUTO_FLASH_LIGHT_LUX_THRESHOLD -import com.navi.common.model.common.NudgeDetailEntity -import com.navi.common.network.models.RepoResult -import com.navi.common.network.models.isSuccessWithData import com.navi.common.usecase.LitmusExperimentsUseCase -import com.navi.common.usecase.RewardsNudgeEntityFetchUseCase import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics import com.navi.pay.analytics.NaviPayAnalytics.Companion.NAVI_PAY_QR_SCANNER -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.NaviPayButtonAction import com.navi.pay.common.model.view.NaviPayButtonTheme import com.navi.pay.common.model.view.NaviPayErrorButtonConfig import com.navi.pay.common.model.view.NaviPaySessionHelper import com.navi.pay.common.usecase.NaviPayConfigUseCase -import com.navi.pay.common.usecase.ValidateVpaUseCase -import com.navi.pay.common.utils.DeviceInfoProvider -import com.navi.pay.common.utils.FrequentOrdersHelper import com.navi.pay.common.viewmodel.NaviPayBaseVM -import com.navi.pay.destinations.SendMoneyScreenDestination import com.navi.pay.entry.NaviPayActivityDataProvider -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.SendMoneyScreenSource +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper import com.navi.pay.management.moneytransfer.scanpay.LightSensorManager import com.navi.pay.management.moneytransfer.scanpay.model.view.QrError import com.navi.pay.management.moneytransfer.scanpay.model.view.QrScanState import com.navi.pay.management.moneytransfer.scanpay.model.view.QrScannerBottomSheetStateHolder import com.navi.pay.management.moneytransfer.scanpay.model.view.UpiUriResult import com.navi.pay.management.moneytransfer.scanpay.util.UpiUriParser -import com.navi.pay.tstore.list.model.view.OrderEntity import com.navi.pay.utils.DEFAULT_CONFIG -import com.navi.pay.utils.INVALID_VPA -import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_FREQUENT_CONTACT_IN_QR_SCANNER +import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE import com.navi.pay.utils.NAVI_PAY_WIDGET_CLICKED_KEY -import com.navi.pay.utils.NOT_LINKED_TO_UPI -import com.ramcosta.composedestinations.spec.Direction +import com.navi.rr.common.models.OfferData import dagger.hilt.android.lifecycle.HiltViewModel import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -78,18 +59,14 @@ class QrScannerViewModel @Inject constructor( private val upiUriParser: UpiUriParser, - private val deviceInfoProvider: DeviceInfoProvider, private val resourceProvider: ResourceProvider, private val naviPayConfigUseCase: NaviPayConfigUseCase, private val naviPaySessionHelper: NaviPaySessionHelper, private val coroutineDispatcherProvider: CoroutineDispatcherProvider, - private val rewardsNudgeEntityFetchUseCase: RewardsNudgeEntityFetchUseCase, - private val frequentOrdersHelper: FrequentOrdersHelper, - private val naviPayNetworkConnectivity: NaviPayNetworkConnectivity, - private val validateVpaUseCase: ValidateVpaUseCase, private val lightSensorManager: LightSensorManager, private val naviPayActivityDataProvider: NaviPayActivityDataProvider, private val litmusExperimentsUseCase: LitmusExperimentsUseCase, + private val naviPayOffersHelper: NaviPayOffersHelper, ) : NaviPayBaseVM() { private var naviPayDefaultConfig = NaviPayDefaultConfig() @@ -107,9 +84,6 @@ constructor( ) val bottomSheetStateHolder = _bottomSheetStateHolder.asStateFlow() - private val _isFrequentOrderListUpdated = MutableSharedFlow() - val isFrequentOrderListUpdated = _isFrequentOrderListUpdated.asSharedFlow() - private val _qrScanResult = MutableSharedFlow() val qrScanResult = _qrScanResult.asSharedFlow() @@ -119,49 +93,41 @@ constructor( private val _isTorchEnabled = MutableLiveData(false) val isTorchEnabled = _isTorchEnabled as LiveData - private val _navigateToNextScreen = MutableSharedFlow() - val navigateToNextScreen = _navigateToNextScreen.asSharedFlow() - var isQrCodeProcessing: AtomicBoolean = AtomicBoolean(false) - private val _frequentOrders = MutableStateFlow(emptyList()) - val frequentOrders = _frequentOrders.asStateFlow() - - private val _showLoader = MutableStateFlow(false) - val showLoader = _showLoader.asStateFlow() - - private val _clickedContactPhoneNumber = MutableStateFlow("") - val clickedContactPhoneNumber = _clickedContactPhoneNumber.asStateFlow() - - private val frequentOrdersTotalEntries = - MutableStateFlow(naviPayDefaultConfig.config.frequentTransactionsTotalEntries) - - private val frequentOrdersDays = - MutableStateFlow(naviPayDefaultConfig.config.frequentTransactionsDays) - - private val _frequentOrdersHeading = - MutableStateFlow(naviPayDefaultConfig.config.frequentTransactionHeading) - - val frequentOrdersHeading = _frequentOrdersHeading.asStateFlow() - - private val _rewardsNudgeDetailEntity = MutableStateFlow(null) - val rewardsNudgeDetailEntity = _rewardsNudgeDetailEntity.asStateFlow() - private var isAutomaticTorchEnabled = false private val lightSensorLuxValue = MutableStateFlow(Float.MAX_VALUE) - private val _isFrequentTransactionsEnabled = MutableStateFlow(false) - val isFrequentTransactionsEnabled = _isFrequentTransactionsEnabled.asStateFlow() + private val _genericOffersList = MutableStateFlow>(emptyList()) + val genericOffersList = _genericOffersList.asStateFlow() init { - fetchRewardsNudgeDetail() + getGenericOffersList() updateNaviPaySessionId() updateNaviPayDefaultConfig() - updateFrequentOrdersList() triggerEventForShortcutWidget() } + private fun getGenericOffersList() { + viewModelScope.launch(Dispatchers.IO) { + val isOfferExperienceEnabled = + litmusExperimentsUseCase + .execute(experimentName = LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE) + ?.variant + ?.enabled ?: false + + if (isOfferExperienceEnabled) { + val genericOffersList = naviPayOffersHelper.getCachedOffers(screenName = screenName) + naviPayAnalytics.onOffersCalloutLanded( + numberOfOffers = genericOffersList.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + _genericOffersList.update { genericOffersList } + } + } + } + private fun triggerEventForShortcutWidget() { viewModelScope.launch(Dispatchers.IO) { naviPayActivityDataProvider @@ -239,58 +205,38 @@ constructor( type = object : TypeToken() {}.type, screenName = screenName, ) ?: NaviPayDefaultConfig() - - updateFrequentOrderTotalEntries( - naviPayDefaultConfig.config.frequentTransactionsTotalEntries - ) - updateFrequentOrderDays(naviPayDefaultConfig.config.frequentTransactionsDays) - updateFrequentOrderHeading(naviPayDefaultConfig.config.frequentTransactionHeading) } } - private fun updateFrequentOrdersList() { - viewModelScope.launch(coroutineDispatcherProvider.io) { - val frequentOrderJobs = mutableListOf>() + fun onOffersStripClicked() { + naviPayAnalytics.onOffersCalloutClicked( + numberOfOffers = genericOffersList.value.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) - frequentOrderJobs.add( - async { - updateFrequentOrders( - frequentOrdersHelper.getFrequentOrderList( - frequentOrdersTotalEntries = frequentOrdersTotalEntries.value, - frequentOrdersDays = frequentOrdersDays.value, - ) - ) - } - ) - - frequentOrderJobs.add( - async { - // This experiment is for disabling frequent transactions in QR scanner & - // default is enabled - val isFrequentTransactionEnabledLitmusResponse = - litmusExperimentsUseCase - .execute( - experimentName = - LITMUS_EXPERIMENT_NAVIPAY_FREQUENT_CONTACT_IN_QR_SCANNER - ) - ?.variant - ?.enabled - ?.not() ?: true - - _isFrequentTransactionsEnabled.update { - isFrequentTransactionEnabledLitmusResponse - } - } - ) - - frequentOrderJobs.awaitAll() - - _isFrequentOrderListUpdated.emit(value = true) - } + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = true, + bottomSheetUIState = + QrScannerBottomSheetUIState.OfferList( + offerData = genericOffersList.value, + heading = + resourceProvider.getString( + resId = R.string.np_n_upi_payment_offers, + genericOffersList.value.size, + ), + ), + ) } + private fun isOffersBottomSheetVisible() = + bottomSheetStateHolder.value.bottomSheetUIState is QrScannerBottomSheetUIState.OfferList && + bottomSheetStateHolder.value.showBottomSheet + fun processQrContent(qrContent: String, sendQrFromGallery: Boolean = false) { - if (isQrCodeProcessing.get()) return + if (isQrCodeProcessing.get() || isOffersBottomSheetVisible()) { + return + } isQrCodeProcessing.set(true) viewModelScope.safeLaunch(coroutineDispatcherProvider.io) { when { @@ -353,18 +299,6 @@ constructor( } } - private fun updateFrequentOrderTotalEntries(totalEntries: Int) { - frequentOrdersTotalEntries.update { totalEntries } - } - - private fun updateFrequentOrderDays(numberOfDays: Int) { - frequentOrdersDays.update { numberOfDays } - } - - private fun updateFrequentOrderHeading(heading: String) { - _frequentOrdersHeading.update { heading } - } - fun updateBottomSheetUIState( showBottomSheet: Boolean, bottomSheetStateChange: Boolean? = null, @@ -406,22 +340,6 @@ constructor( } } - private fun updateFrequentOrders(frequentOrders: List) { - _frequentOrders.update { frequentOrders } - } - - private fun updateShowLoader(showLoader: Boolean) { - _showLoader.update { showLoader } - } - - private fun updateClickedContactPhoneNumber(phoneNumber: String) { - _clickedContactPhoneNumber.update { phoneNumber } - } - - private suspend fun updateNextScreenDestinationState(direction: Direction) { - _navigateToNextScreen.emit(direction) - } - private fun parseBharatQr(qrContent: String, isQrFromUploadImage: Boolean) { when (val upiResult = upiUriParser.getPayeeEntityFromBharatQr(qrContent = qrContent)) { is UpiUriResult.Success -> { @@ -449,109 +367,6 @@ constructor( } } - fun initiatePayment(orderEntity: OrderEntity) { - viewModelScope.launch(Dispatchers.IO) { - if (!naviPayNetworkConnectivity.isInternetConnected()) { - notifyError(getNoInternetErrorConfig()) - return@launch - } - - updateShowLoader(showLoader = true) - updateClickedContactPhoneNumber( - phoneNumber = orderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.vpa.orEmpty() - ) - val response = - validateVpaUseCase.execute( - request = - ValidateVpaRequest( - deviceData = deviceInfoProvider.getDeviceData(), - vpa = - orderEntity.naviPayTransactionDetailsMetadata.payeeInfo - ?.vpa - .orEmpty(), - merchantCustomerId = deviceInfoProvider.getMerchantCustomerId(), - ), - screenName = screenName, - ) - - if (response.isSuccessWithData()) { - handleVpaCheckSuccessForInitiatingPayment( - response = response, - orderEntity = orderEntity, - ) - } else { - handleVpaCheckFailureForInitiatingPayment(response = response) - } - } - } - - private suspend fun handleVpaCheckFailureForInitiatingPayment( - response: RepoResult - ) { - updateShowLoader(showLoader = false) - updateClickedContactPhoneNumber(phoneNumber = "") - when (response.errors?.getOrNull(index = 0)?.code) { - INVALID_VPA -> { - updateBottomSheetUIState( - showBottomSheet = true, - bottomSheetStateChange = true, - bottomSheetUIState = QrScannerBottomSheetUIState.InvalidVpa, - ) - } - NOT_LINKED_TO_UPI -> { - updateBottomSheetUIState( - showBottomSheet = true, - bottomSheetStateChange = true, - bottomSheetUIState = QrScannerBottomSheetUIState.ContactNotLinked, - ) - } - else -> { - notifyError(response = response) - } - } - } - - private suspend fun handleVpaCheckSuccessForInitiatingPayment( - response: RepoResult, - orderEntity: OrderEntity, - ) { - val validateVpa = response.data!! - val payeeEntity = - PayeeEntity( - name = validateVpa.name, - vpa = validateVpa.vpa, - isMerchant = validateVpa.isMerchant, - isVerifiedVpa = true, - mcc = validateVpa.mcc, - severity = validateVpa.riskParams!!.severity, - bankCode = validateVpa.bankCode, - phoneNumber = orderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.mobNo, - bankIfsc = orderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.bAccIfsc, - bankAccountUniqueId = - orderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.bAccUnqId, - upiNumber = orderEntity.naviPayTransactionDetailsMetadata.payeeInfo?.upiNumber, - isNpciData = validateVpa.isNpciData, - featureTags = validateVpa.featureTags, - ) - val sendMoneyScreenSource = - SendMoneyScreenSource.QrScan( - payeeVpaToDisplay = orderEntity.orderDescription, - transactionInitiationMode = - getTransactionInitiationType( - transactionInitiationType = - orderEntity.naviPayTransactionDetailsMetadata.paymentMode - ), - ) - updateNextScreenDestinationState( - SendMoneyScreenDestination( - payeeEntity = payeeEntity, - sendMoneyScreenSource = sendMoneyScreenSource, - ) - ) - updateShowLoader(showLoader = false) - updateClickedContactPhoneNumber(phoneNumber = "") - } - fun toggleTorchStatus(isTorchButtonClicked: Boolean = true) { naviPayAnalytics.onTorchStatusToggle( lightSensorLuxValue = lightSensorLuxValue.value, @@ -575,12 +390,6 @@ constructor( _qrImageErrorViewEnable.update { qrImageErrorViewEnable } } - private fun fetchRewardsNudgeDetail() { - viewModelScope.launch(Dispatchers.IO) { - _rewardsNudgeDetailEntity.update { rewardsNudgeEntityFetchUseCase.execute() } - } - } - private fun isQrContentOfTypeUrl(qrContent: String): Boolean { return Patterns.WEB_URL.matcher(qrContent).matches() } @@ -599,9 +408,8 @@ constructor( } sealed class QrScannerBottomSheetUIState { - data object InvalidVpa : QrScannerBottomSheetUIState() - - data object ContactNotLinked : QrScannerBottomSheetUIState() - data class UrlRedirection(val url: String) : QrScannerBottomSheetUIState() + + data class OfferList(val offerData: List, val heading: String) : + QrScannerBottomSheetUIState() } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/ui/PayToContactsScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/ui/PayToContactsScreen.kt index 32eed29228..27e8c4c307 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/ui/PayToContactsScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/paytocontacts/ui/PayToContactsScreen.kt @@ -10,9 +10,15 @@ package com.navi.pay.management.paytocontacts.ui import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi +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.PaddingValues @@ -25,27 +31,31 @@ 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.wrapContentSize 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.Surface 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.runtime.setValue 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 @@ -55,33 +65,34 @@ 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.model.common.NudgeDetailEntity import com.navi.common.utils.navigateUp import com.navi.design.common.NaviVerticalGrid 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.NaviPayAnalytics 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.IconWithTitleDescriptionButton 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.NaviPayOffersBottomSheet import com.navi.pay.common.ui.NaviPaySponsorView -import com.navi.pay.common.ui.RewardsNudgeWithBg -import com.navi.pay.common.ui.StaticSearchBarView +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 @@ -98,6 +109,9 @@ import com.navi.pay.utils.NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE 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.navi.rr.common.models.OfferData +import com.navi.rr.common.ui.OffersRolodex import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.launch @@ -111,7 +125,6 @@ fun PayToContactsScreen( navigator: DestinationsNavigator, naviPayAnalytics: NaviPayAnalytics.NaviPayToContacts = NaviPayAnalytics.INSTANCE.NaviPayToContacts(), - openSearchState: Boolean = false, ) { val context = LocalContext.current @@ -125,8 +138,6 @@ fun PayToContactsScreen( payToContactsViewModel.isNewContactVisible.collectAsStateWithLifecycle() val filteredFrequentOrdersList by payToContactsViewModel.filteredFrequentOrdersList.collectAsStateWithLifecycle() - val rewardsNudgeDetailEntity by - payToContactsViewModel.rewardsNudgeDetailEntity.collectAsStateWithLifecycle() val showLoader by payToContactsViewModel.showLoader.collectAsStateWithLifecycle() val showShimmer by payToContactsViewModel.showShimmer.collectAsStateWithLifecycle() val hideKeyboard by payToContactsViewModel.hideKeyboard.collectAsStateWithLifecycle() @@ -139,26 +150,17 @@ fun PayToContactsScreen( payToContactsViewModel.frequentOrdersHeading.collectAsStateWithLifecycle() val shouldShowYourContactsTitle by payToContactsViewModel.shouldShowYourContactsTitle.collectAsStateWithLifecycle() + val genericOffersList by payToContactsViewModel.genericOffersList.collectAsStateWithLifecycle() val onBackClick = { - if (uiState == PayToContactsUIState.Search) { - payToContactsViewModel.cancelPaymentRequest() - if (openSearchState) { - navigator.navigateUp() - } else { - payToContactsViewModel.updateUiState(uiState = PayToContactsUIState.Loaded) - payToContactsViewModel.updateSearchQueryStringState(searchQuery = "") - } - } else { - navigator.navigateUp(naviPayActivity) - } - Unit + payToContactsViewModel.cancelPaymentRequest() + payToContactsViewModel.updateSearchQueryStringState(searchQuery = "") + navigator.navigateUp(naviPayActivity) } LaunchedEffect(Unit) { naviPayAnalytics.onSendToContactsLanded( - rewardNudgeShown = if (rewardsNudgeDetailEntity != null) "Y" else "N", - naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes(), + naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes() ) } @@ -188,6 +190,12 @@ fun PayToContactsScreen( } } + LaunchedEffect(Unit) { + payToContactsViewModel.navigateToNextScreenFromHelpCta.collect { + it?.let { NaviPayRouter.onCtaClick(naviPayActivity = naviPayActivity, ctaData = it) } + } + } + BackHandler { onBackClick() } val scope = rememberCoroutineScope() @@ -250,19 +258,6 @@ fun PayToContactsScreen( readContactsPermissionsState.launchMultiplePermissionRequest() } - val onSearchBarBackClicked = { - payToContactsViewModel.cancelPaymentRequest() - keyboardController.customHide(context = context, view = view) - focusManager.clearFocus() - if (openSearchState) { - navigator.navigateUp() - } else { - payToContactsViewModel.updateUiState(uiState = PayToContactsUIState.Loaded) - payToContactsViewModel.updateSearchQueryStringState(searchQuery = "") - } - Unit - } - val onNewContactSelected: (PhoneContactEntity) -> Unit = { keyboardController?.customHide(context = context, view = view) focusManager.clearFocus() @@ -299,6 +294,12 @@ fun PayToContactsScreen( ) } + val onOffersRolodexClicked = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + payToContactsViewModel.onOffersRolodexClicked() + } + val isContactListNonEmpty by payToContactsViewModel.isSavedContactListNonEmpty.collectAsStateWithLifecycle() @@ -307,7 +308,6 @@ fun PayToContactsScreen( payToContactsViewModel.fetchContacts() } else { naviPayAnalytics.onSendToContactsLoaded( - rewardNudgeShown = if (rewardsNudgeDetailEntity != null) "Y" else "N", naviPaySessionAttributes = payToContactsViewModel.getNaviPaySessionAttributes(), isPermissionGranted = false, isContactListEmpty = contactList.isEmpty(), @@ -325,7 +325,7 @@ fun PayToContactsScreen( bottomSheetContent = { PayToContactsBottomSheetContent( bottomSheetUIState = bottomSheetStateHolder.bottomSheetUIState, - onInvalidVpaBottomSheetCtaClicked = onDismissBottomSheet, + onDismissBottomSheet = onDismissBottomSheet, ) }, ) @@ -342,16 +342,10 @@ fun PayToContactsScreen( onContactSelected = onContactSelected, onNewContactSelected = onNewContactSelected, onBackClick = onBackClick, - onSearchBarClicked = { - if (!showLoader) { - payToContactsViewModel.updateUiState(uiState = PayToContactsUIState.Search) - } - }, frequentOrders = filteredFrequentOrdersList, frequentOrderMaxColumns = payToContactsViewModel.getFrequentOrdersMaxColumns(), frequentOrdersHeading = frequentOrdersHeading, onFrequentOrderSelected = onFrequentOrderSelected, - rewardsNudgeDetailEntity = rewardsNudgeDetailEntity, areAllPermissionsGranted = readContactsPermissionsState.allPermissionsGranted, onPrimaryButtonClicked = onAllowPermissionButtonClicked, hasNonZeroContacts = isContactListNonEmpty, @@ -360,67 +354,55 @@ fun PayToContactsScreen( showShimmer = showShimmer, newContact = newContact, shouldShowYourContactsTitle = shouldShowYourContactsTitle, - ) - PayToContactsUIState.Search -> - RenderPayToContactsSearchScreen( - searchQuery = searchQuery, - onSearchInputValueChange = { - payToContactsViewModel.updateSearchQueryStringState(searchQuery = it) - }, - frequentOrders = filteredFrequentOrdersList, + onSearchInputValueChange = payToContactsViewModel::updateSearchQueryStringState, onTrailingIconClicked = onTrailingIconClicked, - onSearchBarBackClicked = onSearchBarBackClicked, - contactList = contactList, - onContactSelected = onContactSelected, - onNewContactSelected = onNewContactSelected, - isNewContactVisible = isNewContactVisible, - onPrimaryButtonClicked = onAllowPermissionButtonClicked, - onFrequentOrderSelected = onFrequentOrderSelected, - frequentOrderMaxColumns = payToContactsViewModel.getFrequentOrdersMaxColumns(), - frequentOrdersHeading = frequentOrdersHeading, - areAllPermissionsGranted = readContactsPermissionsState.allPermissionsGranted, - hasNonZeroContacts = isContactListNonEmpty, - showLoader = showLoader, - clickedContactPhoneNumber = clickedContactPhoneNumber, - showShimmer = showShimmer, - newContact = newContact, invalidState = invalidState, isEmptyState = isEmptyState, - shouldShowYourContactsTitle = shouldShowYourContactsTitle, + genericOffersList = genericOffersList, + onOffersRolodexClicked = onOffersRolodexClicked, + onHelpCtaClicked = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + payToContactsViewModel.onHelpCtaClicked() + }, ) } } @Composable -fun RenderPayToContactsSearchScreen( +fun RenderPayToContactsScreen( searchQuery: String, - onSearchInputValueChange: (String) -> Unit, - frequentOrders: List, - onTrailingIconClicked: () -> Unit, - onSearchBarBackClicked: () -> Unit, + isNewContactVisible: Boolean, contactList: List, onContactSelected: (PhoneContactEntity) -> Unit, onNewContactSelected: (PhoneContactEntity) -> Unit, - isNewContactVisible: Boolean, - onPrimaryButtonClicked: () -> Unit, - onFrequentOrderSelected: (OrderEntity) -> Unit, + onBackClick: () -> Unit, + frequentOrders: List, frequentOrderMaxColumns: Int, frequentOrdersHeading: String, + onFrequentOrderSelected: (OrderEntity) -> Unit, areAllPermissionsGranted: Boolean, + onPrimaryButtonClicked: () -> Unit, hasNonZeroContacts: Boolean, showLoader: Boolean, clickedContactPhoneNumber: String, showShimmer: Boolean = false, newContact: PhoneContactEntity?, - invalidState: WarningErrorInfoState, - isEmptyState: Boolean = false, shouldShowYourContactsTitle: Boolean, + onSearchInputValueChange: (String) -> Unit, + onTrailingIconClicked: () -> Unit, + invalidState: WarningErrorInfoState, + isEmptyState: Boolean, + genericOffersList: List, + onOffersRolodexClicked: () -> Unit, + onHelpCtaClicked: () -> 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 = @@ -435,8 +417,11 @@ fun RenderPayToContactsSearchScreen( ), topBar = { Column(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.height(16.dp)) - + PayToContactsScreenHeader( + navigationIcon = CommonR.drawable.ic_arrow_left_black_v2, + onBackClick = onBackClick, + onHelpCtaClicked = onHelpCtaClicked, + ) InputTextFieldWithDescriptionHeader( modifier = Modifier.fillMaxWidth() @@ -444,22 +429,18 @@ fun RenderPayToContactsSearchScreen( .focusRequester(focusRequester) .clickable(!showLoader) {}, headerString = null, - placeHolderString = "", - leadingIconId = CommonR.drawable.ic_arrow_left_black_v2, - onLeadingIconClicked = onSearchBarBackClicked, + placeHolderString = stringResource(id = R.string.search_name_or_mobile), + leadingIconId = CommonR.drawable.ic_search, + onLeadingIconClicked = {}, value = searchQuery, onValueChangeListener = onSearchInputValueChange, isTrailingIconEnabled = searchQuery.isNotEmpty(), onTrailingIconClicked = onTrailingIconClicked, isLeadingIconEnabled = true, warningErrorInfoState = invalidState.isErrorState, + enabled = !showLoader, ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - keyboardController?.show() - } - Spacer(modifier = Modifier.height(8.dp)) if (invalidState.isWarningState) { @@ -483,6 +464,34 @@ fun RenderPayToContactsSearchScreen( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) } + + OffersRolodex( + modifier = Modifier.fillMaxWidth().padding(all = 16.dp), + offerData = genericOffersList, + shouldShowRolodexAnimation = genericOffersList.size > 1, + onClick = onOffersRolodexClicked, + ) + + 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 = { @@ -509,98 +518,25 @@ fun RenderPayToContactsSearchScreen( frequentOrdersHeading = frequentOrdersHeading, areAllPermissionsGranted = areAllPermissionsGranted, onPrimaryButtonClicked = onPrimaryButtonClicked, - isSearchState = true, - navigationIcon = 0, - onBackClick = {}, - rewardsNudgeDetailEntity = null, - onSearchBarClicked = {}, + isSearchState = searchQuery.isNotEmpty(), hasNonZeroContacts = hasNonZeroContacts, showLoader = showLoader, clickedContactPhoneNumber = clickedContactPhoneNumber, showShimmer = showShimmer, newContact = newContact, shouldShowYourContactsTitle = shouldShowYourContactsTitle, + scrollState = scrollState, ) } }, ) } -@Composable -fun RenderPayToContactsScreen( - searchQuery: String, - isNewContactVisible: Boolean, - contactList: List, - onContactSelected: (PhoneContactEntity) -> Unit, - onNewContactSelected: (PhoneContactEntity) -> Unit, - onBackClick: () -> Unit, - onSearchBarClicked: () -> Unit, - frequentOrders: List, - frequentOrderMaxColumns: Int, - frequentOrdersHeading: String, - onFrequentOrderSelected: (OrderEntity) -> Unit, - rewardsNudgeDetailEntity: NudgeDetailEntity?, - areAllPermissionsGranted: Boolean, - onPrimaryButtonClicked: () -> Unit, - hasNonZeroContacts: Boolean, - showLoader: Boolean, - clickedContactPhoneNumber: String, - showShimmer: Boolean = false, - newContact: PhoneContactEntity?, - shouldShowYourContactsTitle: Boolean, -) { - - val scope = rememberCoroutineScope() - val context = LocalContext.current - val view = LocalView.current - val keyboardController = LocalSoftwareKeyboardController.current - - Scaffold( - modifier = - Modifier.fillMaxSize() - .nestedScroll( - NaviPayCommonUtils.closeKeyboardOnScroll( - scope = scope, - context = context, - view = view, - keyboardController = keyboardController, - ) - ), - content = { - PayToContactScreenScaffoldContent( - modifier = Modifier.padding(it).fillMaxWidth(), - searchQuery = searchQuery, - frequentOrders = frequentOrders, - isNewContactVisible = isNewContactVisible, - contactList = contactList, - onContactSelected = onContactSelected, - onNewContactSelected = onNewContactSelected, - onFrequentOrderSelected = onFrequentOrderSelected, - frequentOrderMaxColumns = frequentOrderMaxColumns, - frequentOrdersHeading = frequentOrdersHeading, - areAllPermissionsGranted = areAllPermissionsGranted, - onPrimaryButtonClicked = onPrimaryButtonClicked, - isSearchState = false, - navigationIcon = CommonR.drawable.ic_arrow_left_black_v2, - onBackClick = onBackClick, - rewardsNudgeDetailEntity = rewardsNudgeDetailEntity, - onSearchBarClicked = onSearchBarClicked, - hasNonZeroContacts = hasNonZeroContacts, - showLoader = showLoader, - clickedContactPhoneNumber = clickedContactPhoneNumber, - showShimmer = showShimmer, - newContact = newContact, - shouldShowYourContactsTitle = shouldShowYourContactsTitle, - ) - }, - ) -} - @Composable private fun PayToContactsScreenHeader( navigationIcon: Int, onBackClick: () -> Unit, - rewardsNudgeDetailEntity: NudgeDetailEntity?, + onHelpCtaClicked: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth()) { NaviPayHeader( @@ -608,17 +544,12 @@ private fun PayToContactsScreenHeader( title = stringResource(R.string.send_money_to_contacts), onNavigationIconClick = onBackClick, modifier = Modifier.fillMaxWidth(), + actionIconText = stringResource(R.string.help), + onActionClick = onHelpCtaClicked, ) - - rewardsNudgeDetailEntity?.let { - RewardsNudgeWithBg(modifier = Modifier.fillMaxWidth(), nudgeDetailEntity = it) - - Spacer(modifier = Modifier.height(16.dp)) - } } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun PayToContactScreenScaffoldContent( modifier: Modifier, @@ -634,39 +565,19 @@ private fun PayToContactScreenScaffoldContent( areAllPermissionsGranted: Boolean, onPrimaryButtonClicked: () -> Unit, isSearchState: Boolean, - navigationIcon: Int, - onBackClick: () -> Unit, - rewardsNudgeDetailEntity: NudgeDetailEntity?, - onSearchBarClicked: () -> Unit, hasNonZeroContacts: Boolean, showLoader: Boolean, clickedContactPhoneNumber: String, showShimmer: Boolean = false, newContact: PhoneContactEntity?, shouldShowYourContactsTitle: Boolean, + scrollState: LazyListState, ) { - LazyColumn(modifier = modifier.fillMaxHeight(), flingBehavior = maxScrollFlingBehavior()) { - if (!isSearchState) { - item { - PayToContactsScreenHeader( - navigationIcon = navigationIcon, - onBackClick = onBackClick, - rewardsNudgeDetailEntity = rewardsNudgeDetailEntity, - ) - } - stickyHeader { - Surface(color = NaviPayColor.bgDefault) { - Spacer(modifier = Modifier.height(8.dp)) - StaticSearchBarView( - modifier = - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - onSearchBarClicked = onSearchBarClicked, - placeHolderString = stringResource(id = R.string.search_name_or_mobile), - ) - Spacer(modifier = Modifier.height(8.dp)) - } - } - } + LazyColumn( + modifier = modifier.fillMaxHeight(), + flingBehavior = maxScrollFlingBehavior(), + state = scrollState, + ) { item { Spacer(modifier = Modifier.height(16.dp)) } if (frequentOrders.isNotEmpty()) { @@ -708,7 +619,7 @@ private fun PayToContactScreenScaffoldContent( text = stringResource(id = R.string.your_contacts), fontFamily = naviFontFamily, fontSize = 16.sp, - fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), color = NaviPayColor.textPrimary, ) @@ -771,7 +682,7 @@ private fun FrequentOrdersSection( text = frequentOrdersHeading, fontFamily = naviFontFamily, fontSize = 16.sp, - fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), color = NaviPayColor.textPrimary, ) @@ -1022,28 +933,57 @@ fun PayToContactsPermissionView(onPrimaryButtonClicked: () -> Unit) { text = stringResource(id = R.string.your_contacts), fontFamily = naviFontFamily, fontSize = 16.sp, - fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), color = NaviPayColor.textPrimary, ) Spacer(modifier = Modifier.height(8.dp)) - IconWithTitleDescriptionButton( - modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp).wrapContentSize(), - iconId = CommonR.drawable.ic_phone_book_permission, - iconSize = 72.dp, - titleId = R.string.contact_permission_title, - descriptionText = stringResource(R.string.contact_permission_des), - buttonTextId = R.string.allow_permission, - onPrimaryButtonClicked = onPrimaryButtonClicked, - ) + 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?, - onInvalidVpaBottomSheetCtaClicked: () -> Unit, + onDismissBottomSheet: () -> Unit, ) { when (bottomSheetUIState) { PayToContactsBottomSheetUIState.InvalidVpa -> { @@ -1052,7 +992,7 @@ private fun PayToContactsBottomSheetContent( headerTextId = R.string.np_saved_beneficiary_invalid_vpa_header, descriptionTextId = R.string.np_saved_beneficiary_invalid_vpa_description, buttonTextId = R.string.np_okay_got_it, - onButtonClicked = onInvalidVpaBottomSheetCtaClicked, + onButtonClicked = onDismissBottomSheet, ) } PayToContactsBottomSheetUIState.ContactNotLinked -> { @@ -1061,7 +1001,14 @@ private fun PayToContactsBottomSheetContent( 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 = onInvalidVpaBottomSheetCtaClicked, + onButtonClicked = onDismissBottomSheet, + ) + } + is PayToContactsBottomSheetUIState.OfferList -> { + NaviPayOffersBottomSheet( + offerData = bottomSheetUIState.offerData, + closeSheet = onDismissBottomSheet, + heading = bottomSheetUIState.heading, ) } else -> {} 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 01010e3824..9085b24b92 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 @@ -7,16 +7,15 @@ package com.navi.pay.management.paytocontacts.viewmodel -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.google.gson.reflect.TypeToken +import com.navi.base.model.CtaData import com.navi.base.utils.ResourceProvider import com.navi.common.constants.EMPTY import com.navi.common.extensions.removeSpaces -import com.navi.common.model.common.NudgeDetailEntity import com.navi.common.network.models.RepoResult import com.navi.common.network.models.isSuccessWithData -import com.navi.common.usecase.RewardsNudgeEntityFetchUseCase +import com.navi.common.usecase.LitmusExperimentsUseCase import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics import com.navi.pay.analytics.NaviPayAnalytics.Companion.NAVI_PAY_SEND_MONEY_TO_CONTACTS_SCREEN_V2 @@ -30,6 +29,7 @@ import com.navi.pay.common.usecase.NaviPayConfigUseCase import com.navi.pay.common.usecase.ValidateVpaUseCase import com.navi.pay.common.utils.DeviceInfoProvider import com.navi.pay.common.utils.FrequentOrdersHelper +import com.navi.pay.common.utils.NaviPayCommonUtils.getHelpCtaData import com.navi.pay.common.utils.NaviPayCommonUtils.getNormalisedPhoneNumber import com.navi.pay.common.utils.getMetricInfo import com.navi.pay.common.viewmodel.NaviPayBaseVM @@ -39,6 +39,7 @@ import com.navi.pay.management.common.sendmoney.model.network.TransactionInitiat 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.SendMoneyScreenSource +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper 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 @@ -47,11 +48,13 @@ import com.navi.pay.tstore.list.model.view.OrderEntity import com.navi.pay.utils.DEFAULT_CONFIG import com.navi.pay.utils.INDIA_COUNTRY_CODE_WITH_PLUS import com.navi.pay.utils.INVALID_VPA +import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE import com.navi.pay.utils.NAVI_PAY_SEARCH_QUERY_API_DELAY import com.navi.pay.utils.NOT_LINKED_TO_UPI import com.navi.pay.utils.PHONE_NUMBER_LENGTH import com.navi.pay.utils.isValidPhoneNumberLength import com.navi.pay.utils.isValidSearchQuery +import com.navi.rr.common.models.OfferData import com.ramcosta.composedestinations.spec.Direction import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -80,12 +83,12 @@ constructor( private val contactManager: PhoneContactManager, private val naviPayNetworkConnectivity: NaviPayNetworkConnectivity, private val naviPayConfigUseCase: NaviPayConfigUseCase, - private val rewardsNudgeEntityFetchUseCase: RewardsNudgeEntityFetchUseCase, private val naviPaySessionHelper: NaviPaySessionHelper, private val frequentOrdersHelper: FrequentOrdersHelper, private val resourceProvider: ResourceProvider, private val validateVpaUseCase: ValidateVpaUseCase, - savedStateHandle: SavedStateHandle, + private val naviPayOffersHelper: NaviPayOffersHelper, + private val litmusExperimentsUseCase: LitmusExperimentsUseCase, ) : NaviPayBaseVM() { private var naviPayDefaultConfig = NaviPayDefaultConfig() @@ -96,8 +99,6 @@ constructor( private val _uiState = MutableStateFlow(PayToContactsUIState.Loading) val uiState = _uiState.asStateFlow() - private var shouldOpenSearchState = savedStateHandle.get("openSearchState") ?: false - private val _searchQuery = MutableStateFlow("") val searchQuery = _searchQuery.asStateFlow() @@ -137,6 +138,12 @@ constructor( private val _isNewContactVisible = MutableStateFlow(false) val isNewContactVisible = _isNewContactVisible.asStateFlow() + private val _genericOffersList = MutableStateFlow>(emptyList()) + val genericOffersList = _genericOffersList.asStateFlow() + + private val _navigateToNextScreenFromHelpCta = MutableSharedFlow() + val navigateToNextScreenFromHelpCta = _navigateToNextScreenFromHelpCta.asSharedFlow() + private val _bottomSheetStateHolder = MutableStateFlow( PayToContactsBottomSheetStateHolder( @@ -150,9 +157,6 @@ constructor( // TODO: Make it private after using proper testing framework val allContactList = MutableStateFlow>(emptyList()) - private val _rewardsNudgeDetailEntity = MutableStateFlow(null) - val rewardsNudgeDetailEntity = _rewardsNudgeDetailEntity.asStateFlow() - private val frequentOrdersTotalEntries = MutableStateFlow(naviPayDefaultConfig.config.frequentTransactionsTotalEntries) @@ -171,6 +175,8 @@ constructor( private var lastSearchQuery = "" + private val helpCtaData = getHelpCtaData(screenName = screenName) + val filteredContactList = combine(searchQuery, allContactList) { searchQuery, allContactList -> apiCallJob?.cancel() @@ -246,13 +252,32 @@ constructor( ) init { + getGenericOffersList() updateNaviPaySessionId() updateNaviPayDefaultConfig() - updateRewardsNudgeEntity() fetchFrequentOrders() observeAndHandleSearchResults() } + private fun getGenericOffersList() { + viewModelScope.launch(Dispatchers.IO) { + val isOfferExperienceEnabled = + litmusExperimentsUseCase + .execute(experimentName = LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE) + ?.variant + ?.enabled ?: false + + if (isOfferExperienceEnabled) { + val genericOffersList = naviPayOffersHelper.getCachedOffers(screenName = screenName) + naviPayAnalytics.onOffersCalloutLanded( + numberOfOffers = genericOffersList.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + _genericOffersList.update { genericOffersList } + } + } + } + private fun observeAndHandleSearchResults() { viewModelScope.launch(Dispatchers.IO) { combine(searchQuery, filteredContactList, filteredFrequentOrdersList) { @@ -336,7 +361,6 @@ constructor( } fun updateUiState(uiState: PayToContactsUIState) { - shouldOpenSearchState = uiState == PayToContactsUIState.Search _uiState.update { uiState } } @@ -594,7 +618,7 @@ constructor( frequentOrdersDays = frequentOrdersDays.value, ) updateFrequentOrderEntityList(getFrequentOrderList) - updateUiAsPerSource() + updateUiState(uiState = PayToContactsUIState.Loaded) } } @@ -604,7 +628,6 @@ constructor( allContactList.update { savedContactListJob.await() } _isSavedContactListNonEmpty.update { allContactList.value.isNotEmpty() } naviPayAnalytics.onSendToContactsLoaded( - rewardNudgeShown = if (rewardsNudgeDetailEntity.value != null) "Y" else "N", naviPaySessionAttributes = getNaviPaySessionAttributes(), isPermissionGranted = true, isContactListEmpty = allContactList.value.isEmpty(), @@ -613,24 +636,10 @@ constructor( } } - private fun updateUiAsPerSource() { - if (shouldOpenSearchState) { - updateUiState(uiState = PayToContactsUIState.Search) - } else { - updateUiState(uiState = PayToContactsUIState.Loaded) - } - } - private fun updateFrequentOrderEntityList(orderEntityList: List) { frequentOrders.update { orderEntityList } } - private fun updateRewardsNudgeEntity() { - viewModelScope.launch(Dispatchers.IO) { - _rewardsNudgeDetailEntity.update { rewardsNudgeEntityFetchUseCase.execute() } - } - } - private fun updateShowLoader(showLoader: Boolean) { _showLoader.update { showLoader } } @@ -704,6 +713,34 @@ constructor( return processedContactNumber.contains(processedSearchQuery) } + fun onOffersRolodexClicked() { + viewModelScope.launch(Dispatchers.IO) { + naviPayAnalytics.onOffersCalloutClicked( + numberOfOffers = genericOffersList.value.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = true, + bottomSheetUIState = + PayToContactsBottomSheetUIState.OfferList( + offerData = genericOffersList.value, + heading = + resourceProvider.getString( + resId = R.string.np_n_upi_payment_offers, + genericOffersList.value.size, + ), + ), + ) + } + } + + fun onHelpCtaClicked() { + viewModelScope.launch(Dispatchers.Default) { + _navigateToNextScreenFromHelpCta.emit(helpCtaData) + } + } + override val screenName: String get() = NAVI_PAY_SEND_MONEY_TO_CONTACTS_SCREEN_V2 } @@ -711,10 +748,13 @@ constructor( enum class PayToContactsUIState { Loading, Loaded, - Search, } -enum class PayToContactsBottomSheetUIState { - InvalidVpa, - ContactNotLinked, +sealed class PayToContactsBottomSheetUIState { + data object InvalidVpa : PayToContactsBottomSheetUIState() + + data object ContactNotLinked : PayToContactsBottomSheetUIState() + + data class OfferList(val offerData: List, val heading: String) : + PayToContactsBottomSheetUIState() } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/model/view/SavedBeneficiaryScreenBottomSheetHolder.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/model/view/SavedBeneficiaryScreenBottomSheetHolder.kt index ad75bbfc7c..a5bd025a5c 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/model/view/SavedBeneficiaryScreenBottomSheetHolder.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/model/view/SavedBeneficiaryScreenBottomSheetHolder.kt @@ -7,7 +7,10 @@ package com.navi.pay.management.savedbeneficiary.model.view +import com.navi.pay.management.savedbeneficiary.viewmodel.SavedBeneficiaryScreenBottomSheetUIState + data class SavedBeneficiaryScreenBottomSheetHolder( val showBottomSheet: Boolean, val bottomSheetStateChange: Boolean, + val bottomSheetUIState: SavedBeneficiaryScreenBottomSheetUIState?, ) diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/ui/SavedBeneficiaryScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/ui/SavedBeneficiaryScreen.kt index 34c659bd1a..2030988bac 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/ui/SavedBeneficiaryScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/ui/SavedBeneficiaryScreen.kt @@ -10,7 +10,6 @@ package com.navi.pay.management.savedbeneficiary.ui import androidx.activity.compose.BackHandler -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,8 +27,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Scaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Tab @@ -64,7 +61,6 @@ import androidx.paging.compose.itemKey import com.navi.common.R as CommonR import com.navi.common.commoncomposables.ui.ScrollableTabRow import com.navi.common.commoncomposables.ui.tabIndicatorOffset -import com.navi.common.model.common.NudgeDetailEntity import com.navi.common.utils.EMPTY import com.navi.common.utils.navigateUp import com.navi.design.font.FontWeightEnum @@ -79,14 +75,13 @@ 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.IconImage import com.navi.pay.common.ui.ImageTitleDescriptionShimmerView import com.navi.pay.common.ui.InputTextFieldWithDescriptionHeader 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.NaviPayOffersBottomSheet import com.navi.pay.common.ui.NaviPaySponsorView -import com.navi.pay.common.ui.RewardsNudgeWithBg import com.navi.pay.common.ui.SelfTransferCtaView import com.navi.pay.common.ui.TabIndicator import com.navi.pay.common.ui.ThemeRoundedButton @@ -96,8 +91,7 @@ import com.navi.pay.entry.NaviPayActivity import com.navi.pay.management.common.model.view.WarningErrorInfoState import com.navi.pay.management.savedbeneficiary.model.view.SavedBeneficiaryEntity import com.navi.pay.management.savedbeneficiary.model.view.SavedBeneficiaryTabData -import com.navi.pay.management.savedbeneficiary.model.view.SavedBeneficiaryType -import com.navi.pay.management.savedbeneficiary.viewmodel.SavedBeneficiaryUIState +import com.navi.pay.management.savedbeneficiary.viewmodel.SavedBeneficiaryScreenBottomSheetUIState import com.navi.pay.management.savedbeneficiary.viewmodel.SavedBeneficiaryViewModel import com.navi.pay.utils.LINKED_ACCOUNT_SCREEN_SOURCE import com.navi.pay.utils.NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE @@ -107,6 +101,8 @@ import com.navi.pay.utils.contactInitials import com.navi.pay.utils.customHide import com.navi.pay.utils.isEmpty import com.navi.pay.utils.shimmerEffect +import com.navi.rr.common.models.OfferData +import com.navi.rr.common.ui.OffersRolodex import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.launch @@ -123,7 +119,6 @@ fun SavedBeneficiaryScreen( val onBackClick = { navigator.navigateUp(naviPayActivity) } val tabData by savedBeneficiaryViewModel.tabData.collectAsStateWithLifecycle() - val uiState by savedBeneficiaryViewModel.uiState.collectAsStateWithLifecycle() val searchQuery by savedBeneficiaryViewModel.searchQuery.collectAsStateWithLifecycle() val bottomSheetStateHolder by savedBeneficiaryViewModel.bottomSheetStateHolder.collectAsStateWithLifecycle() @@ -134,9 +129,6 @@ fun SavedBeneficiaryScreen( val bankAccountTabItems = savedBeneficiaryViewModel.bankAccountTabDataPager.collectAsLazyPagingItems() val searchDataItems = savedBeneficiaryViewModel.searchDataPager.collectAsLazyPagingItems() - - val rewardsNudgeDetailEntity by - savedBeneficiaryViewModel.rewardsNudgeDetailEntity.collectAsStateWithLifecycle() val showLoader by savedBeneficiaryViewModel.showLoader.collectAsStateWithLifecycle() val showShimmer by savedBeneficiaryViewModel.showShimmer.collectAsStateWithLifecycle() val activeQueryVpa by savedBeneficiaryViewModel.activeQueryVpa.collectAsStateWithLifecycle() @@ -145,6 +137,8 @@ fun SavedBeneficiaryScreen( savedBeneficiaryViewModel.isWarningOrErrorState.collectAsStateWithLifecycle() val showSelfTransferCta by savedBeneficiaryViewModel.showSelfTransferCta.collectAsStateWithLifecycle() + val genericOffersList by + savedBeneficiaryViewModel.genericOffersList.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val keyboardController = LocalSoftwareKeyboardController.current @@ -181,16 +175,11 @@ fun SavedBeneficiaryScreen( } BackHandler { - if (uiState == SavedBeneficiaryUIState.Search) { - savedBeneficiaryViewModel.updateSearchQuery(query = "") - savedBeneficiaryViewModel.updateSavedBeneficiaryUIState(SavedBeneficiaryUIState.List) - } else { - onBackClick() - } + savedBeneficiaryViewModel.updateSearchQuery(query = "") + savedBeneficiaryViewModel.cancelPayRequest() + onBackClick() } - val focusRequester = remember { FocusRequester() } - val onSavedBeneficiaryItemClicked: (SavedBeneficiaryEntity, Boolean) -> Unit = { savedBeneficiaryEntity, fromSaved -> focusManager.clearFocus() @@ -216,14 +205,22 @@ fun SavedBeneficiaryScreen( ) } + val onOffersRolodexClicked = { + keyboardController?.customHide(context = context, view = view) + focusManager.clearFocus() + savedBeneficiaryViewModel.onOffersRolodexClicked() + } + if (bottomSheetStateHolder.showBottomSheet) { NaviPayModalBottomSheet( modifier = Modifier.fillMaxWidth(), bottomSheetState = bottomSheetState, onDismissRequest = onDismissBottomSheet, + shouldDismissOnBackPress = true, bottomSheetContent = { RenderSavedBeneficiaryBottomSheetUIState( - onInvalidVpaBottomSheetCtaClicked = onDismissBottomSheet + bottomSheetUIState = bottomSheetStateHolder.bottomSheetUIState, + onDismissBottomSheet = onDismissBottomSheet, ) }, ) @@ -231,92 +228,53 @@ fun SavedBeneficiaryScreen( val pagerState = key(tabData) { rememberPagerState(pageCount = { 2 }, initialPage = 0) } - when (uiState) { - SavedBeneficiaryUIState.List -> - RenderSavedBeneficiaryListScreen( - pagerState = pagerState, - tabData = tabData, - onTabClicked = { - naviPayAnalytics.onLanded( - tab = if (it == 0) "UPI" else "Bank", - rewardNudgeShown = if (rewardsNudgeDetailEntity != null) "Y" else "N", + RenderSavedBeneficiaryListScreen( + pagerState = pagerState, + tabData = tabData, + onTabClicked = { + naviPayAnalytics.onLanded( + tab = if (it == 0) "UPI" else "Bank", + naviPaySessionAttributes = savedBeneficiaryViewModel.getNaviPaySessionAttributes(), + ) + scope.launch { pagerState.animateScrollToPage(it) } + }, + upiIdTabItems = upiIdTabItems, + bankAccountTabItems = bankAccountTabItems, + onSavedBeneficiaryListBottomCtaClicked = { + if (showLoader.not()) { + if (pagerState.currentPage == 0) { + naviPayAnalytics.onSavedBeneficiaryScreenCtaClicked( + tab = "UPI", naviPaySessionAttributes = savedBeneficiaryViewModel.getNaviPaySessionAttributes(), ) - scope.launch { pagerState.animateScrollToPage(it) } - }, - onSearchBarClicked = { - naviPayAnalytics.onSavedBeneficiaryListViewSearchAreaClicked( + navigator.navigate(UPIIdInputScreenDestination()) + } else { + naviPayAnalytics.onSavedBeneficiaryScreenCtaClicked( + tab = "Bank", naviPaySessionAttributes = - savedBeneficiaryViewModel.getNaviPaySessionAttributes() + savedBeneficiaryViewModel.getNaviPaySessionAttributes(), ) - savedBeneficiaryViewModel.updateSavedBeneficiaryUIState( - SavedBeneficiaryUIState.Search - ) - }, - upiIdTabItems = upiIdTabItems, - bankAccountTabItems = bankAccountTabItems, - onSavedBeneficiaryListBottomCtaClicked = { - if (showLoader.not()) { - if (pagerState.currentPage == 0) { - naviPayAnalytics.onSavedBeneficiaryScreenCtaClicked( - tab = "UPI", - naviPaySessionAttributes = - savedBeneficiaryViewModel.getNaviPaySessionAttributes(), - ) - navigator.navigate(UPIIdInputScreenDestination()) - } else { - naviPayAnalytics.onSavedBeneficiaryScreenCtaClicked( - tab = "Bank", - naviPaySessionAttributes = - savedBeneficiaryViewModel.getNaviPaySessionAttributes(), - ) - navigator.navigate(BankDetailInputScreenDestination) - } - } - }, - onSavedBeneficiaryItemClicked = onSavedBeneficiaryItemClicked, - onBackClick = onBackClick, - rewardsNudgeDetailEntity = rewardsNudgeDetailEntity, - showLoader = showLoader, - activeQueryVpa = activeQueryVpa, - ) - SavedBeneficiaryUIState.Search -> { - RenderSavedBeneficiarySearchScreen( - searchQuery = searchQuery, - onSearchQueryChanged = savedBeneficiaryViewModel::updateSearchQuery, - onSearchBarBackClicked = { - savedBeneficiaryViewModel.updateSearchQuery(query = "") - savedBeneficiaryViewModel.cancelPayRequest() - naviPayAnalytics.onSavedBeneficiarySearchScreenBackClicked( - naviPaySessionAttributes = - savedBeneficiaryViewModel.getNaviPaySessionAttributes() - ) - savedBeneficiaryViewModel.updateSavedBeneficiaryUIState( - SavedBeneficiaryUIState.List - ) - }, - onSearchBarClearQueryClicked = { - savedBeneficiaryViewModel.updateSearchQuery(query = "") - }, - searchDataItems = searchDataItems, - onSavedBeneficiaryItemClicked = onSavedBeneficiaryItemClicked, - onSearchItemClicked = { - keyboardController?.customHide(context = context, view = view) - focusManager.clearFocus() - savedBeneficiaryViewModel.onSearchItemClicked(it) - }, - searchDataSavedBeneficiaryEntity = searchDataSavedBeneficiaryEntity, - focusRequester = focusRequester, - showLoader = showLoader, - showShimmer = showShimmer, - activeQueryVpa = activeQueryVpa, - warningErrorInfoState = warningErrorInfoState, - showSelfTransferCta = showSelfTransferCta, - onSelfTransferCtaClicked = onSelfTransferCtaClicked, - ) - } - } + navigator.navigate(BankDetailInputScreenDestination) + } + } + }, + onSavedBeneficiaryItemClicked = onSavedBeneficiaryItemClicked, + onBackClick = onBackClick, + showLoader = showLoader, + activeQueryVpa = activeQueryVpa, + searchQuery = searchQuery, + onSearchQueryChanged = savedBeneficiaryViewModel::updateSearchQuery, + onSearchBarClearQueryClicked = { savedBeneficiaryViewModel.updateSearchQuery(query = "") }, + warningErrorInfoState = warningErrorInfoState, + showSelfTransferCta = showSelfTransferCta, + onSelfTransferCtaClicked = onSelfTransferCtaClicked, + searchDataSavedBeneficiaryEntity = searchDataSavedBeneficiaryEntity, + searchDataItems = searchDataItems, + showShimmer = showShimmer, + genericOffersList = genericOffersList, + onOffersRolodexClicked = onOffersRolodexClicked, + ) } @Composable @@ -324,39 +282,68 @@ private fun RenderSavedBeneficiaryListScreen( pagerState: PagerState, tabData: List, onTabClicked: (Int) -> Unit, - onSearchBarClicked: () -> Unit, upiIdTabItems: LazyPagingItems, bankAccountTabItems: LazyPagingItems, onSavedBeneficiaryListBottomCtaClicked: () -> Unit, onSavedBeneficiaryItemClicked: (SavedBeneficiaryEntity, Boolean) -> Unit, onBackClick: () -> Unit, - rewardsNudgeDetailEntity: NudgeDetailEntity?, showLoader: Boolean, activeQueryVpa: String = "", + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchBarClearQueryClicked: () -> Unit, + warningErrorInfoState: WarningErrorInfoState, + showSelfTransferCta: Boolean, + onSelfTransferCtaClicked: () -> Unit, + searchDataSavedBeneficiaryEntity: SavedBeneficiaryEntity?, + searchDataItems: LazyPagingItems, + showShimmer: Boolean, + genericOffersList: List, + onOffersRolodexClicked: () -> Unit, ) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { SavedBeneficiaryListTopBar( currentPage = pagerState.currentPage, - onSearchBarClicked = onSearchBarClicked, tabData = tabData, onTabClicked = onTabClicked, onBackClick = onBackClick, - rewardsNudgeDetailEntity = rewardsNudgeDetailEntity, showLoader = showLoader, + searchQuery = searchQuery, + onSearchQueryChanged = onSearchQueryChanged, + onSearchBarClearQueryClicked = onSearchBarClearQueryClicked, + warningErrorInfoState = warningErrorInfoState, + genericOffersList = genericOffersList, + onOffersRolodexClicked = onOffersRolodexClicked, ) }, content = { - RenderSavedBeneficiaryListContent( - pagerState = pagerState, - upiIdTabItems = upiIdTabItems, - bankAccountTabItems = bankAccountTabItems, - modifier = Modifier.padding(it), - onSavedBeneficiaryItemClicked = { onSavedBeneficiaryItemClicked(it, true) }, - showLoader = showLoader, - activeQueryVpa = activeQueryVpa, - ) + if (searchQuery.isEmpty()) { + RenderSavedBeneficiaryListContent( + pagerState = pagerState, + upiIdTabItems = upiIdTabItems, + bankAccountTabItems = bankAccountTabItems, + modifier = Modifier.padding(it), + onSavedBeneficiaryItemClicked = { onSavedBeneficiaryItemClicked(it, true) }, + showLoader = showLoader, + activeQueryVpa = activeQueryVpa, + ) + } else { + RenderSavedBeneficiarySearchItems( + modifier = Modifier.padding(it), + searchQuery = searchQuery, + searchDataItems = searchDataItems, + onSavedBeneficiaryItemClicked = onSavedBeneficiaryItemClicked, + onSearchItemClicked = { onSavedBeneficiaryItemClicked(it, false) }, + searchDataSavedBeneficiaryEntity = searchDataSavedBeneficiaryEntity, + showLoader = showLoader, + showShimmer = showShimmer, + activeQueryVpa = activeQueryVpa, + showSelfTransferCta = showSelfTransferCta, + onSelfTransferCtaClicked = onSelfTransferCtaClicked, + ) + } }, bottomBar = { SavedBeneficiaryListBottomBar( @@ -371,13 +358,19 @@ private fun RenderSavedBeneficiaryListScreen( @Composable private fun SavedBeneficiaryListTopBar( currentPage: Int, - onSearchBarClicked: () -> Unit, tabData: List, onTabClicked: (Int) -> Unit, onBackClick: () -> Unit, - rewardsNudgeDetailEntity: NudgeDetailEntity?, showLoader: Boolean, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchBarClearQueryClicked: () -> Unit, + warningErrorInfoState: WarningErrorInfoState, + genericOffersList: List, + onOffersRolodexClicked: () -> Unit, ) { + val focusRequester = remember { FocusRequester() } + Column(modifier = Modifier.fillMaxWidth()) { NaviPayHeader( navigationIcon = CommonR.drawable.ic_arrow_left_black_v2, @@ -386,103 +379,111 @@ private fun SavedBeneficiaryListTopBar( modifier = Modifier.fillMaxWidth(), ) - rewardsNudgeDetailEntity?.let { - RewardsNudgeWithBg(modifier = Modifier.fillMaxWidth(), nudgeDetailEntity = it) - - Spacer(modifier = Modifier.height(24.dp)) - } - - SavedBeneficiaryListSearchBar( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - onSearchBarClicked = onSearchBarClicked, - showLoader = showLoader, + InputTextFieldWithDescriptionHeader( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp).focusRequester(focusRequester), + headerString = null, + placeHolderString = + stringResource(id = R.string.np_saved_beneficiary_search_bar_placeholder), + value = searchQuery, + onValueChangeListener = onSearchQueryChanged, + leadingIconId = CommonR.drawable.ic_arrow_left_black_v2, + isTrailingIconEnabled = searchQuery.isNotEmpty(), + onTrailingIconClicked = onSearchBarClearQueryClicked, + warningErrorInfoState = warningErrorInfoState.isErrorState, + trailingIconId = WidgetsR.drawable.small_cross_purple, + enabled = !showLoader, ) - Spacer(modifier = Modifier.height(16.dp)) + if (warningErrorInfoState.isWarningState && searchQuery.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + NaviText( + text = warningErrorInfoState.warningMessage.orEmpty(), + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + fontFamily = naviFontFamily, + fontSize = 12.sp, + color = NaviPayColor.textPrimary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + } - ScrollableTabRow( - edgePadding = 0.dp, - selectedTabIndex = 2 * currentPage, - modifier = Modifier.padding(horizontal = 16.dp), - containerColor = NaviPayColor.transparent, - indicator = { tabPositions -> - TabIndicator( - modifier = - Modifier.tabIndicatorOffset( - currentTabPosition = tabPositions[2 * currentPage], - isSelectedTabIndexLastTab = currentPage == tabPositions.size - 1, - ), - height = 2.dp, - color = NaviPayColor.ctaPrimary, - ) - }, - ) { - tabData.forEachIndexed { index, tabInfo -> - Row(verticalAlignment = Alignment.CenterVertically) { - Tab( - modifier = Modifier.wrapContentSize(), - selected = index == currentPage, - onClick = { if (showLoader.not()) onTabClicked(index) }, - interactionSource = remember { NoRippleIndicationSource() }, - ) { - NaviText( - text = stringResource(id = tabInfo.textId), - fontSize = 16.sp, - fontFamily = naviFontFamily, - fontWeight = - if (index == currentPage) { - getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD) - } else { - getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR) - }, - color = - if (index == currentPage) { - NaviPayColor.ctaPrimary - } else { - NaviPayColor.textTertiary - }, - lineHeight = 24.sp, - ) + if (warningErrorInfoState.isErrorState && searchQuery.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + NaviText( + text = warningErrorInfoState.errorMessage.orEmpty(), + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + fontFamily = naviFontFamily, + fontSize = 12.sp, + color = NaviPayColor.inputFieldError, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + } - Spacer(modifier = Modifier.height(4.dp)) + OffersRolodex( + modifier = + Modifier.fillMaxWidth() + .padding(top = 24.dp, bottom = 8.dp, start = 16.dp, end = 16.dp), + offerData = genericOffersList, + shouldShowRolodexAnimation = genericOffersList.size > 1, + onClick = onOffersRolodexClicked, + ) + + if (searchQuery.isEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + ScrollableTabRow( + edgePadding = 0.dp, + selectedTabIndex = 2 * currentPage, + modifier = Modifier.padding(horizontal = 16.dp), + containerColor = NaviPayColor.transparent, + indicator = { tabPositions -> + TabIndicator( + modifier = + Modifier.tabIndicatorOffset( + currentTabPosition = tabPositions[2 * currentPage], + isSelectedTabIndexLastTab = currentPage == tabPositions.size - 1, + ), + height = 2.dp, + color = NaviPayColor.ctaPrimary, + ) + }, + ) { + tabData.forEachIndexed { index, tabInfo -> + Row(verticalAlignment = Alignment.CenterVertically) { + Tab( + modifier = Modifier.wrapContentSize(), + selected = index == currentPage, + onClick = { if (showLoader.not()) onTabClicked(index) }, + interactionSource = remember { NoRippleIndicationSource() }, + ) { + NaviText( + text = stringResource(id = tabInfo.textId), + fontSize = 16.sp, + fontFamily = naviFontFamily, + fontWeight = + if (index == currentPage) { + getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD) + } else { + getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR) + }, + color = + if (index == currentPage) { + NaviPayColor.ctaPrimary + } else { + NaviPayColor.textTertiary + }, + lineHeight = 24.sp, + ) + + Spacer(modifier = Modifier.height(4.dp)) + } + } + if (index != tabData.size - 1) { + Spacer(modifier = Modifier.width(24.dp)) } } - if (index != tabData.size - 1) { - Spacer(modifier = Modifier.width(24.dp)) - } } + Spacer(modifier = Modifier.height(32.dp)) } - - Spacer(modifier = Modifier.height(32.dp)) - } -} - -@Composable -private fun SavedBeneficiaryListSearchBar( - modifier: Modifier = Modifier, - onSearchBarClicked: () -> Unit, - showLoader: Boolean, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .clickable { if (showLoader.not()) onSearchBarClicked() } - .border( - width = 1.dp, - shape = RoundedCornerShape(4.dp), - color = NaviPayColor.borderDefault, - ) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - NaviText( - text = stringResource(id = R.string.np_saved_beneficiary_search_bar_placeholder), - fontSize = 14.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), - color = NaviPayColor.textTertiary, - ) } } @@ -523,10 +524,10 @@ private fun RenderSavedBeneficiaryListData( activeQueryVpa: String = "", ) { var isLoading by remember { mutableStateOf(true) } - LaunchedEffect(items.loadState.refresh) { isLoading = items.loadState.refresh is LoadState.Loading } + if (items.isEmpty()) { EmptyDataScreen( iconResId = R.drawable.ic_empty_saved_beneficiary_screen, @@ -563,136 +564,65 @@ private fun RenderSavedBeneficiaryListData( } @Composable -private fun RenderSavedBeneficiarySearchScreen( +private fun RenderSavedBeneficiarySearchItems( + modifier: Modifier = Modifier, searchQuery: String, - onSearchQueryChanged: (String) -> Unit, - onSearchBarBackClicked: () -> Unit, - onSearchBarClearQueryClicked: () -> Unit, searchDataItems: LazyPagingItems, onSavedBeneficiaryItemClicked: (SavedBeneficiaryEntity, Boolean) -> Unit, onSearchItemClicked: (SavedBeneficiaryEntity) -> Unit, searchDataSavedBeneficiaryEntity: SavedBeneficiaryEntity?, - focusRequester: FocusRequester, showLoader: Boolean = false, showShimmer: Boolean = false, activeQueryVpa: String = "", - warningErrorInfoState: WarningErrorInfoState, showSelfTransferCta: Boolean, onSelfTransferCtaClicked: () -> Unit, ) { - - val keyboardController = LocalSoftwareKeyboardController.current - - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - Column(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.height(16.dp)) - - InputTextFieldWithDescriptionHeader( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp) - .focusRequester(focusRequester), - headerString = null, - placeHolderString = "", - value = searchQuery, - onValueChangeListener = onSearchQueryChanged, - isLeadingIconEnabled = true, - leadingIconId = CommonR.drawable.ic_arrow_left_black_v2, - onLeadingIconClicked = onSearchBarBackClicked, - isTrailingIconEnabled = searchQuery.isNotEmpty(), - onTrailingIconClicked = onSearchBarClearQueryClicked, - warningErrorInfoState = warningErrorInfoState.isErrorState, - trailingIconId = WidgetsR.drawable.small_cross_purple, - ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - keyboardController?.show() - } - - if (warningErrorInfoState.isWarningState) { - Spacer(modifier = Modifier.height(8.dp)) - NaviText( - text = warningErrorInfoState.warningMessage.orEmpty(), - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), - fontFamily = naviFontFamily, - fontSize = 12.sp, - color = NaviPayColor.textPrimary, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) - } - - if (warningErrorInfoState.isErrorState) { - Spacer(modifier = Modifier.height(8.dp)) - NaviText( - text = warningErrorInfoState.errorMessage.orEmpty(), - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), - fontFamily = naviFontFamily, - fontSize = 12.sp, - color = NaviPayColor.inputFieldError, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) - } - } - }, - content = { - if (showSelfTransferCta) { - SelfTransferCtaView( - description = searchQuery, - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 24.dp) - .clickable { onSelfTransferCtaClicked() }, - ) - } - LazyColumn( - modifier = Modifier.padding(it).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - item { Spacer(modifier = Modifier.height(8.dp)) } - items( - count = searchDataItems.itemCount, - key = searchDataItems.itemKey { it.vpa }, - ) { index -> - searchDataItems[index]?.let { savedBeneficiaryEntity -> - SavedBeneficiaryItemView( - modifier = Modifier.padding(horizontal = 16.dp), - savedBeneficiaryEntity = savedBeneficiaryEntity, - onSavedBeneficiaryItemClicked = { - onSavedBeneficiaryItemClicked(it, true) - }, - index = index, - showLoader = showLoader, - activeQueryVpa = activeQueryVpa, - ) - } - } - if (showShimmer) { - item { ImageTitleDescriptionShimmerView() } - } - - if (searchDataSavedBeneficiaryEntity != null) { - item { - SavedBeneficiaryItemView( - modifier = Modifier.padding(horizontal = 16.dp), - savedBeneficiaryEntity = searchDataSavedBeneficiaryEntity, - onSavedBeneficiaryItemClicked = { onSearchItemClicked(it) }, - index = 0, - showLoader = showLoader, - activeQueryVpa = activeQueryVpa, - ) - } - } - } - }, - bottomBar = { - NaviPaySponsorView( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 32.dp) + Column(modifier = modifier) { + if (showSelfTransferCta) { + SelfTransferCtaView( + description = searchQuery, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp) + .clickable { onSelfTransferCtaClicked() }, ) - }, - ) + } + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { Spacer(modifier = Modifier.height(8.dp)) } + items(count = searchDataItems.itemCount, key = searchDataItems.itemKey { it.vpa }) { + index -> + searchDataItems[index]?.let { savedBeneficiaryEntity -> + SavedBeneficiaryItemView( + modifier = Modifier.padding(horizontal = 16.dp), + savedBeneficiaryEntity = savedBeneficiaryEntity, + onSavedBeneficiaryItemClicked = { onSavedBeneficiaryItemClicked(it, true) }, + index = index, + showLoader = showLoader, + activeQueryVpa = activeQueryVpa, + ) + } + } + if (showShimmer) { + item { ImageTitleDescriptionShimmerView() } + } + + if (searchDataSavedBeneficiaryEntity != null) { + item { + SavedBeneficiaryItemView( + modifier = Modifier.padding(horizontal = 16.dp), + savedBeneficiaryEntity = searchDataSavedBeneficiaryEntity, + onSavedBeneficiaryItemClicked = { onSearchItemClicked(it) }, + index = 0, + showLoader = showLoader, + activeQueryVpa = activeQueryVpa, + ) + } + } + } + } } @Composable @@ -725,23 +655,12 @@ private fun SavedBeneficiaryItemView( }, ), ) { - if (savedBeneficiaryEntity.type == SavedBeneficiaryType.BANK_ACCOUNT) { - IconImage( - modifier = Modifier, - iconUrl = savedBeneficiaryEntity.bankIconUrl, - iconSize = 36.dp, - boxSize = 40.dp, - bgColor = NaviPayColor.textWhite, - shape = CircleShape, - ) - } else { - ContactIconView( - index = index, - contactInitials = savedBeneficiaryEntity.name.contactInitials(), - vpa = savedBeneficiaryEntity.vpa, - phoneNumber = EMPTY, - ) - } + ContactIconView( + index = index, + contactInitials = savedBeneficiaryEntity.name.contactInitials(), + vpa = savedBeneficiaryEntity.vpa, + phoneNumber = EMPTY, + ) Spacer(modifier = Modifier.width(16.dp)) @@ -795,8 +714,8 @@ private fun SavedBeneficiaryListBottomBar( text = stringResource( id = - if (currentPage == 0) R.string.np_saved_beneficiary_add_upi_id - else R.string.np_saved_beneficiary_add_bank_account + if (currentPage == 0) R.string.np_pay_to_upi_id + else R.string.np_pay_to_bank_account ), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), onClick = { @@ -812,15 +731,28 @@ private fun SavedBeneficiaryListBottomBar( @Composable private fun RenderSavedBeneficiaryBottomSheetUIState( - onInvalidVpaBottomSheetCtaClicked: () -> Unit + bottomSheetUIState: SavedBeneficiaryScreenBottomSheetUIState?, + onDismissBottomSheet: () -> Unit, ) { - BottomSheetContentWithIconHeaderDescButton( - iconId = CommonR.drawable.ic_exclamation_red_border, - headerTextId = R.string.np_saved_beneficiary_invalid_vpa_header, - descriptionTextId = R.string.np_saved_beneficiary_invalid_vpa_description, - buttonTextId = R.string.np_okay_got_it, - onButtonClicked = onInvalidVpaBottomSheetCtaClicked, - ) + when (bottomSheetUIState) { + SavedBeneficiaryScreenBottomSheetUIState.InvalidVpa -> { + BottomSheetContentWithIconHeaderDescButton( + iconId = CommonR.drawable.ic_exclamation_red_border, + headerTextId = R.string.np_saved_beneficiary_invalid_vpa_header, + descriptionTextId = R.string.np_saved_beneficiary_invalid_vpa_description, + buttonTextId = R.string.np_okay_got_it, + onButtonClicked = onDismissBottomSheet, + ) + } + is SavedBeneficiaryScreenBottomSheetUIState.OfferList -> { + NaviPayOffersBottomSheet( + offerData = bottomSheetUIState.offerData, + closeSheet = onDismissBottomSheet, + heading = bottomSheetUIState.heading, + ) + } + else -> {} + } } @Composable diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/viewmodel/SavedBeneficiaryViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/viewmodel/SavedBeneficiaryViewModel.kt index 063e1f24c1..6c62154a5b 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/viewmodel/SavedBeneficiaryViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/savedbeneficiary/viewmodel/SavedBeneficiaryViewModel.kt @@ -13,10 +13,9 @@ import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.navi.base.utils.ResourceProvider import com.navi.base.utils.isNotNullAndNotEmpty -import com.navi.common.model.common.NudgeDetailEntity import com.navi.common.network.models.RepoResult import com.navi.common.network.models.isSuccessWithData -import com.navi.common.usecase.RewardsNudgeEntityFetchUseCase +import com.navi.common.usecase.LitmusExperimentsUseCase import com.navi.common.utils.EMPTY import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics @@ -36,6 +35,7 @@ import com.navi.pay.destinations.SendMoneyScreenDestination import com.navi.pay.management.common.model.view.WarningErrorInfoState import com.navi.pay.management.common.sendmoney.model.view.PayeeEntity import com.navi.pay.management.common.sendmoney.model.view.SendMoneyScreenSource +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper import com.navi.pay.management.savedbeneficiary.model.view.SavedBeneficiaryEntity import com.navi.pay.management.savedbeneficiary.model.view.SavedBeneficiaryScreenBottomSheetHolder import com.navi.pay.management.savedbeneficiary.model.view.SavedBeneficiaryTabData @@ -48,6 +48,7 @@ import com.navi.pay.utils.INVALID_UPI_ID import com.navi.pay.utils.INVALID_UPI_NUMBER import com.navi.pay.utils.INVALID_VPA import com.navi.pay.utils.INVALID_VPA_HANDLE +import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE import com.navi.pay.utils.MAPPING_DOES_NOT_EXIST import com.navi.pay.utils.MCC_MISMATCH import com.navi.pay.utils.NAVI_PAY_SEARCH_QUERY_API_DELAY @@ -58,6 +59,7 @@ import com.navi.pay.utils.isValidSearchQuery import com.navi.pay.utils.isValidUpiId import com.navi.pay.utils.isValidUpiNumberLength import com.navi.pay.utils.toUpiMapperVpa +import com.navi.rr.common.models.OfferData import com.ramcosta.composedestinations.spec.Direction import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -91,9 +93,10 @@ constructor( private val validateVpaUseCase: ValidateVpaUseCase, private val bankRepository: BankRepository, private val deviceInfoProvider: DeviceInfoProvider, - private val rewardsNudgeEntityFetchUseCase: RewardsNudgeEntityFetchUseCase, private val naviPaySessionHelper: NaviPaySessionHelper, private val resourceProvider: ResourceProvider, + private val naviPayOffersHelper: NaviPayOffersHelper, + private val litmusExperimentsUseCase: LitmusExperimentsUseCase, ) : NaviPayBaseVM() { private val SEARCH_QUERY_DEBOUNCE_TIME = 100.milliseconds @@ -103,9 +106,6 @@ constructor( private val naviPayAnalytics: NaviPayAnalytics.SavedBeneficiaryScreen = NaviPayAnalytics.INSTANCE.SavedBeneficiaryScreen() - private val _uiState = MutableStateFlow(SavedBeneficiaryUIState.List) - val uiState = _uiState.asStateFlow() - private val _tabData = MutableStateFlow(getDefaultTabData()) val tabData = _tabData.asStateFlow() @@ -128,6 +128,9 @@ constructor( private val _searchDataSavedBeneficiaryEntity = MutableStateFlow(null) val searchDataSavedBeneficiaryEntity = _searchDataSavedBeneficiaryEntity.asStateFlow() + private val _genericOffersList = MutableStateFlow>(emptyList()) + val genericOffersList = _genericOffersList.asStateFlow() + private val _hideKeyboard = MutableStateFlow(false) val hideKeyboard = _hideKeyboard.asStateFlow() @@ -146,6 +149,7 @@ constructor( SavedBeneficiaryScreenBottomSheetHolder( showBottomSheet = false, bottomSheetStateChange = false, + bottomSheetUIState = null, ) ) val bottomSheetStateHolder = _bottomSheetStateHolder.asStateFlow() @@ -153,7 +157,7 @@ constructor( private val _navigateToNextScreen = MutableSharedFlow() val navigateToNextScreen = _navigateToNextScreen.asSharedFlow() - val userPhoneNumber = fetchUserPhoneNumber() + private val userPhoneNumber = fetchUserPhoneNumber() private val _shouldShowSelfTransferCta = MutableStateFlow(false) val showSelfTransferCta = _shouldShowSelfTransferCta.asStateFlow() @@ -272,10 +276,8 @@ constructor( fetchNewBeneficiaryData(upiNumberOrId = upiNumberOrId, isValidUpiNumber = isValidUpiNumber) } - private val _rewardsNudgeDetailEntity = MutableStateFlow(null) - val rewardsNudgeDetailEntity = _rewardsNudgeDetailEntity.asStateFlow() - init { + getGenericOffersList() updateNaviPaySessionId() updateLinkedAccount() viewModelScope.launch(Dispatchers.IO) { @@ -286,17 +288,24 @@ constructor( savedBeneficiaryRepository.deleteAllPartiallyDeletedBeneficiaries() } launch(Dispatchers.IO) { searchQueryChangeObserver() } - launch(Dispatchers.IO) { - val rewardsNudgeDetailEntityResponseFromUseCase = - rewardsNudgeEntityFetchUseCase.execute() - _rewardsNudgeDetailEntity.update { rewardsNudgeDetailEntityResponseFromUseCase } + } + } - naviPayAnalytics.onLanded( - tab = "UPI", - rewardNudgeShown = - if (rewardsNudgeDetailEntityResponseFromUseCase != null) "Y" else "N", + private fun getGenericOffersList() { + viewModelScope.launch(Dispatchers.IO) { + val isOfferExperienceEnabled = + litmusExperimentsUseCase + .execute(experimentName = LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE) + ?.variant + ?.enabled ?: false + + if (isOfferExperienceEnabled) { + val genericOffersList = naviPayOffersHelper.getCachedOffers(screenName = screenName) + naviPayAnalytics.onOffersCalloutLanded( + numberOfOffers = genericOffersList.size, naviPaySessionAttributes = getNaviPaySessionAttributes(), ) + _genericOffersList.update { genericOffersList } } } } @@ -345,10 +354,6 @@ constructor( _showLoader.update { showLoader } } - fun updateSavedBeneficiaryUIState(savedBeneficiaryUIState: SavedBeneficiaryUIState) { - _uiState.update { savedBeneficiaryUIState } - } - private fun updateNaviPaySessionId() { naviPaySessionHelper.createNewSessionId() } @@ -390,11 +395,13 @@ constructor( fun updateBottomSheetUIState( showBottomSheet: Boolean, bottomSheetStateChange: Boolean? = null, + bottomSheetUIState: SavedBeneficiaryScreenBottomSheetUIState? = null, ) { _bottomSheetStateHolder.update { it.copy( showBottomSheet = showBottomSheet, bottomSheetStateChange = bottomSheetStateChange ?: it.bottomSheetStateChange, + bottomSheetUIState = bottomSheetUIState ?: it.bottomSheetUIState, ) } } @@ -403,7 +410,6 @@ constructor( viewModelScope.launch(Dispatchers.IO) { updateNavigateToNextScreen(LinkedAccountsScreenDestination(shouldNavigateUp = true)) updateSearchQuery(query = "") - updateSavedBeneficiaryUIState(savedBeneficiaryUIState = SavedBeneficiaryUIState.List) } } @@ -495,15 +501,6 @@ constructor( ) } - fun onSearchItemClicked(savedBeneficiaryEntity: SavedBeneficiaryEntity) { - updateSearchDataSavedBeneficiaryEntity(null) - onSavedBeneficiaryItemClicked( - savedBeneficiaryEntity = savedBeneficiaryEntity, - isEntityFromSavedList = false, - newContactResponse = newContactResponse, - ) - } - fun onSavedBeneficiaryItemClicked( savedBeneficiaryEntity: SavedBeneficiaryEntity, isEntityFromSavedList: Boolean, @@ -630,7 +627,6 @@ constructor( ) ) updateShowLoader(showLoader = false) - updateSavedBeneficiaryUIState(savedBeneficiaryUIState = SavedBeneficiaryUIState.List) } private suspend fun onValidateVpaFailureForSavedContact( @@ -643,7 +639,11 @@ constructor( savedBeneficiaryEntity = savedBeneficiaryEntity, naviPaySessionAttributes = getNaviPaySessionAttributes(), ) - updateBottomSheetUIState(showBottomSheet = true) + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = false, + bottomSheetUIState = SavedBeneficiaryScreenBottomSheetUIState.InvalidVpa, + ) savedBeneficiaryRepository.setIsPartiallyDeleted(vpa = savedBeneficiaryEntity.vpa) } else { updateHideKeyboard(hideKeyboard = true) @@ -651,11 +651,36 @@ constructor( } } + fun onOffersRolodexClicked() { + viewModelScope.launch(Dispatchers.IO) { + naviPayAnalytics.onOffersCalloutClicked( + numberOfOffers = genericOffersList.value.size, + naviPaySessionAttributes = getNaviPaySessionAttributes(), + ) + + updateBottomSheetUIState( + showBottomSheet = true, + bottomSheetStateChange = true, + bottomSheetUIState = + SavedBeneficiaryScreenBottomSheetUIState.OfferList( + offerData = genericOffersList.value, + heading = + resourceProvider.getString( + resId = R.string.np_n_upi_payment_offers, + genericOffersList.value.size, + ), + ), + ) + } + } + override val screenName: String get() = NAVI_PAY_SAVED_BENEFICIARY_SCREEN } -enum class SavedBeneficiaryUIState { - List, - Search, +sealed class SavedBeneficiaryScreenBottomSheetUIState { + data class OfferList(val offerData: List, val heading: String) : + SavedBeneficiaryScreenBottomSheetUIState() + + data object InvalidVpa : SavedBeneficiaryScreenBottomSheetUIState() } 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 140e80ba9a..bec22653fe 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 @@ -27,6 +27,7 @@ const val BOTTOM_SHEET_HEIGHT_PERCENTAGE_80 = 0.80f const val BOTTOM_SHEET_LIST_HEIGHT_PERCENTAGE_45 = 0.45f const val LITE_MANDATE = "LITEMANDATE" const val BUTLER_VPA_TRANSACTIONS_INFO_MAX_SIZE = 10 +const val BAU = "BAU" const val MAX_VISIBLE_TRANSACTION_ITEMS_IN_SCREEN_HEIGHT = 5 const val NAVI_UPI = "Navi UPI" @@ -151,6 +152,7 @@ const val NAVI_PAY_UPI_REQUEST_ID_CACHE_KEY = "naviPayUpiRequestId" const val NAVI_PAY_SETTING_QR_PAGER_ANIMATION_COUNTER = "settingQrPagerAnimationCounter" const val NAVI_PAY_AUTO_POPUP_SCRATCH_CARD_COUNTER = "autoPopupScratchCardCounter" const val KEY_UPI_LITE_MANDATE_INFO = "liteMandateInfo" +const val NAVI_PAY_GENERIC_OFFERS_INFO = "naviPayGenericOffersInfo" const val KEY_UPI_LITE_LAST_NOTIFICATION_TIMESTAMP = "upiLiteLastNotificationTimestamp" const val NAVI_PAY_DEVICE_BINDING_IS_SMV_TRIGGERED_AND_FAILED = "naviPayDeviceBindingIsSmvTriggeredAndFailed" @@ -169,17 +171,16 @@ const val NAVI_PAY_SYNC_TABLE_UPI_LITE_MANDATE_INFO = "upiLiteMandateInfo" const val LITMUS_EXPERIMENT_NAVIPAY_LITE_DEFAULT_ENTERED_AMOUNT = "NaviPay-lite-default-entered-amount" const val LITMUS_EXPERIMENT_NAVIPAY_ORDER_TAG_SUMMARY = "NaviPay-order-tag-summary" -const val LITMUS_EXPERIMENT_NAVIPAY_FREQUENT_CONTACT_IN_QR_SCANNER = - "NaviPay-frequent-contact-in-qr-scanner" const val LITMUS_EXPERIMENT_NAVIPAY_SMV_BINDING = "NaviPay-exp-smv-binding" const val LITMUS_EXPERIMENT_NAVI_FESTIVE_THEME = "festive-theme" +const val LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE = "NaviPay-offer-experience" val NAVI_PAY_LITMUS_EXPERIMENTS = listOf( LITMUS_EXPERIMENT_NAVIPAY_LITE_DEFAULT_ENTERED_AMOUNT, LITMUS_EXPERIMENT_NAVIPAY_ORDER_TAG_SUMMARY, - LITMUS_EXPERIMENT_NAVIPAY_FREQUENT_CONTACT_IN_QR_SCANNER, LITMUS_EXPERIMENT_NAVIPAY_SMV_BINDING, LITMUS_EXPERIMENT_NAVI_FESTIVE_THEME, + LITMUS_EXPERIMENT_NAVIPAY_OFFER_EXPERIENCE, ) // Generic @@ -221,6 +222,14 @@ const val CHECK_BALANCE_ERROR_TRANSITION_TOTAL_DURATION: Long = (CHECK_BALANCE_ERROR_TO_INITIAL_TOTAL_TRANSITION_DURATION - CHECK_BALANCE_ERROR_ANIMATION_DURATION) +// Offer experience +const val TXN_AMOUNT = "TXN_AMOUNT" +const val PAYER_VPA = "PAYER_VPA" +const val PAYEE_VPA = "PAYEE_VPA" +const val UPI_PAYEE_MCC = "UPI_PAYEE_MCC" +const val UPI_PURPOSE_CODE = "UPI_PURPOSE_CODE" +const val UPI_PAYER_BANK_CODE = "UPI_PAYER_BANK_CODE" + // Deeplink const val NAVI_PAY_INTENT_SCHEME = "upi" const val NAVI_PAY_INTENT_MANDATE_HOST = "mandate" diff --git a/android/navi-pay/src/main/res/values/strings.xml b/android/navi-pay/src/main/res/values/strings.xml index 90ac21ff90..2a58c130d9 100644 --- a/android/navi-pay/src/main/res/values/strings.xml +++ b/android/navi-pay/src/main/res/values/strings.xml @@ -82,8 +82,7 @@ Scan & pay Send money Transfer now - Send money to bank - Enter recipient details + Enter receiver details Account number Enter UPI ID or number Enter account number @@ -146,7 +145,7 @@ Grant camera permission to scan QR codes for merchants, family, and friends. Contacts To allow UPI payments to your contacts. - Allow contact permission + Allow contact permission to transfer money to your friends & family. We collect this data so you can transfer money to your friends and family and earn rewards! You have no contacts in your phone Invalid QR code @@ -470,8 +469,8 @@ Search UPI ID, UPI number or bank account UPI IDs Bank accounts - Add UPI ID - Add bank account + Pay to UPI ID + Pay to bank account UPI ID is not active anymore! Please try with some other UPI ID. This UPI ID will be removed from the list. Contact number is not linked to UPI @@ -518,8 +517,6 @@ Via UPI Paid via You\'ve won  - %s Navi coins - %s Navi coin Don\'t know user? Account not linked The bank account you are trying to send money is not linked to Navi UPI anymore. @@ -841,4 +838,6 @@ up to Pay mobile number Transaction history + check now + %s UPI payment offers \ No newline at end of file diff --git a/android/navi-pay/src/test/kotlin/com/navi/pay/usecase/NaviPayOffersHelperTest.kt b/android/navi-pay/src/test/kotlin/com/navi/pay/usecase/NaviPayOffersHelperTest.kt new file mode 100644 index 0000000000..b2d0860b32 --- /dev/null +++ b/android/navi-pay/src/test/kotlin/com/navi/pay/usecase/NaviPayOffersHelperTest.kt @@ -0,0 +1,139 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.usecase + +import com.google.gson.Gson +import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.common.network.models.RepoResult +import com.navi.pay.common.usecase.NaviPayConfigUseCase +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper +import com.navi.pay.network.di.NaviPayGsonBuilder +import com.navi.pay.onboarding.account.detail.model.view.LinkedAccountEntity +import com.navi.rr.common.models.OfferData +import com.navi.rr.common.models.OfferResponse +import com.navi.rr.common.repo.OffersRepo +import com.navi.rr.utils.mapper.OfferResponseToOfferDataMapper +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.mockkConstructor +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.joda.time.DateTime +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NaviPayOffersHelperTest { + + @RelaxedMockK private lateinit var offersRepo: OffersRepo + @RelaxedMockK private lateinit var naviCacheRepository: NaviCacheRepository + @RelaxedMockK private lateinit var naviPayConfigUseCase: NaviPayConfigUseCase + @RelaxedMockK @NaviPayGsonBuilder private lateinit var gson: Gson + + private val testDispatcher = StandardTestDispatcher() + private lateinit var naviPayOffersHelper: NaviPayOffersHelper + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + Dispatchers.setMain(testDispatcher) + naviPayOffersHelper = + NaviPayOffersHelper( + offersRepo = offersRepo, + naviCacheRepository = naviCacheRepository, + naviPayConfigUseCase = naviPayConfigUseCase, + gson = gson, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getOffersFromNetwork should return empty list when no offers found`() = runTest { + coEvery { offersRepo.fetchOffersForProduct(any(), metricInfo = any()) } returns emptyFlow() + val result = naviPayOffersHelper.getOffersFromNetwork(screenName = "Test") + assertTrue(result.isEmpty()) + } + + @Test + fun `getOffersFromNetwork should return valid offers list`() = runTest { + val mockOffersResponse = listOf(OfferResponse(), OfferResponse()) + val mappedOffers = + listOf(OfferData(titlePrefix = "Offer 1"), OfferData(titlePrefix = "Offer 2")) + + coEvery { offersRepo.fetchOffersForProduct(any(), metricInfo = any()) } returns + flowOf(RepoResult(data = mockOffersResponse)) + + mockkConstructor(OfferResponseToOfferDataMapper::class) + + every { anyConstructed().map(any(), any()) } returnsMany + mappedOffers + + val result = naviPayOffersHelper.getOffersFromNetwork("Test") + + assertEquals(2, result.size) + assertTrue(result[0].isBestOffer) + } + + @Test + fun `buildParamsMap should return map with payment amount and bank details`() { + val selectedBankAccount = + LinkedAccountEntity( + accountId = "123", + bankCode = "HDFC", + bankName = "", + ifsc = "", + fallbackBankLogoResId = -1, + maskedAccountNumber = "", + originalMaskedAccountNumber = "", + accountType = "", + vpaEntityList = emptyList(), + isMPinSet = true, + mPinLength = "", + atmPinLength = "", + accountTypeFormatted = "", + isAccountPrimary = true, + creditLineAllowedMCC = emptyList(), + name = "", + bankIconImageUrl = "", + balance = "", + otpLength = "", + updatedAt = DateTime(), + isAadhaarConsentProvided = true, + upiLiteInfo = emptyList(), + creditLineNotAllowedMCC = emptyList(), + ) + val result = + naviPayOffersHelper.buildParamsMap( + paymentAmount = "1000", + selectedBankAccount = selectedBankAccount, + payeeMcc = "5411", + purposeCode = "00", + ) + + assertEquals(4, result.size) + assertEquals("1000", result[OfferResponse.SessionAttributesInfo.Type.TXN_AMOUNT]) + assertEquals("HDFC", result[OfferResponse.SessionAttributesInfo.Type.UPI_PAYER_BANK_CODE]) + assertEquals("5411", result[OfferResponse.SessionAttributesInfo.Type.UPI_PAYEE_MCC]) + assertEquals("00", result[OfferResponse.SessionAttributesInfo.Type.UPI_PURPOSE_CODE]) + } +} diff --git a/android/navi-pay/src/test/kotlin/com/navi/pay/viewmodel/UpiIdViewModelUnitTest.kt b/android/navi-pay/src/test/kotlin/com/navi/pay/viewmodel/UpiIdViewModelUnitTest.kt index 5a9e412cc5..d9371dc842 100644 --- a/android/navi-pay/src/test/kotlin/com/navi/pay/viewmodel/UpiIdViewModelUnitTest.kt +++ b/android/navi-pay/src/test/kotlin/com/navi/pay/viewmodel/UpiIdViewModelUnitTest.kt @@ -9,6 +9,7 @@ package com.navi.pay.viewmodel import com.navi.base.utils.EMPTY import com.navi.base.utils.ResourceProvider +import com.navi.common.usecase.LitmusExperimentsUseCase import com.navi.pay.common.connectivity.NaviPayNetworkConnectivity import com.navi.pay.common.mockCoroutinesDispatcherProvider import com.navi.pay.common.mockDeviceInfoProvider @@ -16,6 +17,7 @@ import com.navi.pay.common.model.view.NaviPaySessionHelper import com.navi.pay.common.usecase.LinkedAccountsUseCase import com.navi.pay.common.usecase.NaviPayConfigUseCase import com.navi.pay.common.usecase.ValidateVpaUseCase +import com.navi.pay.management.common.sendmoney.util.NaviPayOffersHelper import com.navi.pay.management.common.upiid.viewmodel.UPIIdInputViewModel import io.mockk.MockKAnnotations import io.mockk.impl.annotations.MockK @@ -32,6 +34,8 @@ class UpiIdViewModelUnitTest { @MockK private lateinit var naviPaySessionHelper: NaviPaySessionHelper @MockK private lateinit var validateVpaUseCase: ValidateVpaUseCase @MockK private lateinit var resourceProvider: ResourceProvider + @MockK private lateinit var litmusExperimentsUseCase: LitmusExperimentsUseCase + @MockK private lateinit var naviPayOffersHelper: NaviPayOffersHelper @OptIn(ExperimentalCoroutinesApi::class) private val coroutineDispatcher = UnconfinedTestDispatcher() @@ -53,6 +57,8 @@ class UpiIdViewModelUnitTest { naviPaySessionHelper, validateVpaUseCase, resourceProvider, + naviPayOffersHelper, + litmusExperimentsUseCase, ) // showSuggestions = false and List Empty Cases @@ -219,6 +225,8 @@ class UpiIdViewModelUnitTest { naviPaySessionHelper, validateVpaUseCase, resourceProvider, + naviPayOffersHelper, + litmusExperimentsUseCase, ) Assert.assertEquals(EMPTY, upiIdInputViewModel.getFormattedUpiIdInput(input = EMPTY)) Assert.assertEquals("Adi", upiIdInputViewModel.getFormattedUpiIdInput(input = "Adi")) diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferData.kt b/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferData.kt index 23ac3b32d2..ec4949dde5 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferData.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferData.kt @@ -1,6 +1,6 @@ /* * - * * Copyright © 2024 by Navi Technologies Limited + * * Copyright © 2024-2025 by Navi Technologies Limited * * All rights reserved. Strictly confidential * */ @@ -16,9 +16,10 @@ data class OfferData( val descriptionSuffix: String? = null, val applicableInfo: List? = null, val sessionAttributesInfo: OfferResponse.SessionAttributesInfo? = null, - val offerStrip: OfferResponse.BillPay.OfferStrip? = null, - val offerAppliedStrip: OfferResponse.BillPay.OfferStrip? = null, + val offerStrip: OfferResponse.OfferStrip? = null, + val offerAppliedStrip: OfferResponse.OfferStrip? = null, val tags: List? = null, + val isBestOffer: Boolean = false, ) data class OfferDisabledData( @@ -30,3 +31,8 @@ data class EvaluatedOffer( val offer: OfferData, val disabledData: OfferDisabledData?, // Null if the offer is enabled ) + +enum class OfferType { + ALL, + BEST, +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferResponse.kt b/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferResponse.kt index 711bc62267..7cdd4bba4d 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferResponse.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/common/models/OfferResponse.kt @@ -40,26 +40,43 @@ data class OfferResponse( ) enum class Type { - TXN_AMOUNT + TXN_AMOUNT, + UPI_PURPOSE_CODE, + UPI_PAYER_BANK_CODE, + UPI_PAYEE_MCC, } } data class BillPay( - @SerializedName("offerWidget", alternate = ["earnOffer"]) val earnOffer: EarnOffer? = null, - val offerStrip: OfferStrip? = null, - val offerAppliedStrip: OfferStrip? = null, - ) { - data class EarnOffer( - val iconUrl: String? = null, - val title: Title? = null, - val description: Description? = null, - val applicableInfo: List? = null, - ) + @SerializedName("offerWidget", alternate = ["earnOffer"]) + override val earnOffer: EarnOffer? = null, + override val offerStrip: OfferStrip? = null, + override val offerAppliedStrip: OfferStrip? = null, + ) : OfferWidget - data class OfferStrip(val prefixText: String? = null, val suffixText: String? = null) + data class NaviPay( + @SerializedName("offerWidget", alternate = ["earnOffer"]) + override val earnOffer: EarnOffer? = null, + override val offerStrip: OfferStrip? = null, + override val offerAppliedStrip: OfferStrip? = null, + ) : OfferWidget - data class Title(val prefixText: String? = null, val suffixText: String? = null) - - data class Description(val prefixText: String? = null, val suffixText: String? = null) + interface OfferWidget { + val earnOffer: EarnOffer? + val offerStrip: OfferStrip? + val offerAppliedStrip: OfferStrip? } + + data class EarnOffer( + val iconUrl: String? = null, + val title: Title? = null, + val description: Description? = null, + val applicableInfo: List? = null, + ) + + data class OfferStrip(val prefixText: String? = null, val suffixText: String? = null) + + data class Title(val prefixText: String? = null, val suffixText: String? = null) + + data class Description(val prefixText: String? = null, val suffixText: String? = null) } diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/network/retrofit/RetrofitService.kt b/android/navi-rr/src/main/java/com/navi/rr/common/network/retrofit/RetrofitService.kt index 28fa5117b7..5a2de5fa15 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/common/network/retrofit/RetrofitService.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/common/network/retrofit/RetrofitService.kt @@ -194,7 +194,6 @@ interface RetrofitService { @Header("X-Target") target: String = XTarget.MERLIN.name, @Path("product") product: String, @Query("offerType") offerType: String, - @Query("strategy") strategy: String, @Body offerRequest: OfferRequest, ): Response> @@ -204,7 +203,6 @@ interface RetrofitService { @Header("X-Target") target: String = XTarget.MERLIN.name, @Path("product") product: String, @Query("offerType") offerType: String, - @Query("strategy") strategy: String, @Body offerRequest: List, ): Response diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/repo/OffersRepo.kt b/android/navi-rr/src/main/java/com/navi/rr/common/repo/OffersRepo.kt index 3d5fe6434c..b537fc3c32 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/common/repo/OffersRepo.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/common/repo/OffersRepo.kt @@ -15,6 +15,7 @@ import com.navi.rr.common.models.CoinBurnData import com.navi.rr.common.models.MultipleOffersResponse import com.navi.rr.common.models.OfferRequest import com.navi.rr.common.models.OfferResponse +import com.navi.rr.common.models.OfferType import com.navi.rr.common.network.retrofit.ResponseHandler import com.navi.rr.common.network.retrofit.RetrofitService import com.navi.rr.utils.cachemanager.CacheHandlerProxy @@ -58,8 +59,7 @@ constructor( .addKeyIfMissing(OS_TYPE, OFFERS_OS_TYPE_ANDROID), requestId = requestId, ), - offerType = ALL, - strategy = CAMPAIGN_PRIORITY, + offerType = OfferType.ALL.name, ) .toGenericResponse(), metricInfo = metricInfo, @@ -72,7 +72,6 @@ constructor( product: String, offerRequestList: List? = emptyList(), offerType: String? = null, - strategy: String? = null, metricInfo: MetricInfo>, ): Flow> { return cacheHandlerProxy.fetchData( @@ -83,8 +82,7 @@ constructor( .fetchOffersForProductsForMultipleRequests( product = product, offerRequest = offerRequestList ?: emptyList(), - offerType = offerType ?: ALL, - strategy = strategy ?: CAMPAIGN_PRIORITY, + offerType = offerType ?: OfferType.ALL.name, ) .toGenericResponse(), metricInfo = metricInfo, @@ -109,6 +107,5 @@ constructor( private companion object { const val ALL = "ALL" - const val CAMPAIGN_PRIORITY = "CAMPAIGN_PRIORITY" } } diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferBottomSheetWidget.kt b/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferBottomSheetWidget.kt index 3957898f92..cc4c1fecd6 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferBottomSheetWidget.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferBottomSheetWidget.kt @@ -231,11 +231,16 @@ private fun OfferList( LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (params.isNullOrEmpty()) { items(offerData) { offer -> OfferTicketItem().DefaultOfferTicketItem(offerData = offer) + Spacer( + modifier = + Modifier.height( + if (offer.isBestOffer && offerData.size > 1) 4.dp else 12.dp + ) + ) } } else { items(sortedOffers) { evaluatedOffer -> @@ -255,15 +260,22 @@ private fun OfferList( disabledData = evaluatedOffer.disabledData, ) } + Spacer( + modifier = + Modifier.height( + if (evaluatedOffer.offer.isBestOffer && offerData.size > 1) 4.dp + else 12.dp + ) + ) } } - item { BottomSheetFooter(imageUrl = BOTTOM_SHEET_FOOTER_URL) } + item { BottomSheetFooter() } } } @Composable -private fun BottomSheetFooter(imageUrl: String) { +private fun BottomSheetFooter() { Column( modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -272,7 +284,7 @@ private fun BottomSheetFooter(imageUrl: String) { model = ImageRequest.Builder(LocalContext.current) .allowHardware(false) - .data(imageUrl) + .data(BOTTOM_SHEET_FOOTER_URL) .build(), contentDescription = EMPTY, modifier = Modifier.wrapContentSize(), diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferTicketItem.kt b/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferTicketItem.kt index 5ffd4ffbea..0c62533964 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferTicketItem.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/common/ui/OfferTicketItem.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -54,6 +55,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.navi.base.utils.SPACE import com.navi.base.utils.orZero +import com.navi.common.extensions.conditional import com.navi.common.shape.TicketWidgetShape import com.navi.common.utils.Constants.DEBOUNCE_TIME import com.navi.design.font.FontWeightEnum @@ -65,7 +67,6 @@ import com.navi.rr.R import com.navi.rr.common.models.CoinBurnData import com.navi.rr.common.models.OfferData import com.navi.rr.common.models.OfferDisabledData -import com.navi.rr.common.models.OfferResponse import com.navi.rr.common.theme.color.NaviRRColor import com.navi.rr.common.views.CommonAsyncImage import com.navi.rr.common.views.DashedDivider @@ -81,115 +82,171 @@ class OfferTicketItem { fun DefaultOfferTicketItem(modifier: Modifier = Modifier, offerData: OfferData) { var isExpanded by remember { mutableStateOf(false) } var heightFromTop by remember { mutableFloatStateOf(0f) } - TicketItem(modifier = modifier, heightFromTop = heightFromTop) { - Column { - Column(modifier = Modifier.onSizeChanged { heightFromTop = it.height.toFloat() }) { - Row( - modifier = - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, + + Column { + if (offerData.isBestOffer) { + BestOfferStrip( + modifier = + Modifier.fillMaxWidth() + .background( + color = NaviRRColor.purpleText, + shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp), + ) + ) + } + TicketItem( + modifier = modifier.conditional(offerData.isBestOffer) { offset(y = (-8).dp) }, + heightFromTop = heightFromTop, + ) { + Column { + Column( + modifier = Modifier.onSizeChanged { heightFromTop = it.height.toFloat() } ) { - Column(horizontalAlignment = Alignment.Start) { - Box( - modifier = Modifier.height(IntrinsicSize.Max), - contentAlignment = Alignment.CenterStart, - ) { - Spacer( - modifier = - Modifier.padding(start = 16.dp) - .background( - brush = - Brush.horizontalGradient( - colors = - listOf( - NaviRRColor.earnOfferGradientStart, - Color.White, - ) - ) - ) - .width(180.dp) - .height(24.dp) - ) - Row( - modifier = Modifier.padding(start = 18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = Modifier.width(17.dp)) - NaviText( - text = offerData.titlePrefix.orEmpty(), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = naviFontFamily, - color = NaviRRColor.primaryText, - ) - Spacer(modifier = Modifier.width(3.dp)) - Image( - painter = - painterResource(id = WidgetsR.drawable.navi_coin_40), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(2.dp)) - NaviText( - text = offerData.titleSuffix.orEmpty(), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = NaviRRColor.primaryText, - fontFamily = naviFontFamily, - modifier = Modifier, - ) - } + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(horizontalAlignment = Alignment.Start) { Box( - modifier = - Modifier.shadow( - shape = CircleShape, - elevation = 8.dp, - ambientColor = Color.Black, - spotColor = Color(0x4DD1D9E6), - ) - .background(Color.White, shape = CircleShape) - .size(30.dp), - contentAlignment = Alignment.Center, + modifier = Modifier.height(IntrinsicSize.Max), + contentAlignment = Alignment.CenterStart, ) { - CommonAsyncImage( - imageUrl = offerData.iconUrl.orEmpty(), - modifier = Modifier.width(24.dp), - contentScale = ContentScale.FillWidth, - placeholderIconResId = - WidgetsR.drawable.navi_widgets_ic_biller_placeholder, + Spacer( + modifier = + Modifier.padding(start = 16.dp) + .background( + brush = + Brush.horizontalGradient( + colors = + listOf( + NaviRRColor + .earnOfferGradientStart, + Color.White, + ) + ) + ) + .width(180.dp) + .height(24.dp) + ) + Row( + modifier = Modifier.padding(start = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(17.dp)) + NaviText( + text = offerData.titlePrefix.orEmpty(), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = naviFontFamily, + color = NaviRRColor.primaryText, + ) + Spacer(modifier = Modifier.width(3.dp)) + Image( + painter = + painterResource( + id = WidgetsR.drawable.navi_coin_40 + ), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(2.dp)) + NaviText( + text = offerData.titleSuffix.orEmpty(), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = NaviRRColor.primaryText, + fontFamily = naviFontFamily, + modifier = Modifier, + ) + } + Box( + modifier = + Modifier.shadow( + shape = CircleShape, + elevation = 8.dp, + ambientColor = Color.Black, + spotColor = Color(0x4DD1D9E6), + ) + .background(Color.White, shape = CircleShape) + .size(30.dp), + contentAlignment = Alignment.Center, + ) { + CommonAsyncImage( + imageUrl = offerData.iconUrl.orEmpty(), + modifier = Modifier.width(24.dp), + contentScale = ContentScale.FillWidth, + placeholderIconResId = + WidgetsR.drawable.navi_widgets_ic_biller_placeholder, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + NaviText( + text = offerData.descriptionPrefix.orEmpty(), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.secondaryText, + ) + NaviText( + text = offerData.descriptionSuffix.orEmpty(), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.SemiBold, + color = NaviRRColor.secondaryText, ) } } - - Spacer(modifier = Modifier.height(2.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - NaviText( - text = offerData.descriptionPrefix.orEmpty(), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.Medium, - color = NaviRRColor.secondaryText, - ) - NaviText( - text = offerData.descriptionSuffix.orEmpty(), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.SemiBold, - color = NaviRRColor.secondaryText, - ) - } } } + DashedDivider() + // Expandable section + BulletPointSection( + offerData.applicableInfo, + isExpanded, + setIsExpanded = { isExpanded = it }, + ) } - DashedDivider() - // Expandable section - BulletPointSection( - offerData.applicableInfo, - isExpanded, - setIsExpanded = { isExpanded = it }, + } + } + } + + @Composable + fun BestOfferStrip(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.height(20.dp).padding(vertical = 4.dp), + painter = painterResource(WidgetsR.drawable.ic_offer_purple), + contentDescription = null, + contentScale = ContentScale.FillHeight, + ) + NaviText( + text = stringResource(id = R.string.best_offer_caps), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.SemiBold, + color = NaviRRColor.white, + letterSpacing = 1.5.sp, + modifier = Modifier.padding(horizontal = 6.dp), + ) + Image( + modifier = Modifier.height(20.dp).padding(vertical = 4.dp), + painter = painterResource(WidgetsR.drawable.ic_offer_purple), + contentDescription = null, + contentScale = ContentScale.FillHeight, ) } + Spacer(modifier = Modifier.height(8.dp)) } } @@ -197,183 +254,214 @@ class OfferTicketItem { fun AppliedOfferTicketItem(modifier: Modifier = Modifier, offerData: OfferData) { var isExpanded by remember { mutableStateOf(false) } var heightFromTop by remember { mutableFloatStateOf(0f) } - TicketItem(modifier = modifier, heightFromTop = heightFromTop) { - Column { - Column(modifier = Modifier.onSizeChanged { heightFromTop = it.height.toFloat() }) { - Row( - modifier = - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, + + Column { + if (offerData.isBestOffer) { + BestOfferStrip( + modifier = + Modifier.fillMaxWidth() + .background( + color = NaviRRColor.purpleText, + shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp), + ) + ) + } + TicketItem( + modifier = modifier.conditional(offerData.isBestOffer) { offset(y = (-8).dp) }, + heightFromTop = heightFromTop, + ) { + Column { + Column( + modifier = Modifier.onSizeChanged { heightFromTop = it.height.toFloat() } ) { - Column(horizontalAlignment = Alignment.Start) { - Box( - modifier = Modifier.height(IntrinsicSize.Max), - contentAlignment = Alignment.CenterStart, - ) { - Spacer( - modifier = - Modifier.padding(start = 16.dp) - .background( - brush = - Brush.horizontalGradient( - colors = - listOf( - NaviRRColor.earnOfferGradientStart, - Color.White, - ) - ) - ) - .width(180.dp) - .height(24.dp) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(horizontalAlignment = Alignment.Start) { + Box( + modifier = Modifier.height(IntrinsicSize.Max), + contentAlignment = Alignment.CenterStart, ) { - Row( + Spacer( modifier = - Modifier.padding(start = 18.dp).weight(1f, false), + Modifier.padding(start = 16.dp) + .background( + brush = + Brush.horizontalGradient( + colors = + listOf( + NaviRRColor + .earnOfferGradientStart, + Color.White, + ) + ) + ) + .width(180.dp) + .height(24.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Spacer(modifier = Modifier.width(17.dp)) Row( - modifier = Modifier.weight(1f, false), + modifier = + Modifier.padding(start = 18.dp).weight(1f, false), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, + ) { + Spacer(modifier = Modifier.width(17.dp)) + Row( + modifier = Modifier.weight(1f, false), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + NaviText( + text = + buildAnnotatedString { + append(offerData.titlePrefix.orEmpty()) + appendInlineContent( + SPACER_WIDGET, + SPACE, + ) + appendInlineContent( + COIN_IMAGE_WIDGET, + SPACE, + ) + appendInlineContent( + SPACER_WIDGET, + SPACE, + ) + append(offerData.titleSuffix.orEmpty()) + }, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = naviFontFamily, + color = NaviRRColor.primaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + inlineContent = + mapOf( + COIN_IMAGE_WIDGET to + InlineTextContent( + placeholder = + Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = + PlaceholderVerticalAlign + .Center, + ) + ) { + Image( + painter = + painterResource( + id = + WidgetsR + .drawable + .navi_coin_40 + ), + contentDescription = null, + modifier = + Modifier.size(16.dp), + ) + }, + SPACER_WIDGET to + InlineTextContent( + placeholder = + Placeholder( + width = 3.sp, + height = 0.sp, + placeholderVerticalAlign = + PlaceholderVerticalAlign + .Center, + ) + ) { + Spacer( + modifier = + Modifier.width(3.dp) + ) + }, + ), + ) + } + } + + Column( + modifier = + Modifier.background( + NaviRRColor.burnOfferGradientStart, + shape = CircleShape, + ) + .padding(horizontal = 8.dp, vertical = 4.dp) ) { NaviText( - text = - buildAnnotatedString { - append(offerData.titlePrefix.orEmpty()) - appendInlineContent(SPACER_WIDGET, SPACE) - appendInlineContent( - COIN_IMAGE_WIDGET, - SPACE, - ) - appendInlineContent(SPACER_WIDGET, SPACE) - append(offerData.titleSuffix.orEmpty()) - }, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = naviFontFamily, - color = NaviRRColor.primaryText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - inlineContent = - mapOf( - COIN_IMAGE_WIDGET to - InlineTextContent( - placeholder = - Placeholder( - width = 16.sp, - height = 16.sp, - placeholderVerticalAlign = - PlaceholderVerticalAlign - .Center, - ) - ) { - Image( - painter = - painterResource( - id = - WidgetsR.drawable - .navi_coin_40 - ), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - SPACER_WIDGET to - InlineTextContent( - placeholder = - Placeholder( - width = 3.sp, - height = 0.sp, - placeholderVerticalAlign = - PlaceholderVerticalAlign - .Center, - ) - ) { - Spacer( - modifier = Modifier.width(3.dp) - ) - }, + text = "Applied", + fontSize = 12.sp, + fontWeight = + getFontWeight( + FontWeightEnum.NAVI_HEADLINE_REGULAR ), + color = NaviRRColor.progressBarGreen, + fontFamily = naviFontFamily, + modifier = Modifier, ) } } - Column( + Box( modifier = - Modifier.background( - NaviRRColor.burnOfferGradientStart, + Modifier.shadow( shape = CircleShape, + elevation = 8.dp, + ambientColor = Color.Black, + spotColor = Color(0x4DD1D9E6), ) - .padding(horizontal = 8.dp, vertical = 4.dp) + .background(Color.White, shape = CircleShape) + .size(30.dp), + contentAlignment = Alignment.Center, ) { - NaviText( - text = "Applied", - fontSize = 12.sp, - fontWeight = - getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), - color = NaviRRColor.progressBarGreen, - fontFamily = naviFontFamily, - modifier = Modifier, + CommonAsyncImage( + imageUrl = offerData.iconUrl.orEmpty(), + modifier = Modifier.width(24.dp), + contentScale = ContentScale.FillWidth, + placeholderIconResId = + WidgetsR.drawable.navi_widgets_ic_biller_placeholder, ) } } - Box( - modifier = - Modifier.shadow( - shape = CircleShape, - elevation = 8.dp, - ambientColor = Color.Black, - spotColor = Color(0x4DD1D9E6), - ) - .background(Color.White, shape = CircleShape) - .size(30.dp), - contentAlignment = Alignment.Center, - ) { - CommonAsyncImage( - imageUrl = offerData.iconUrl.orEmpty(), - modifier = Modifier.width(24.dp), - contentScale = ContentScale.FillWidth, - placeholderIconResId = - WidgetsR.drawable.navi_widgets_ic_biller_placeholder, + Spacer(modifier = Modifier.height(2.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + NaviText( + text = offerData.descriptionPrefix.orEmpty(), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.secondaryText, + ) + NaviText( + text = offerData.descriptionSuffix.orEmpty(), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.SemiBold, + color = NaviRRColor.secondaryText, ) } } - - Spacer(modifier = Modifier.height(2.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - NaviText( - text = offerData.descriptionPrefix.orEmpty(), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.Medium, - color = NaviRRColor.secondaryText, - ) - NaviText( - text = offerData.descriptionSuffix.orEmpty(), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.SemiBold, - color = NaviRRColor.secondaryText, - ) - } } } + DashedDivider() + // Expandable section + BulletPointSection( + offerData.applicableInfo, + isExpanded, + setIsExpanded = { isExpanded = it }, + ) } - DashedDivider() - // Expandable section - BulletPointSection( - offerData.applicableInfo, - isExpanded, - setIsExpanded = { isExpanded = it }, - ) } } } @@ -386,168 +474,196 @@ class OfferTicketItem { ) { var isExpanded by remember { mutableStateOf(false) } var heightFromTop by remember { mutableFloatStateOf(0f) } - TicketItem(modifier = modifier, heightFromTop = heightFromTop) { - Column { - Column(modifier = Modifier.onSizeChanged { heightFromTop = it.height.toFloat() }) { - Row( - modifier = - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, + + Column { + if (offerData.isBestOffer) { + BestOfferStrip( + modifier = + Modifier.fillMaxWidth() + .background( + color = NaviRRColor.purpleText, + shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp), + ) + ) + } + TicketItem( + modifier = modifier.conditional(offerData.isBestOffer) { offset(y = (-8).dp) }, + heightFromTop = heightFromTop, + ) { + Column { + Column( + modifier = Modifier.onSizeChanged { heightFromTop = it.height.toFloat() } ) { - Column(horizontalAlignment = Alignment.Start) { - Box( - modifier = Modifier.height(IntrinsicSize.Max), - contentAlignment = Alignment.CenterStart, - ) { - Spacer( - modifier = - Modifier.padding(start = 16.dp) - .background( - brush = - Brush.horizontalGradient( - colors = - listOf( - NaviRRColor - .disabledOfferGradientStart, - Color.White, - ) - ) - ) - .width(180.dp) - .height(24.dp) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(horizontalAlignment = Alignment.Start) { + Box( + modifier = Modifier.height(IntrinsicSize.Max), + contentAlignment = Alignment.CenterStart, ) { - Row( - modifier = Modifier.padding(start = 18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = Modifier.width(17.dp)) - Row( - modifier = Modifier.weight(1f, false), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - NaviText( - text = - buildAnnotatedString { - append(offerData.titlePrefix.orEmpty()) - appendInlineContent(SPACER_WIDGET, SPACE) - appendInlineContent( - COIN_IMAGE_WIDGET, - SPACE, + Spacer( + modifier = + Modifier.padding(start = 16.dp) + .background( + brush = + Brush.horizontalGradient( + colors = + listOf( + NaviRRColor + .disabledOfferGradientStart, + Color.White, + ) ) - appendInlineContent(SPACER_WIDGET, SPACE) - append(offerData.titleSuffix.orEmpty()) - }, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = naviFontFamily, - color = NaviRRColor.primaryText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - inlineContent = - mapOf( - COIN_IMAGE_WIDGET to - InlineTextContent( - placeholder = - Placeholder( - width = 16.sp, - height = 16.sp, - placeholderVerticalAlign = - PlaceholderVerticalAlign - .Center, + ) + .width(180.dp) + .height(24.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.padding(start = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(17.dp)) + Row( + modifier = Modifier.weight(1f, false), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + NaviText( + text = + buildAnnotatedString { + append(offerData.titlePrefix.orEmpty()) + appendInlineContent( + SPACER_WIDGET, + SPACE, + ) + appendInlineContent( + COIN_IMAGE_WIDGET, + SPACE, + ) + appendInlineContent( + SPACER_WIDGET, + SPACE, + ) + append(offerData.titleSuffix.orEmpty()) + }, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = naviFontFamily, + color = NaviRRColor.primaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + inlineContent = + mapOf( + COIN_IMAGE_WIDGET to + InlineTextContent( + placeholder = + Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = + PlaceholderVerticalAlign + .Center, + ) + ) { + Image( + painter = + painterResource( + id = + WidgetsR + .drawable + .ic_navi_coin_grey + ), + contentDescription = null, + modifier = + Modifier.size(16.dp), ) - ) { - Image( - painter = - painterResource( - id = - WidgetsR.drawable - .ic_navi_coin_grey - ), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - SPACER_WIDGET to - InlineTextContent( - placeholder = - Placeholder( - width = 3.sp, - height = 0.sp, - placeholderVerticalAlign = - PlaceholderVerticalAlign - .Center, + }, + SPACER_WIDGET to + InlineTextContent( + placeholder = + Placeholder( + width = 3.sp, + height = 0.sp, + placeholderVerticalAlign = + PlaceholderVerticalAlign + .Center, + ) + ) { + Spacer( + modifier = + Modifier.width(3.dp) ) - ) { - Spacer( - modifier = Modifier.width(3.dp) - ) - }, - ), - ) + }, + ), + ) + } } } + + Box( + modifier = + Modifier.shadow( + shape = CircleShape, + elevation = 8.dp, + ambientColor = Color.Black, + spotColor = Color(0x4DD1D9E6), + ) + .background(Color.White, shape = CircleShape) + .size(30.dp), + contentAlignment = Alignment.Center, + ) { + CommonAsyncImage( + imageUrl = offerData.iconUrl.orEmpty(), + modifier = Modifier.width(24.dp), + contentScale = ContentScale.FillWidth, + placeholderIconResId = + WidgetsR.drawable.navi_widgets_ic_biller_placeholder, + ) + } } - Box( - modifier = - Modifier.shadow( - shape = CircleShape, - elevation = 8.dp, - ambientColor = Color.Black, - spotColor = Color(0x4DD1D9E6), - ) - .background(Color.White, shape = CircleShape) - .size(30.dp), - contentAlignment = Alignment.Center, - ) { - CommonAsyncImage( - imageUrl = offerData.iconUrl.orEmpty(), - modifier = Modifier.width(24.dp), - contentScale = ContentScale.FillWidth, - placeholderIconResId = - WidgetsR.drawable.navi_widgets_ic_biller_placeholder, + Spacer(modifier = Modifier.height(2.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + NaviText( + text = offerData.descriptionPrefix.orEmpty(), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.secondaryText, + ) + NaviText( + text = offerData.descriptionSuffix.orEmpty(), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.SemiBold, + color = NaviRRColor.secondaryText, ) } + + Spacer(modifier = Modifier.height(6.dp)) + + DisabledOfferItemReason(disabledData = disabledData) } - - Spacer(modifier = Modifier.height(2.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - NaviText( - text = offerData.descriptionPrefix.orEmpty(), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.Medium, - color = NaviRRColor.secondaryText, - ) - NaviText( - text = offerData.descriptionSuffix.orEmpty(), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.SemiBold, - color = NaviRRColor.secondaryText, - ) - } - - Spacer(modifier = Modifier.height(6.dp)) - - DisabledOfferItemReason(disabledData) } } + DashedDivider() + // Expandable section + BulletPointSection( + offerData.applicableInfo, + isExpanded, + setIsExpanded = { isExpanded = it }, + ) } - DashedDivider() - // Expandable section - BulletPointSection( - offerData.applicableInfo, - isExpanded, - setIsExpanded = { isExpanded = it }, - ) } } } @@ -681,42 +797,6 @@ class OfferTicketItem { } } - @Composable - private fun DisabledOfferItemReason(disabledData: OfferDisabledData) { - when (disabledData.type) { - OfferResponse.SessionAttributesInfo.Type.TXN_AMOUNT -> { - if (disabledData.threshold.toDoubleOrNull() == null) return - Row(verticalAlignment = Alignment.CenterVertically) { - NaviText( - text = TXN_AMOUNT_DISABLED_PREFIX, - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.Medium, - color = NaviRRColor.textOrange, - ) - NaviText( - text = - buildString { - append(RUPEE_SYMBOL) - append(moneyFormat(disabledData.threshold)) - }, - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.SemiBold, - color = NaviRRColor.textOrange, - ) - NaviText( - text = TXN_AMOUNT_DISABLED_SUFFIX, - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = FontWeight.Medium, - color = NaviRRColor.textOrange, - ) - } - } - } - } - @Composable private fun TicketItem( modifier: Modifier = Modifier, @@ -839,10 +919,4 @@ class OfferTicketItem { } } } - - private companion object { - const val TXN_AMOUNT_DISABLED_PREFIX = "Pay " - const val TXN_AMOUNT_DISABLED_SUFFIX = " more to get this offer" - const val RUPEE_SYMBOL = "₹" - } } diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/ui/OffersCommonComposables.kt b/android/navi-rr/src/main/java/com/navi/rr/common/ui/OffersCommonComposables.kt new file mode 100644 index 0000000000..c49b77d1f8 --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/common/ui/OffersCommonComposables.kt @@ -0,0 +1,107 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.common.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.navi.design.font.naviFontFamily +import com.navi.naviwidgets.extensions.NaviText +import com.navi.rr.common.models.OfferDisabledData +import com.navi.rr.common.models.OfferResponse +import com.navi.rr.common.theme.color.NaviRRColor +import com.navi.rr.utils.constants.OffersConstants.RUPEE_SYMBOL +import com.navi.rr.utils.constants.OffersConstants.TXN_AMOUNT_DISABLED_PREFIX +import com.navi.rr.utils.constants.OffersConstants.TXN_AMOUNT_DISABLED_SUFFIX +import com.navi.rr.utils.constants.OffersConstants.UPI_LITE +import com.navi.rr.utils.constants.OffersConstants.UPI_PAYEE_MCC +import com.navi.rr.utils.constants.OffersConstants.UPI_PAYER_BANK_CODE +import com.navi.rr.utils.constants.OffersConstants.UPI_PURPOSE_CODE_PREFIX +import com.navi.rr.utils.constants.OffersConstants.UPI_PURPOSE_CODE_SUFFIX +import com.navi.uitron.utils.transformations.moneyFormat + +@Composable +fun DisabledOfferItemReason(disabledData: OfferDisabledData) { + when (disabledData.type) { + OfferResponse.SessionAttributesInfo.Type.TXN_AMOUNT -> { + if (disabledData.threshold.toDoubleOrNull() == null) return + Row(verticalAlignment = Alignment.CenterVertically) { + NaviText( + text = TXN_AMOUNT_DISABLED_PREFIX, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.textOrange, + ) + NaviText( + text = + buildString { + append(RUPEE_SYMBOL) + append(moneyFormat(disabledData.threshold)) + }, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.SemiBold, + color = NaviRRColor.textOrange, + ) + NaviText( + text = TXN_AMOUNT_DISABLED_SUFFIX, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.textOrange, + ) + } + } + OfferResponse.SessionAttributesInfo.Type.UPI_PURPOSE_CODE -> { + Row(verticalAlignment = Alignment.CenterVertically) { + NaviText( + text = UPI_PURPOSE_CODE_PREFIX, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.textOrange, + ) + NaviText( + text = UPI_LITE, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.SemiBold, + color = NaviRRColor.textOrange, + ) + NaviText( + text = UPI_PURPOSE_CODE_SUFFIX, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.textOrange, + ) + } + } + OfferResponse.SessionAttributesInfo.Type.UPI_PAYER_BANK_CODE -> { + NaviText( + text = UPI_PAYER_BANK_CODE, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.textOrange, + ) + } + OfferResponse.SessionAttributesInfo.Type.UPI_PAYEE_MCC -> { + NaviText( + text = UPI_PAYEE_MCC, + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = FontWeight.Medium, + color = NaviRRColor.textOrange, + ) + } + } +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/constants/Constants.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/constants/Constants.kt index 3e67ccdb5c..184c9ded71 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/utils/constants/Constants.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/constants/Constants.kt @@ -185,4 +185,12 @@ object OffersConstants { const val BOTTOM_SHEET_FOOTER_URL = "https://public-assets.prod.navi-sa.in/bbps/bbps-landing-page-v2/reward_bottomsheet_end.png" const val TXN_AMOUNT = "TXN_AMOUNT" + const val TXN_AMOUNT_DISABLED_PREFIX = "Pay " + const val TXN_AMOUNT_DISABLED_SUFFIX = " more to get this offer" + const val RUPEE_SYMBOL = "₹" + const val UPI_PURPOSE_CODE_PREFIX = "This offer is applicable only on " + const val UPI_PURPOSE_CODE_SUFFIX = " transactions" + const val UPI_LITE = "UPI Lite" + const val UPI_PAYER_BANK_CODE = "This bank is not eligible for this offer" + const val UPI_PAYEE_MCC = "This offer is not eligible for this transaction" } diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/mapper/OfferResponseToOfferDataMapper.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/mapper/OfferResponseToOfferDataMapper.kt index 6efefcb515..22a219439c 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/utils/mapper/OfferResponseToOfferDataMapper.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/mapper/OfferResponseToOfferDataMapper.kt @@ -7,6 +7,7 @@ package com.navi.rr.utils.mapper +import com.navi.common.model.ModuleNameV2 import com.navi.rr.common.models.OfferData import com.navi.rr.common.models.OfferResponse import com.navi.rr.utils.ext.toJson @@ -14,22 +15,34 @@ import com.navi.rr.utils.getGsonBuilders import javax.inject.Inject class OfferResponseToOfferDataMapper @Inject constructor() { - fun map(offerResponse: OfferResponse): OfferData { + fun map(offerResponse: OfferResponse, vertical: ModuleNameV2): OfferData { val metadata = offerResponse.metadata + + val naviPayMap = + getGsonBuilders() + .fromJson(metadata?.get(NAVIPAY).toJson(), OfferResponse.NaviPay::class.java) + val billPayMap = getGsonBuilders() .fromJson(metadata?.get(BILLPAY).toJson(), OfferResponse.BillPay::class.java) + val selectedMap = + when (vertical) { + ModuleNameV2.NAVIPAY -> naviPayMap + ModuleNameV2.BBPS -> billPayMap + else -> null // Handle any other cases + } + return OfferData( campaignId = offerResponse.campaignId, - iconUrl = billPayMap?.earnOffer?.iconUrl, - titlePrefix = billPayMap?.earnOffer?.title?.prefixText, - titleSuffix = billPayMap?.earnOffer?.title?.suffixText, - descriptionPrefix = billPayMap?.earnOffer?.description?.prefixText, - descriptionSuffix = billPayMap?.earnOffer?.description?.suffixText, - applicableInfo = billPayMap?.earnOffer?.applicableInfo, - offerStrip = billPayMap?.offerStrip, - offerAppliedStrip = billPayMap?.offerAppliedStrip, + iconUrl = selectedMap?.earnOffer?.iconUrl, + titlePrefix = selectedMap?.earnOffer?.title?.prefixText, + titleSuffix = selectedMap?.earnOffer?.title?.suffixText, + descriptionPrefix = selectedMap?.earnOffer?.description?.prefixText, + descriptionSuffix = selectedMap?.earnOffer?.description?.suffixText, + applicableInfo = selectedMap?.earnOffer?.applicableInfo, + offerStrip = selectedMap?.offerStrip, + offerAppliedStrip = selectedMap?.offerAppliedStrip, sessionAttributesInfo = offerResponse.sessionAttributesInfo, tags = offerResponse.tags, ) @@ -37,5 +50,6 @@ class OfferResponseToOfferDataMapper @Inject constructor() { companion object { private const val BILLPAY = "billPay" + private const val NAVIPAY = "naviPay" } } diff --git a/android/navi-rr/src/main/res/values/strings.xml b/android/navi-rr/src/main/res/values/strings.xml index a0f269e191..e2aede83f3 100644 --- a/android/navi-rr/src/main/res/values/strings.xml +++ b/android/navi-rr/src/main/res/values/strings.xml @@ -47,5 +47,6 @@ Know more Know less Navi coins discount + BEST OFFER diff --git a/android/navi-widgets/src/main/res/drawable/navi_coin_12.xml b/android/navi-widgets/src/main/res/drawable/navi_coin_12.xml new file mode 100644 index 0000000000..f6c9cd66f2 --- /dev/null +++ b/android/navi-widgets/src/main/res/drawable/navi_coin_12.xml @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +