diff --git a/android/app/src/main/java/com/naviapp/common/navigator/NaviDeepLinkNavigator.kt b/android/app/src/main/java/com/naviapp/common/navigator/NaviDeepLinkNavigator.kt index bc0ddb0b05..94f1bf825f 100644 --- a/android/app/src/main/java/com/naviapp/common/navigator/NaviDeepLinkNavigator.kt +++ b/android/app/src/main/java/com/naviapp/common/navigator/NaviDeepLinkNavigator.kt @@ -409,7 +409,7 @@ object NaviDeepLinkNavigator : DeepLinkListener { NavArgs( ctaData = ctaData, finish = finish.orFalse(), - bundle = bundle, + bundle = ctaData.bundle ?: bundle, needsResult = needsResult, requestCode = requestCode, clearTask = clearTaskTemp, 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 b6f2ecf258..8d28acc80b 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 @@ -143,7 +143,10 @@ object FirebaseRemoteConfigHelper { const val NAVI_PAY_DATA_REFRESH_MIN_TIMESTAMP = "NAVI_PAY_DATA_REFRESH_MIN_TIMESTAMP" const val NAVI_PAY_ENABLE_DARK_KNIGHT = "NAVI_PAY_ENABLE_DARK_KNIGHT" const val NAVI_PAY_UPI_GLOBAL_ENABLED = "NAVI_PAY_UPI_GLOBAL_ENABLED" + const val MM_DISCOVERABILITY_ENABLED = "MM_DISCOVERABILITY_ENABLED" const val NAVI_PAY_EMI_CONVERSION_ENABLED = "NAVI_PAY_EMI_CONVERSION_ENABLED" + const val SHOULD_USE_GLOBAL_SCOPE_FOR_DASHBOARD_SYNC = + "SHOULD_USE_GLOBAL_SCOPE_FOR_DASHBOARD_SYNC" // COMMON const val LITMUS_EXPERIMENTS_CACHE_DURATION_IN_MILLIS = diff --git a/android/navi-common/src/main/java/com/navi/common/utils/Constants.kt b/android/navi-common/src/main/java/com/navi/common/utils/Constants.kt index 4263bba260..0ce348a44b 100644 --- a/android/navi-common/src/main/java/com/navi/common/utils/Constants.kt +++ b/android/navi-common/src/main/java/com/navi/common/utils/Constants.kt @@ -351,6 +351,10 @@ object Constants { // navi-ipl scratch card experience experiment keys const val LITMUS_EXPERIMENT_NAVIPAY_NAVI_POWER_PLAY = "hpc-Navi-Powerplay" + // money manager litmus experiments keys + const val LITMUS_EXPERIMENT_CHECK_BALANCE_MM_EXPERIENCE = "mm-discoverability-check-balance" + const val LITMUS_EXPERIMENT_HPC_MM_ROLLOUT = "hpc-money-manager-rollout" + // navi-personalised offer experience experiment keys const val LITMUS_EXPERIMENT_NAVIPAY_PERSONALISED_OFFERS = "personalised_offers_check" diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/analytics/MMAnalytics.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/analytics/MMAnalytics.kt index 145cb62051..378f9166d3 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/analytics/MMAnalytics.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/analytics/MMAnalytics.kt @@ -471,6 +471,19 @@ interface DashboardEventTracker : @EventName("mm_dashboard_bill_calendar_entry_point_viewed") fun onDashboardBillCalendarEntryPointViewed() + + @EventName("mm_discoverability_dashboard_screen_cb_bank_account_selected") + fun onSelectedBankForBalanceUpdated() + + @EventName("mm_discoverability_dashboard_screen_add_account_state_handled") + fun onAddAccountStateHandled() + + @EventName("mm_discoverability_dashboard_screen_finarkein_data_fetch_triggered") + fun onFinarkeinDataFetchTriggered(bankFipId: String) + + @EventName("mm_dashboard_sync_started") fun onDashboardSyncStarted(scope: String) + + @EventName("mm_dashboard_sync_skipped_already_running") fun onDashboardSyncSkipped() } @AutoGenerate @@ -1018,3 +1031,32 @@ interface MMActivityEventTracker { isMMOnNewIntentEnabled: Boolean, ) } + +@AutoGenerate +interface MMDiscoverabilityEventTracker { + + @EventName("mm_discoverability_cb_widget_visible") + fun onMMDiscoverabilityWidgetVisible(widgetState: String) + + @EventName("mm_discoverability_cb_widget_footer_clicked") + fun onMMDiscoverabilityWidgetFooterClicked(fipId: String, maskedAccountNumber: String) + + @EventName("mm_discoverability_cb_widget_no_data_state_triggered") + fun onMMDiscoverabilityWidgetNoDataStateTriggered() + + @EventName("mm_discoverability_entry_check_started") + fun onMMDiscoverabilityCheckStarted( + accountType: String, + experimentEnabled: Boolean, + bankCode: String, + ) + + @EventName("mm_discoverability_entry_point_not_eligible") + fun onMMDiscoverabilityEntryPointNotEligible(reason: String) + + @EventName("mm_discoverability_entry_point_eligible") + fun onMMDiscoverabilityEntryPointEligible() + + @EventName("mm_discoverability_entry_point_load_triggered") + fun onMMDiscoverabilityEntryPointLoadTriggered() +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/helper/CheckBalanceScreenEntryPointHelper.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/helper/CheckBalanceScreenEntryPointHelper.kt new file mode 100644 index 0000000000..c3a2f67ec4 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/helper/CheckBalanceScreenEntryPointHelper.kt @@ -0,0 +1,193 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.dataprovider.data.checkbalance.helper + +import android.os.Bundle +import com.navi.base.model.CtaData +import com.navi.base.utils.formatToInrWithDecimals +import com.navi.base.utils.orZero +import com.navi.moneymanager.R +import com.navi.moneymanager.common.illustration.model.IllustrationSource +import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.CHECK_BALANCE_LOCK_ICON +import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM +import com.navi.moneymanager.common.model.CategorySummary +import com.navi.moneymanager.common.model.database.AccountOverview +import com.navi.moneymanager.common.navigation.navigator.MMDeeplinkNavigator.MONEY_MANAGER_ACTIVITY +import com.navi.moneymanager.common.network.model.CategoryItemData +import com.navi.moneymanager.common.network.model.MMConfigResponse +import com.navi.moneymanager.common.ui.theme.color.MMColor.categoriesProgressBarColors +import com.navi.moneymanager.common.utils.Constants.BANK_FIP_ID +import com.navi.moneymanager.common.utils.Constants.BILLS +import com.navi.moneymanager.common.utils.Constants.CATEGORY_BILLSANDUTILITY +import com.navi.moneymanager.common.utils.Constants.CATEGORY_SHOPPING +import com.navi.moneymanager.common.utils.Constants.CHECK_BALANCE_LINKED_ACCOUNT_REFERENCE +import com.navi.moneymanager.common.utils.Constants.MASKED_ACCOUNT_NUMBER +import com.navi.moneymanager.common.utils.Constants.MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW +import com.navi.moneymanager.common.utils.Constants.MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW_NAME +import com.navi.moneymanager.common.utils.Constants.SELF_TRANSFER +import com.navi.moneymanager.common.utils.Constants.SHOPPING +import com.navi.moneymanager.common.utils.Constants.SHOPPING_CATEGORY_ICON +import com.navi.moneymanager.common.utils.Constants.UTILITIES_CATEGORY_ICON +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceCategoryItemData +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMButtonData +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointState +import com.navi.naviwidgets.utils.NaviWidgetLottieUtils.TUK_TUK_LOTTIE +import com.navi.naviwidgets.utils.ZERO +import javax.inject.Inject + +class CheckBalanceScreenEntryPointHelper @Inject constructor() { + + fun getTopTwoCategoriesData( + categories: List, + mmConfig: MMConfigResponse?, + ): List? { + + // Filter categories excluding "SELF_TRANSFER" + val filteredCategories = getFilteredCategories(categories) + + // Sort categories by totalAmount in descending order + val sortedCategories = filteredCategories.sortedByDescending { it.totalAmount } + + // Calculate total amount of all categories + val totalAmount = categories.sumOf { it.totalAmount.orZero() } + + // Calculate percentage for top category + val topCategoryWithPercentage = + sortedCategories.take(2).mapIndexed { index, item -> + val effectiveTotalAmount = if (totalAmount == 0.0) 1.0 else totalAmount + val progress = item.totalAmount.orZero() / effectiveTotalAmount + val category = + mmConfig?.categories?.firstOrNull { it.categoryId == item.finalCategory } + ?: return null + CheckBalanceCategoryItemData( + name = category.categoryName.orEmpty(), + iconUrl = + IllustrationSource.Remote( + url = category.categoryIcon.orEmpty(), + placeholder = IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM, + ), + progress.toFloat().coerceAtLeast(0.03f), // Need to show min 3% for all progress + progressColor = categoriesProgressBarColors[index], + amount = + item.totalAmount + .orZero() + .formatToInrWithDecimals(showDecimalOnlyForFractional = true), + ) + } + + return topCategoryWithPercentage + } + + private fun getFilteredCategories( + categorySummary: List + ): List { + return categorySummary.filter { it.finalCategory != SELF_TRANSFER } + } + + fun getLockedState(mmConfig: MMConfigResponse?): CheckBalanceMMEntryPointState { + return CheckBalanceMMEntryPointState.Locked( + spendCategories = getDefaultCategoryData(mmConfig), + lockedIcon = + IllustrationSource.Remote( + url = CHECK_BALANCE_LOCK_ICON, + placeholder = IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM, + ), + ) + } + + fun getLoadedState( + spendCategories: List + ): CheckBalanceMMEntryPointState { + return CheckBalanceMMEntryPointState.Loaded(spendCategories = spendCategories) + } + + fun getLoadingState(mmConfig: MMConfigResponse?): CheckBalanceMMEntryPointState { + return CheckBalanceMMEntryPointState.Loading( + spendCategories = getDefaultCategoryData(mmConfig), + loadingLottie = IllustrationSource.Resource(resId = TUK_TUK_LOTTIE), + ) + } + + fun getEmptyState(mmConfig: MMConfigResponse?): CheckBalanceMMEntryPointState { + return CheckBalanceMMEntryPointState.Empty( + spendCategories = getEmptyStateCategoryData(mmConfig) + ) + } + + private fun getEmptyStateCategoryData( + mmConfig: MMConfigResponse? + ): List { + return mmConfig?.categories?.take(2)?.mapIndexed { index, it -> + CheckBalanceCategoryItemData( + name = it.categoryName.orEmpty(), + iconUrl = + IllustrationSource.Remote( + url = it.categoryIcon.orEmpty(), + placeholder = IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM, + ), + progress = null, + progressColor = categoriesProgressBarColors[index], + amount = + ZERO.toDouble().formatToInrWithDecimals(showDecimalOnlyForFractional = true), + ) + } ?: emptyList() + } + + fun getFooterButtonData( + maskedAccountNumber: String? = null, + bankFipId: String? = null, + selectedAccount: AccountOverview? = null, + ): CheckBalanceMMButtonData { + return CheckBalanceMMButtonData( + textResId = R.string.check_spend_analysis, + ctaData = + CtaData( + url = MONEY_MANAGER_ACTIVITY, + bundle = + Bundle().apply { + putString(MASKED_ACCOUNT_NUMBER, maskedAccountNumber) + putString(BANK_FIP_ID, bankFipId) + putString( + CHECK_BALANCE_LINKED_ACCOUNT_REFERENCE, + selectedAccount?.linkedAccRef, + ) + putString( + MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW, + MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW_NAME, + ) + }, + ), + ) + } + + fun getDefaultCategoryData(mmConfig: MMConfigResponse?): List { + val billCategory = + mmConfig?.categories?.firstOrNull { it.categoryId == CATEGORY_BILLSANDUTILITY } + ?: CategoryItemData(categoryName = BILLS, categoryIcon = UTILITIES_CATEGORY_ICON) + val shoppingCategory = + mmConfig?.categories?.firstOrNull { it.categoryId == CATEGORY_SHOPPING } + ?: CategoryItemData(categoryName = SHOPPING, categoryIcon = SHOPPING_CATEGORY_ICON) + + return listOf( + CheckBalanceCategoryItemData( + name = billCategory.categoryName.orEmpty(), + iconUrl = IllustrationSource.Remote(billCategory.categoryIcon.orEmpty()), + progress = 0.7f, + progressColor = categoriesProgressBarColors[0], + amount = 5000.0.formatToInrWithDecimals(showDecimalOnlyForFractional = true), + ), + CheckBalanceCategoryItemData( + name = shoppingCategory.categoryName.orEmpty(), + iconUrl = IllustrationSource.Remote(shoppingCategory.categoryIcon.orEmpty()), + progress = 0.3f, + progressColor = categoriesProgressBarColors[1], + amount = 2000.0.formatToInrWithDecimals(showDecimalOnlyForFractional = true), + ), + ) + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/provider/CheckBalanceScreenEntryPointDataProviderImpl.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/provider/CheckBalanceScreenEntryPointDataProviderImpl.kt new file mode 100644 index 0000000000..e0c6847b54 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/provider/CheckBalanceScreenEntryPointDataProviderImpl.kt @@ -0,0 +1,309 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.dataprovider.data.checkbalance.provider + +import com.navi.base.utils.BaseUtils.getDayMonthAndYearFromTimestamp +import com.navi.moneymanager.R +import com.navi.moneymanager.common.dataprovider.data.checkbalance.helper.CheckBalanceScreenEntryPointHelper +import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMConfigResponseHelper +import com.navi.moneymanager.common.dataprovider.data.dashboard.provider.IsCurrentMonthSynced +import com.navi.moneymanager.common.dataprovider.data.dashboard.provider.IsTotalSyncCompleted +import com.navi.moneymanager.common.dataprovider.data.datastore.DataStoreInfoProvider +import com.navi.moneymanager.common.dataprovider.domain.CheckBalanceScreenEntryPointDataProvider +import com.navi.moneymanager.common.dataprovider.utils.MMQueryExecutor.Companion.executeQueryFlow +import com.navi.moneymanager.common.dataprovider.utils.isSameAccount +import com.navi.moneymanager.common.datasync.model.DataSyncState +import com.navi.moneymanager.common.db.database.MMDatabase +import com.navi.moneymanager.common.model.CategorySummary +import com.navi.moneymanager.common.model.database.AccountOverview +import com.navi.moneymanager.common.network.di.RoomDataStoreInfoProvider +import com.navi.moneymanager.common.network.model.MMConfigResponse +import com.navi.moneymanager.common.utils.Constants.IS_FIRST_MONTH_SYNC_COMPLETED +import com.navi.moneymanager.common.utils.Constants.IS_TOTAL_SYNC_COMPLETED +import com.navi.moneymanager.common.utils.MonthConstants +import com.navi.moneymanager.common.utils.combineWithFlatMapLatest +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData +import dagger.Lazy +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged + +class CheckBalanceScreenEntryPointDataProviderImpl +@Inject +constructor( + private val database: Lazy, + private val mmConfigResponseHelper: MMConfigResponseHelper, + private val entryPointHelper: CheckBalanceScreenEntryPointHelper, + @RoomDataStoreInfoProvider private val dbDataStoreProvider: DataStoreInfoProvider, +) : CheckBalanceScreenEntryPointDataProvider { + override suspend fun getCheckBalanceMMEntryPointData( + npciBankCode: String, + maskedAccountNumber: String, + syncStatus: Flow, + isUserOnboardedForMM: Boolean, + ): Flow { + return combineWithFlatMapLatest( + flow1 = syncStatusFlow(), + flow2 = syncStatus, + flow3 = + getLinkedAccounts( + mmConfigResponse = mmConfigResponseHelper.getMMConfig(), + npciBankCode = npciBankCode, + ), + combineTransform = { + (isCurrentMonthSynced, isTotalDataSynced), + syncState, + linkedAccounts -> + SyncStatusBundle(isCurrentMonthSynced, isTotalDataSynced, syncState, linkedAccounts) + }, + flatMapLatestTransform = { + (isFirstMonthSynced, isTotalSyncCompleted, syncState, linkedAccounts) -> + val mmConfig = mmConfigResponseHelper.getMMConfig() + + // Extract the bank code to FIP ID map from the configuration + val bankCodeToFipMap = mmConfig?.bankCodeToFipIdMap + + // Get the FIP ID corresponding to the NPCI bank code + val fipId = bankCodeToFipMap?.get(npciBankCode) + + val dateComponents = getDayMonthAndYearFromTimestamp(System.currentTimeMillis()) + + // If the user hasn't onboarded to the MM journey with any account (i.e., new to + // MM), show the locked state and exit. + + if (isUserOnboardedForMM.not()) { + val checkBalanceScreenData = + CheckBalanceMMEntryPointData( + titleResId = R.string.your_spend_analysis_for, + titleSuffix = MonthConstants.monthNames[dateComponents.second], + mmEntryPointState = + entryPointHelper.getLockedState(mmConfig = mmConfig), + buttonData = + entryPointHelper.getFooterButtonData( + maskedAccountNumber = maskedAccountNumber, + bankFipId = fipId, + ), + ) + emit(checkBalanceScreenData) + return@combineWithFlatMapLatest + } + + // Fetch the linked account matching the masked account number for the given FIP + // ID + val matchedLinkedAccount = + getMatchingLinkedAccount( + linkedAccounts = linkedAccounts, + maskedAccountNumber = maskedAccountNumber, + ) + + // If the user has completed MM onboarding with a different account, + // but the current account (for check balance) is not onboarded – show locked + // state and exit early + + if (matchedLinkedAccount == null && syncState is DataSyncState.Completed) { + val checkBalanceScreenData = + CheckBalanceMMEntryPointData( + titleResId = R.string.your_spend_analysis_for, + titleSuffix = MonthConstants.monthNames[dateComponents.second], + mmEntryPointState = + entryPointHelper.getLockedState(mmConfig = mmConfig), + buttonData = + entryPointHelper.getFooterButtonData( + maskedAccountNumber = maskedAccountNumber, + bankFipId = fipId, + ), + ) + emit(checkBalanceScreenData) + return@combineWithFlatMapLatest + } + + if ( + matchedLinkedAccount?.currentBalance != null && + isFirstMonthSynced && + isTotalSyncCompleted && + syncState is DataSyncState.Completed + ) { + + fetchCategorizedTransactionsSummary( + month = dateComponents.second, + year = dateComponents.third, + linkedAccountRef = matchedLinkedAccount.linkedAccRef, + ) + .collect { currentMonthTransactionsList -> + val currentMonthSpendCategories = + entryPointHelper.getTopTwoCategoriesData( + currentMonthTransactionsList, + mmConfig, + ) + if (currentMonthSpendCategories != null) { + + if (currentMonthTransactionsList.isEmpty()) { + // If there are no transactions in the current month + var previousMonth = dateComponents.second - 1 + val previousYear = + if (previousMonth == -1) { + dateComponents.third - 1 + } else { + dateComponents.third + } + if (previousMonth == -1) { + previousMonth = 11 + } + fetchCategorizedTransactionsSummary( + month = previousMonth, + year = previousYear, + linkedAccountRef = matchedLinkedAccount.linkedAccRef, + ) + .collect { previousMonthTransactionsList -> + val state = + if (previousMonthTransactionsList.isNotEmpty()) { + val previousMonthSpendCategories = + entryPointHelper.getTopTwoCategoriesData( + previousMonthTransactionsList, + mmConfig, + ) + if (previousMonthSpendCategories != null) { + entryPointHelper.getLoadedState( + previousMonthSpendCategories + ) + } else { + entryPointHelper.getEmptyState(mmConfig) + } + } else { + entryPointHelper.getEmptyState(mmConfig) + } + emit( + CheckBalanceMMEntryPointData( + titleResId = R.string.your_spend_analysis_for, + titleSuffix = + MonthConstants.monthNames[previousMonth], + mmEntryPointState = state, + buttonData = + entryPointHelper.getFooterButtonData( + maskedAccountNumber = + maskedAccountNumber, + selectedAccount = matchedLinkedAccount, + ), + ) + ) + } + } else { + // Show loaded state for current month data + emit( + CheckBalanceMMEntryPointData( + titleResId = R.string.your_spend_analysis_for, + titleSuffix = + MonthConstants.monthNames[dateComponents.second], + mmEntryPointState = + entryPointHelper.getLoadedState( + currentMonthSpendCategories + ), + buttonData = + entryPointHelper.getFooterButtonData( + maskedAccountNumber = maskedAccountNumber, + selectedAccount = matchedLinkedAccount, + ), + ) + ) + } + } + } + } else { + emit( + CheckBalanceMMEntryPointData( + titleResId = R.string.your_spend_analysis_for, + titleSuffix = MonthConstants.monthNames[dateComponents.second], + mmEntryPointState = entryPointHelper.getLoadingState(mmConfig), + buttonData = entryPointHelper.getFooterButtonData(), + ) + ) + } + }, + ) + } + + private fun getLinkedAccounts( + mmConfigResponse: MMConfigResponse?, + npciBankCode: String, + ): Flow> { + // Extract the bank code to FIP ID map from the configuration + val bankCodeToFipMap = mmConfigResponse?.bankCodeToFipIdMap + // Get the FIP ID corresponding to the NPCI bank code + val fipId = bankCodeToFipMap?.get(npciBankCode) + return database + .get() + .accountsDao() + .fetchAccountsFromFipId(fipId.orEmpty()) + .distinctUntilChanged() + } + + private fun getMatchingLinkedAccount( + linkedAccounts: List, + maskedAccountNumber: String, + ): AccountOverview? { + // Find the account that matches the given masked account number + return linkedAccounts.firstOrNull { + isSameAccount(it?.maskedAccNumber.orEmpty(), maskedAccountNumber) + } + } + + /** + * Fetches categorized transaction summaries for the given account reference for the specified + * month and year. + * + * @param month The target month (0-based, i.e., January = 0). + * @param year The target year. + * @param linkedAccountRef The reference ID of the linked account. + * @return A list of categorized transaction summaries. + */ + private suspend fun fetchCategorizedTransactionsSummary( + month: Int, + year: Int, + linkedAccountRef: String, + ): Flow> { + return executeQueryFlow( + queryName = + database.get().transactionsDao():: + getCategorizedTransactionSummaryForSelectedBanks + .name, + methodName = ::getCheckBalanceMMEntryPointData.name, + flow = + database + .get() + .transactionsDao() + .getCategorizedTransactionSummaryForSelectedBanks( + month, + year, + setOf(linkedAccountRef), + ), + ) + .distinctUntilChanged() + } + + private suspend fun isFirstMonthSyncedFlow() = + dbDataStoreProvider.getBooleanData(IS_FIRST_MONTH_SYNC_COMPLETED).distinctUntilChanged() + + private suspend fun isTotalSyncCompletedFlow() = + dbDataStoreProvider.getBooleanData(IS_TOTAL_SYNC_COMPLETED).distinctUntilChanged() + + private suspend fun syncStatusFlow(): Flow> { + return combine(isFirstMonthSyncedFlow(), isTotalSyncCompletedFlow()) { + isFirstMonthSyncCompleted, + isTotalSyncCompleted -> + Pair(isFirstMonthSyncCompleted, isTotalSyncCompleted) + } + } +} + +data class SyncStatusBundle( + val isFirstMonthSynced: Boolean, + val isTotalSyncCompleted: Boolean, + val syncState: DataSyncState, + val linkedAccounts: List, +) diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelper.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelper.kt index b94702b36d..fbe578996f 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelper.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelper.kt @@ -135,6 +135,8 @@ constructor( bankSyncFailedTitle = context.resources.getString(R.string.bank_sync_failed_title), bankSyncFailedDescription = context.resources.getString(R.string.bank_sync_failed_description), + bankFipId = account.fipId, + maskedAccountNumber = account.maskedAccNumber, ) } } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/remote/RemoteDataProviderImpl.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/remote/RemoteDataProviderImpl.kt index 6bf219b07f..0d742e37ce 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/remote/RemoteDataProviderImpl.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/remote/RemoteDataProviderImpl.kt @@ -72,9 +72,12 @@ class RemoteDataProviderImpl @Inject constructor(private val retrofitService: Re metricInfo = MetricInfo.AppMetric(screen = MMScreen.LAUNCHER.screen, isNae = { false }), ) - override suspend fun fetchFinarkeinData(screenName: String): RepoResult { + override suspend fun fetchFinarkeinData( + screenName: String, + bankFipId: String?, + ): RepoResult { return apiResponseCallback( - response = retrofitService.fetchFinarkeinSDKInitData(), + response = retrofitService.fetchFinarkeinSDKInitData(fipId = bankFipId), metricInfo = MetricInfo.AppMetric(screen = screenName, isNae = { !it.isSuccessWithData() }), ) diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/di/DataProviderModule.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/di/DataProviderModule.kt index f65010b63f..836397255c 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/di/DataProviderModule.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/di/DataProviderModule.kt @@ -14,6 +14,7 @@ import com.navi.moneymanager.billcalendar.domain.provider.BillCalendarDataProvid import com.navi.moneymanager.common.dataprovider.data.addcategory.provider.AddCategoryDataProviderImpl import com.navi.moneymanager.common.dataprovider.data.addcategory.provider.UpiSpendAddCategoryDataProviderImpl import com.navi.moneymanager.common.dataprovider.data.bankaccounts.BankAccountsDataProviderImpl +import com.navi.moneymanager.common.dataprovider.data.checkbalance.provider.CheckBalanceScreenEntryPointDataProviderImpl import com.navi.moneymanager.common.dataprovider.data.dashboard.provider.DashboardDataProviderImpl import com.navi.moneymanager.common.dataprovider.data.dashboard.provider.UpiSpendDashboardDataProviderImpl import com.navi.moneymanager.common.dataprovider.data.remote.LocalDataSyncManagerImpl @@ -31,6 +32,7 @@ import com.navi.moneymanager.common.dataprovider.data.valueprop.provider.Launche import com.navi.moneymanager.common.dataprovider.domain.AddCategoryDataProvider import com.navi.moneymanager.common.dataprovider.domain.BankAccountsDataProvider import com.navi.moneymanager.common.dataprovider.domain.CategoryDetailsLocalDataProvider +import com.navi.moneymanager.common.dataprovider.domain.CheckBalanceScreenEntryPointDataProvider import com.navi.moneymanager.common.dataprovider.domain.DashboardDataProvider import com.navi.moneymanager.common.dataprovider.domain.LauncherDataProvider import com.navi.moneymanager.common.dataprovider.domain.LocalDataSyncManager @@ -140,6 +142,11 @@ abstract class DataProviderModule { abstract fun bindAddNewBillDataProvider( addNewBillDataProviderImpl: AddBillDataProviderImpl ): AddBillDataProvider + + @Binds + abstract fun bindCheckBalanceScreenEntryPointDataProvider( + checkBalanceScreenEntryPointDataProvider: CheckBalanceScreenEntryPointDataProviderImpl + ): CheckBalanceScreenEntryPointDataProvider } @Module diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/CheckBalanceScreenEntryPointDataProvider.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/CheckBalanceScreenEntryPointDataProvider.kt new file mode 100644 index 0000000000..3f533c8560 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/CheckBalanceScreenEntryPointDataProvider.kt @@ -0,0 +1,21 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.dataprovider.domain + +import com.navi.moneymanager.common.datasync.model.DataSyncState +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData +import kotlinx.coroutines.flow.Flow + +interface CheckBalanceScreenEntryPointDataProvider : LocalDataProvider { + suspend fun getCheckBalanceMMEntryPointData( + npciBankCode: String, + maskedAccountNumber: String, + syncStatus: Flow, + isUserOnboardedForMM: Boolean, + ): Flow +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/RemoteDataProvider.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/RemoteDataProvider.kt index da791be134..c411e03612 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/RemoteDataProvider.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/RemoteDataProvider.kt @@ -42,7 +42,10 @@ interface RemoteDataProvider { suspend fun fetchAlchemistScreen(screenName: String): RepoResult - suspend fun fetchFinarkeinData(screenName: String): RepoResult + suspend fun fetchFinarkeinData( + screenName: String, + bankFipId: String?, + ): RepoResult suspend fun fetchMMConfigResponse(screenName: String): RepoResult diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/utils/DataProviderUtils.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/utils/DataProviderUtils.kt index 2f51c21f01..fc1697e6cd 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/utils/DataProviderUtils.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/utils/DataProviderUtils.kt @@ -106,3 +106,19 @@ fun getAccountHolderPaymentNarrative(transactionType: String?, context: Context) } } } + +fun isSameAccount(mmAccountNumber: String, npciAccountNumber: String): Boolean { + // Ensure the account numbers are long enough to check + if (mmAccountNumber.length < 4 || npciAccountNumber.length < 2) return false + + val last4MMAccountNumber = mmAccountNumber.takeLast(4) + + val visibleLength = npciAccountNumber.takeLastWhile { it.isDigit() }.length + val visibleNpciAccountNumber = npciAccountNumber.takeLast(visibleLength) + + return when (visibleLength) { + 2 -> last4MMAccountNumber.takeLast(2) == visibleNpciAccountNumber + 4 -> last4MMAccountNumber == visibleNpciAccountNumber + else -> false // If npciAccountNumber has neither 2 nor 4 visible digits + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/DBSyncExecutor.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/DBSyncExecutor.kt index 9922cc3ccc..9f7fb2d72c 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/DBSyncExecutor.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/DBSyncExecutor.kt @@ -39,7 +39,9 @@ constructor( private val mmConfigResponseHelper: MMConfigResponseHelper, ) { private var currentMonthSyncStatus = MutableStateFlow(DataSyncState.NotStarted) - private var allMonthsSyncStatus = MutableStateFlow(DataSyncState.NotStarted) + val currentMonthSyncState: StateFlow = currentMonthSyncStatus + var allMonthsSyncStatus = MutableStateFlow(DataSyncState.NotStarted) + val allMonthsSyncState: StateFlow = allMonthsSyncStatus private lateinit var scope: CoroutineScope lateinit var dataSyncStatus: StateFlow @@ -128,7 +130,7 @@ constructor( private fun shouldSyncAllMonths(): Boolean = allMonthsSyncStatus.value.shouldInitiateSync() } -private fun DataSyncState.shouldInitiateSync() = +fun DataSyncState.shouldInitiateSync() = this in listOf(DataSyncState.NotStarted, DataSyncState.Failed) enum class SyncStatus { diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/MMDiscoverabilitySyncPreviousDataUseCase.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/MMDiscoverabilitySyncPreviousDataUseCase.kt new file mode 100644 index 0000000000..5c79acd37d --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/MMDiscoverabilitySyncPreviousDataUseCase.kt @@ -0,0 +1,75 @@ +/* + * + * * Copyright © 2024-2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.datasync + +import com.navi.base.utils.orZero +import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMConfigResponseHelper +import com.navi.moneymanager.common.dataprovider.domain.RemoteDataProvider +import com.navi.moneymanager.common.dataprovider.utils.MMQueryExecutor.Companion.executeQuery +import com.navi.moneymanager.common.datasync.model.DBSyncConfig +import com.navi.moneymanager.common.datasync.model.DataSyncState +import com.navi.moneymanager.common.db.database.MMDatabase +import com.navi.moneymanager.common.network.model.MMConfigResponse +import com.navi.moneymanager.common.network.model.PollingStatusResponse +import dagger.Lazy +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +@ViewModelScoped +class MMDiscoverabilitySyncPreviousDataUseCase +@Inject +constructor( + private val dBSyncExecutor: DBSyncExecutor, + private val remoteDataProvider: RemoteDataProvider, + private val database: Lazy, + private val mmConfigResponseHelper: MMConfigResponseHelper, +) { + suspend fun execute(scope: CoroutineScope) { + val configResponse = mmConfigResponseHelper.getMMConfig() + val pollingStatusData = + PollingStatusResponse( + accountDetailsStatus = SyncStatus.COMPLETED.name, + currMonthTxnsStatus = SyncStatus.COMPLETED.name, + oldMonthTxnsStatus = SyncStatus.COMPLETED.name, + ) + val dbSyncConfig = + DBSyncConfig( + pollingStatusResponse = pollingStatusData, + timestampConfig = configResponse?.timestampConfig, + paginationConfig = configResponse?.paginationConfig, + ) + dBSyncExecutor.initDBSyncExecutor(scope) + dBSyncExecutor.execute(dbSyncConfig) + } + + fun syncCompleteFlow(): Flow { + return dBSyncExecutor.allMonthsSyncState + } + + suspend fun fetchAndSaveConfigResponse(screenName: String): MMConfigResponse? { + val networkResponse = remoteDataProvider.fetchMMConfigResponse(screenName) + return networkResponse.data?.let { + executeQuery( + queryName = database.get().transactionsDao()::deleteTransactionsOlderThan.name, + methodName = ::fetchAndSaveConfigResponse.name, + query = { + database + .get() + .transactionsDao() + .deleteTransactionsOlderThan( + it.timestampConfig?.twelveMonthsOldTimestamp.orZero() + ) + }, + ) + mmConfigResponseHelper.saveMMConfigToDB(it) + mmConfigResponseHelper.getMMConfig() + } + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/dao/AccountsDao.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/dao/AccountsDao.kt index 8d2ad89871..ad9b231bc8 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/dao/AccountsDao.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/dao/AccountsDao.kt @@ -22,14 +22,21 @@ interface AccountsDao { @Upsert suspend fun insertAllAccounts(accounts: List) @Query( - "SELECT linkedAccRef, accountHolderName, maskedAccNumber, bankName, bankCode, bankIconUrl, currentBalance, updatedAt " + + "SELECT linkedAccRef, accountHolderName, maskedAccNumber, bankName, bankCode, bankIconUrl, currentBalance, updatedAt, fipId " + "FROM $ACCOUNT_TABLE " + "WHERE linkedAccRef = :linkedAccountReference" ) fun fetchAccount(linkedAccountReference: String): Flow @Query( - "SELECT linkedAccRef, accountHolderName, maskedAccNumber, bankName, bankCode, bankIconUrl, currentBalance, updatedAt " + + "SELECT linkedAccRef, accountHolderName, maskedAccNumber, bankName, bankCode, bankIconUrl, currentBalance, updatedAt, fipId " + + "FROM $ACCOUNT_TABLE " + + "WHERE fipId = :fipId" + ) + fun fetchAccountsFromFipId(fipId: String): Flow> + + @Query( + "SELECT linkedAccRef, accountHolderName, maskedAccNumber, bankName, bankCode, bankIconUrl, currentBalance, updatedAt, fipId " + "FROM $ACCOUNT_TABLE " + "ORDER BY bankName, maskedAccNumber ASC" ) diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/entity/AccountEntity.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/entity/AccountEntity.kt index 9b187094d6..83e66208eb 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/entity/AccountEntity.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/entity/AccountEntity.kt @@ -1,6 +1,6 @@ /* * - * * Copyright © 2024 by Navi Technologies Limited + * * Copyright © 2024-2025 by Navi Technologies Limited * * All rights reserved. Strictly confidential * */ diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/helper/CheckBalanceMMWidgetHelper.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/helper/CheckBalanceMMWidgetHelper.kt new file mode 100644 index 0000000000..8119a8fafc --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/helper/CheckBalanceMMWidgetHelper.kt @@ -0,0 +1,59 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.helper + +import com.navi.base.utils.orFalse +import com.navi.common.usecase.LitmusExperimentsUseCase +import com.navi.common.utils.Constants.LITMUS_EXPERIMENT_CHECK_BALANCE_MM_EXPERIENCE +import com.navi.common.utils.Constants.LITMUS_EXPERIMENT_HPC_MM_ROLLOUT +import com.navi.moneymanager.common.dataprovider.domain.CheckBalanceScreenEntryPointDataProvider +import com.navi.moneymanager.common.datasync.model.DataSyncState +import com.navi.moneymanager.common.network.model.MMConfigResponse +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class CheckBalanceMMWidgetHelper +@Inject +constructor( + private val litmusExperimentsUseCase: LitmusExperimentsUseCase, + private val checkBalanceDataProvider: CheckBalanceScreenEntryPointDataProvider, +) { + suspend fun getCheckBalanceScreenEntryPointData( + npciBankCode: String, + maskedAccountNumber: String, + syncStatus: Flow, + isUserOnboardedForMM: Boolean, + ): Flow { + return checkBalanceDataProvider.getCheckBalanceMMEntryPointData( + npciBankCode = npciBankCode, + maskedAccountNumber = maskedAccountNumber, + syncStatus = syncStatus, + isUserOnboardedForMM = isUserOnboardedForMM, + ) + } + + suspend fun isMMDiscoverabilityExperimentEnabled(): Boolean { + val experimentInfo = + litmusExperimentsUseCase.execute( + experimentName = LITMUS_EXPERIMENT_CHECK_BALANCE_MM_EXPERIENCE + ) + return experimentInfo?.variant?.enabled.orFalse() + } + + suspend fun isMoneyManagerExperimentEnabled(): Boolean { + val experimentInfo = + litmusExperimentsUseCase.execute(experimentName = LITMUS_EXPERIMENT_HPC_MM_ROLLOUT) + return experimentInfo?.variant?.enabled.orFalse() + } + + fun isBankEligible(bankCode: String, mmConfig: MMConfigResponse?): Boolean { + val bankCodeToFipMap = mmConfig?.bankCodeToFipIdMap + return bankCodeToFipMap.orEmpty().containsKey(bankCode) + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/illustration/repository/ImageRepository.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/illustration/repository/ImageRepository.kt index fad9754a40..44924ac5c2 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/illustration/repository/ImageRepository.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/illustration/repository/ImageRepository.kt @@ -75,6 +75,7 @@ class ImageRepository : IllustrationRepository { const val ALL_BILLS_PAID_ICON = "ALL_BILLS_PAID_ICON" const val NO_BILLS_PAID_ICON = "NO_BILLS_PAID_ICON" const val SPIKE_ICON_URL = "SPIKE_ICON_URL" + const val CHECK_BALANCE_LOCK_ICON = "CHECK_BALANCE_LOCK_ICON" } override fun getResourceId(illustrationName: String, isDarkThemeEnabled: Boolean): Int { @@ -268,6 +269,9 @@ class ImageRepository : IllustrationRepository { CHIP_FAILURE_IMAGE -> "https://public-assets.prod.navi-sa.in/money-manager/svg/ic_chip_failure_info_icon.svg" + + CHECK_BALANCE_LOCK_ICON -> + "https://public-assets.prod.navi-sa.in/money-manager/svg/check-balance/ic_white_lock_with_grey_circular_bg.svg" else -> EMPTY } } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/database/AccountOverview.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/database/AccountOverview.kt index 89be208c54..a959113b33 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/database/AccountOverview.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/database/AccountOverview.kt @@ -1,12 +1,16 @@ /* * - * * Copyright © 2024 by Navi Technologies Limited + * * Copyright © 2024-2025 by Navi Technologies Limited * * All rights reserved. Strictly confidential * */ package com.navi.moneymanager.common.model.database +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class AccountOverview( val linkedAccRef: String, val accountHolderName: String?, @@ -16,4 +20,5 @@ data class AccountOverview( val bankIconUrl: String?, val currentBalance: Double?, val updatedAt: Long?, -) + val fipId: String?, +) : Parcelable diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/AccountData.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/AccountData.kt index 3db8ddfbc3..c0c869a030 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/AccountData.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/AccountData.kt @@ -1,6 +1,6 @@ /* * - * * Copyright © 2024 by Navi Technologies Limited + * * Copyright © 2024-2025 by Navi Technologies Limited * * All rights reserved. Strictly confidential * */ diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/ConfigResponse.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/ConfigResponse.kt index 9203f87273..cfbb43e1be 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/ConfigResponse.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/ConfigResponse.kt @@ -21,6 +21,7 @@ data class MMConfigResponse( val billCalendarMonthRange: CalendarMonthRange? = null, val billCalendarFABTitle: String? = null, val darkThemeEnabled: LitmusConfig? = null, + val bankCodeToFipIdMap: Map? = null, ) { data class CalendarMonthRange(val startOffset: Int? = null, val endOffset: Int? = null) diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/EmptyRequestBody.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/EmptyRequestBody.kt index eeae63df3e..0c4419a88e 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/EmptyRequestBody.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/EmptyRequestBody.kt @@ -1,6 +1,6 @@ /* * - * * Copyright © 2024 by Navi Technologies Limited + * * Copyright © 2024-2025 by Navi Technologies Limited * * All rights reserved. Strictly confidential * */ diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/service/RetrofitService.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/service/RetrofitService.kt index a00e8bad64..1b582a4df2 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/service/RetrofitService.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/service/RetrofitService.kt @@ -68,7 +68,8 @@ interface RetrofitService { @RetryPolicy @POST("/money-manager/core/init-onboarding") suspend fun fetchFinarkeinSDKInitData( - @Body emptyBody: EmptyRequestBody = EmptyRequestBody() + @Body requestBody: EmptyRequestBody = EmptyRequestBody(), + @Query("fipId") fipId: String?, ): Response> @RetryPolicy diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/checkbalance/CheckBalanceMMEntryPointWidget.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/checkbalance/CheckBalanceMMEntryPointWidget.kt new file mode 100644 index 0000000000..d6a0062bb6 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/checkbalance/CheckBalanceMMEntryPointWidget.kt @@ -0,0 +1,309 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.ui.composable.checkbalance + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +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 com.navi.base.model.CtaData +import com.navi.common.utils.onClickWithDebounce +import com.navi.design.font.FontWeightEnum +import com.navi.moneymanager.common.analytics.MMDiscoverabilityEventTrackerImpl +import com.navi.moneymanager.common.illustration.model.IllustrationType +import com.navi.moneymanager.common.illustration.ui.Illustration +import com.navi.moneymanager.common.ui.composable.MMCategoryProgressBar +import com.navi.moneymanager.common.ui.composable.base.MMDivider +import com.navi.moneymanager.common.ui.composable.base.MMText +import com.navi.moneymanager.common.utils.fourDpRoundedShape +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceCategoryItemData +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointState + +@Composable +fun CheckBalanceMMEntryPointWidget( + data: CheckBalanceMMEntryPointData, + onFooterClick: (ctaData: CtaData) -> Unit, + getMMDiscoverabilityEventTracker: () -> MMDiscoverabilityEventTrackerImpl, +) { + when (val state = data.mmEntryPointState) { + is CheckBalanceMMEntryPointState.NoData -> { + getMMDiscoverabilityEventTracker().onMMDiscoverabilityWidgetNoDataStateTriggered() + } + + is CheckBalanceMMEntryPointState.Locked -> { + getMMDiscoverabilityEventTracker() + .onMMDiscoverabilityWidgetVisible(widgetState = "Locked") + CheckBalanceMMBlurredWidget( + title = stringResource(id = data.titleResId, data.titleSuffix.orEmpty()), + categories = state.spendCategories, + overlay = { + Illustration( + illustrationType = IllustrationType.Image(source = state.lockedIcon), + modifier = Modifier.size(32.dp), + ) + }, + footer = { SpendCategorySectionFooterUI(data, onFooterClick) }, + ) + } + + is CheckBalanceMMEntryPointState.Loading -> { + getMMDiscoverabilityEventTracker() + .onMMDiscoverabilityWidgetVisible(widgetState = "Loading") + CheckBalanceMMBlurredWidget( + title = stringResource(id = data.titleResId, data.titleSuffix.orEmpty()), + categories = state.spendCategories, + overlay = { + Illustration( + illustrationType = IllustrationType.Lottie(source = state.loadingLottie), + modifier = Modifier.size(50.dp), + ) + }, + footer = { SpendCategorySectionFooterUI(data, onFooterClick) }, + ) + } + + is CheckBalanceMMEntryPointState.Loaded, + is CheckBalanceMMEntryPointState.Empty -> { + val categories = + when (state) { + is CheckBalanceMMEntryPointState.Loaded -> { + getMMDiscoverabilityEventTracker() + .onMMDiscoverabilityWidgetVisible(widgetState = "Loaded") + state.spendCategories + } + is CheckBalanceMMEntryPointState.Empty -> { + getMMDiscoverabilityEventTracker() + .onMMDiscoverabilityWidgetVisible(widgetState = "Empty") + state.spendCategories + } + else -> emptyList() + } + + CheckBalanceMMPlainWidget( + title = stringResource(id = data.titleResId, data.titleSuffix.orEmpty()), + categories = categories, + footer = { SpendCategorySectionFooterUI(data, onFooterClick) }, + ) + } + } +} + +@Composable +private fun CheckBalanceMMBlurredWidget( + title: String?, + categories: List, + overlay: @Composable () -> Unit, + footer: @Composable () -> Unit, +) { + Column { + SpendCategoryTitleUI(title) + Spacer(modifier = Modifier.height(16.dp)) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.wrapContentHeight().fillMaxWidth(), + ) { + SpendCategorySectionUI( + categories, + modifier = Modifier.wrapContentSize().blur(2.dp, BlurredEdgeTreatment.Unbounded), + ) + overlay() + } + Spacer(modifier = Modifier.height(24.dp)) + footer() + } +} + +@Composable +private fun CheckBalanceMMPlainWidget( + title: String?, + categories: List, + footer: @Composable () -> Unit, +) { + Column { + SpendCategoryTitleUI(title) + Spacer(modifier = Modifier.height(16.dp)) + SpendCategorySectionUI(categories) + Spacer(modifier = Modifier.height(24.dp)) + footer() + } +} + +@Composable +private fun SpendCategoryTitleUI(title: String?) { + MMText( + text = title.orEmpty(), + color = Color(0xFF6B6B6B), + fontSize = 12.sp, + fontWeight = FontWeightEnum.NAVI_BODY_REGULAR, + textAlign = TextAlign.Center, + lineHeight = 16.sp, + ) +} + +@Composable +private fun SpendCategorySectionUI( + categories: List, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + categories.forEachIndexed { index, item -> + if (index == 1) { + MMDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = Color(0xFFF5F5F5), + ) + } + TopCategoryItem(data = item) + } + } +} + +@Composable +private fun TopCategoryItem(data: CheckBalanceCategoryItemData) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.size(40.dp) + .background(color = Color(0xFFF5F5F5), shape = CircleShape) + .clip(CircleShape), + ) { + Illustration( + illustrationType = IllustrationType.Image(source = data.iconUrl), + modifier = Modifier.size(28.dp), + ) + } + + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + MMText( + modifier = Modifier.weight(1f), + text = data.name, + color = Color(0xFF191919), + fontSize = 14.sp, + fontWeight = FontWeightEnum.NAVI_BODY_REGULAR, + lineHeight = 22.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = + TextStyle( + lineHeightStyle = + LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ) + ), + ) + + Spacer(Modifier.width(8.dp)) + + MMText( + text = data.amount, + color = Color(0xFF191919), + fontSize = 14.sp, + fontWeight = FontWeightEnum.NAVI_BODY_DEMI_BOLD, + lineHeight = 22.sp, + style = + TextStyle( + lineHeightStyle = + LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ) + ), + ) + } + + Spacer(Modifier.height(4.dp)) + + data.progress?.let { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + MMCategoryProgressBar( + progress = it, + color = data.progressColor, + totalWidth = 120.dp, + ) + } + Spacer(Modifier.width(8.dp)) + } + } + } + } +} + +@Composable +private fun SpendCategorySectionFooterUI( + data: CheckBalanceMMEntryPointData, + onFooterClick: (ctaData: CtaData) -> Unit, +) { + Box { + Row( + horizontalArrangement = Arrangement.Center, + modifier = + Modifier.fillMaxWidth() + .background(color = Color(0xFFF5F5F5), shape = fourDpRoundedShape) + .clip(RoundedCornerShape(size = 4.dp)) + .onClickWithDebounce { data.buttonData?.ctaData?.let(onFooterClick) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + data.buttonData?.textResId?.let { + MMText( + text = stringResource(data.buttonData.textResId), + color = Color(0xFF1F002A), + fontSize = 14.sp, + fontWeight = FontWeightEnum.NAVI_BODY_DEMI_BOLD, + textAlign = TextAlign.Center, + lineHeight = 22.sp, + style = + TextStyle( + lineHeightStyle = + LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ) + ), + ) + } + } + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Constants.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Constants.kt index 150a34fee3..c459f0d380 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Constants.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Constants.kt @@ -41,9 +41,11 @@ object Constants { const val CREDIT = "CREDIT" const val DEBIT = "DEBIT" const val UNCATEGORIZED = "UNCATEGORIZED" + const val OTHERS = "Others" const val PLUS_SPACE = "+ " const val RECENT_TRANSACTION_COUNT = 3 const val IS_CONSENT_REVOKED = "IS_CONSENT_REVOKED" + const val BANK_FIP_ID = "BANK_FIP_ID" const val UNKNOWN = "Unknown" const val TXN_TIMESTAMP = "TXN_TIMESTAMP" const val UPDATED_AT = "UPDATED_AT" @@ -83,10 +85,27 @@ object Constants { const val CONTROL = "Control" const val JOURNEY_SOURCE = "journey_source" const val MM_ACTIVITY_NEW_INTENT_ENABLED = "MM_ACTIVITY_NEW_INTENT_ENABLED" + const val MASKED_ACCOUNT_NUMBER = "maskedAccountNumber" + const val CHECK_BALANCE_LINKED_ACCOUNT_REFERENCE = "CHECK_BALANCE_LINKED_ACCOUNT_REFERENCE" + const val MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW = + "MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW" + const val MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW_NAME = + "MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW_NAME" const val ACCOUNT_SYNC_STATUS = "ACCOUNT_SYNC_STATUS" const val SPEND_ANALYSER = "SPEND_ANALYSER" const val MONEY_MANAGER = "MONEY_MANAGER" + + const val UTILITIES_CATEGORY_ICON = + "https://public-assets.prod.navi-sa.in/money-manager/svg/category/v2/ic_utilities_logo.svg" + + const val SHOPPING_CATEGORY_ICON = + "https://public-assets.prod.navi-sa.in/money-manager/svg/category/v2/ic_shopping_logo.svg" + + const val CATEGORY_BILLSANDUTILITY = "BILLSANDUTILITY" + const val CATEGORY_SHOPPING = "SHOPPING" + const val BILLS = "Bills" + const val SHOPPING = "Shopping" } object DbCacheConstants { diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/entry/model/checkbalance/CheckBalanceMMEntryPointData.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/entry/model/checkbalance/CheckBalanceMMEntryPointData.kt new file mode 100644 index 0000000000..243c2d0a1c --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/entry/model/checkbalance/CheckBalanceMMEntryPointData.kt @@ -0,0 +1,50 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.entry.model.checkbalance + +import androidx.compose.ui.graphics.Color +import com.navi.base.model.CtaData +import com.navi.moneymanager.common.illustration.model.IllustrationSource + +data class CheckBalanceMMEntryPointData( + val titleResId: Int, + val titleSuffix: String? = null, + val mmEntryPointState: CheckBalanceMMEntryPointState, + val buttonData: CheckBalanceMMButtonData? = null, +) + +data class CheckBalanceMMButtonData(val textResId: Int, val ctaData: CtaData) + +sealed interface CheckBalanceMMEntryPointState { + + data object NoData : CheckBalanceMMEntryPointState + + data class Locked( + val spendCategories: List, + val lockedIcon: IllustrationSource, + ) : CheckBalanceMMEntryPointState + + data class Loading( + val spendCategories: List, + val loadingLottie: IllustrationSource, + ) : CheckBalanceMMEntryPointState + + data class Loaded(val spendCategories: List) : + CheckBalanceMMEntryPointState + + data class Empty(val spendCategories: List) : + CheckBalanceMMEntryPointState +} + +data class CheckBalanceCategoryItemData( + val name: String, + val iconUrl: IllustrationSource, + val progress: Float? = null, + val progressColor: Color, + val amount: String, +) diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardData.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardData.kt index 0867e18209..0db73b621d 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardData.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardData.kt @@ -113,6 +113,8 @@ data class BankAccount( val lastUpdatedSuffixText: String, val bankSyncFailedTitle: String, val bankSyncFailedDescription: String, + val bankFipId: String? = null, + val maskedAccountNumber: String? = null, ) enum class BankAccountStatus { diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardScreenUiEffect.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardScreenUiEffect.kt index 7ff330ab09..4ce947e1ae 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardScreenUiEffect.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/model/DashboardScreenUiEffect.kt @@ -22,7 +22,7 @@ sealed class DashboardScreenUiEffect : UiEffect { data object OpenSettings : Navigation() - data object FetchFinarkeinData : Navigation() + data class FetchFinarkeinData(val bankFipId: String? = null) : Navigation() data object DismissFinarkeinBottomSheet : Navigation() diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/repo/DashboardScreenRepository.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/repo/DashboardScreenRepository.kt index 0a541acb66..2cb860ae01 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/repo/DashboardScreenRepository.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/repo/DashboardScreenRepository.kt @@ -79,6 +79,7 @@ constructor( }, ) } + MMJourneySource.NAVI_UPI.name -> { executeQuery( queryName = @@ -140,8 +141,11 @@ constructor( suspend fun updateBankSectionRefreshingText(refreshing: Boolean) = dashboardDataProvider.updateBankSectionRefreshingText(refreshing) - suspend fun fetchFinarkeinData() = - remoteDataProvider.fetchFinarkeinData(screenName = MMScreen.DASHBOARD.screen) + suspend fun fetchFinarkeinData(bankFipId: String?) = + remoteDataProvider.fetchFinarkeinData( + screenName = MMScreen.DASHBOARD.screen, + bankFipId = bankFipId, + ) suspend fun getSpendCategorizationSectionStateFlow( screenParams: MutableStateFlow diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardScreen.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardScreen.kt index de2324893d..460eca8184 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardScreen.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardScreen.kt @@ -10,12 +10,14 @@ package com.navi.moneymanager.postonboard.dashboard.ui import android.Manifest import android.os.Bundle import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -50,6 +52,8 @@ import com.navi.moneymanager.common.utils.Constants import com.navi.moneymanager.common.utils.Constants.GREEN_TICK_MARK import com.navi.moneymanager.common.utils.Constants.IS_CONSENT_REVOKED import com.navi.moneymanager.common.utils.Constants.IS_TOTAL_SYNC_COMPLETED +import com.navi.moneymanager.common.utils.Constants.MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW +import com.navi.moneymanager.common.utils.Constants.MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW_NAME import com.navi.moneymanager.common.utils.Constants.TRANSACTION_ID import com.navi.moneymanager.common.utils.MMScreenEventLogger import com.navi.moneymanager.common.utils.ShowCustomToast @@ -107,6 +111,7 @@ fun DashboardScreen( } val state by viewModel.state.collectAsStateWithLifecycle() val sharedVM = activity.getSharedVM() + val intentBundle = bundle ?: LocalActivity.current?.intent?.extras val showBottomSheet = remember { mutableStateOf(false) } var showTransactionUpdatedToast by remember { mutableStateOf(false) } @@ -170,8 +175,8 @@ fun DashboardScreen( activity.openAppSettings() } - DashboardScreenUiEffect.Navigation.FetchFinarkeinData -> { - viewModel.fetchFinarkeinData { + is DashboardScreenUiEffect.Navigation.FetchFinarkeinData -> { + viewModel.fetchFinarkeinData(effect.bankFipId) { when (it) { is FinarkeinDataState.Success -> { activity.launchFinarkeinSdk( @@ -309,6 +314,21 @@ fun DashboardScreen( BackHandler { viewModel.setEffect { DashboardScreenUiEffect.Navigation.Back } } + val flowName = remember { + activity.intent.extras?.getString(MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW) + } + val initFinarkeinForSelectedAccount = rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(flowName, state.screenData.bankSectionData) { + if (flowName == MM_DISCOVERABILITY_UPI_CHECK_BALANCE_FLOW_NAME) { + viewModel.handleMMCheckBalanceEntryFlow( + bundle = activity.intent.extras, + bankSectionData = state.screenData.bankSectionData, + initFinarkeinForSelectedAccount = initFinarkeinForSelectedAccount, + ) + } + } + DashboardScaffoldRenderer( modifier = Modifier, dashboardState = { state }, @@ -461,7 +481,7 @@ private fun DashboardBottomSheetContentHandler( onRetry = { onEvent(UpdateFinarkeinBottomSheetType(FinarkeinSheetStatus.Retry)) onEffect(DashboardScreenUiEffect.Navigation.DismissFinarkeinBottomSheet) - onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData) + onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData()) }, onDismiss = { onEffect(DashboardScreenUiEffect.Navigation.DismissFinarkeinBottomSheet) diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardSections.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardSections.kt index 0634bb2231..9146864e83 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardSections.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/DashboardSections.kt @@ -63,7 +63,7 @@ internal fun BankSection( AddAccountState.Selected.BankSection ) ) - onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData) + onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData()) } else { getViewModel().showAddAccountLoadingBottomSheet() } @@ -110,7 +110,7 @@ internal fun SpendAnalysisSection( AddAccountState.Selected.SpentCategorizationSection ) ) - onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData) + onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData()) } else { getViewModel().showAddAccountLoadingBottomSheet() } @@ -242,7 +242,7 @@ internal fun RecentTransaction( AddAccountState.Selected.RecentTransactionSection ) ) - onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData) + onEffect(DashboardScreenUiEffect.Navigation.FetchFinarkeinData()) } else { getViewModel().showAddAccountLoadingBottomSheet() } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/bankSection/BankChipCarousel.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/bankSection/BankChipCarousel.kt index 23f37ce8d9..b7c318a246 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/bankSection/BankChipCarousel.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/ui/bankSection/BankChipCarousel.kt @@ -77,17 +77,25 @@ fun BankChipCarousel( trackDashboardBankChipCarouselView?.invoke(pillCount) } } + val scrollState = rememberScrollState() Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + modifier = modifier.fillMaxWidth().horizontalScroll(scrollState), ) { // Spacer to add the start spacing Spacer(Modifier.width(8.dp)) when (bankAccountsState) { - is BankAccountsState.Loading -> LoadingBankChip(data = bankAccountsState.data) + is BankAccountsState.Loading -> { + LoadingBankChip(data = bankAccountsState.data) + } is BankAccountsState.Loaded -> { val data = bankAccountsState.data + LaunchedEffect(isAddAccountChipSelected) { + if (isAddAccountChipSelected) { + scrollState.animateScrollTo(scrollState.maxValue) + } + } // Displays the All bank chip if there is more than 1 banks if (data.aggregate != null) { AggregateBankChip( diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/viewmodel/DashboardViewModel.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/viewmodel/DashboardViewModel.kt index 795883a6f7..7135e31891 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/viewmodel/DashboardViewModel.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/postonboard/dashboard/viewmodel/DashboardViewModel.kt @@ -7,10 +7,16 @@ package com.navi.moneymanager.postonboard.dashboard.viewmodel +import android.os.Bundle +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope import com.navi.base.sharedpref.PreferenceManager import com.navi.base.utils.BaseUtils.getDayMonthAndYearFromTimestamp +import com.navi.base.utils.isNotNullAndNotEmpty +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.MM_DISCOVERABILITY_ENABLED +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.SHOULD_USE_GLOBAL_SCOPE_FOR_DASHBOARD_SYNC import com.navi.common.network.models.isSuccessWithData import com.navi.moneymanager.base.viewmodel.MMBaseViewModel import com.navi.moneymanager.common.analytics.DashboardEventTrackerImpl @@ -20,6 +26,7 @@ import com.navi.moneymanager.common.analytics.SpendAnalysisEventTrackerImpl import com.navi.moneymanager.common.analytics.TransactionHistoryEventTrackerImpl import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMConfigResponseHelper import com.navi.moneymanager.common.dataprovider.data.datastore.DataStoreInfoProvider +import com.navi.moneymanager.common.dataprovider.utils.isSameAccount import com.navi.moneymanager.common.datasync.UpiSpendDBSyncExecutor import com.navi.moneymanager.common.model.FilterAttribute import com.navi.moneymanager.common.model.MMToastData @@ -29,10 +36,13 @@ import com.navi.moneymanager.common.model.sectionHeader.MMJourneySource import com.navi.moneymanager.common.navigation.utils.MMScreen import com.navi.moneymanager.common.network.di.RoomDataStoreInfoProvider import com.navi.moneymanager.common.utils.Constants.ALL_BANKS +import com.navi.moneymanager.common.utils.Constants.BANK_FIP_ID +import com.navi.moneymanager.common.utils.Constants.CHECK_BALANCE_LINKED_ACCOUNT_REFERENCE import com.navi.moneymanager.common.utils.Constants.IS_FIRST_MONTH_SYNC_COMPLETED import com.navi.moneymanager.common.utils.Constants.IS_TOTAL_SYNC_COMPLETED import com.navi.moneymanager.common.utils.Constants.JOURNEY_SOURCE import com.navi.moneymanager.common.utils.Constants.LAST_REFRESH_SUCCESSFUL_TIMESTAMP +import com.navi.moneymanager.common.utils.Constants.MASKED_ACCOUNT_NUMBER import com.navi.moneymanager.common.utils.Constants.MM_IS_USER_ONBOARDED import com.navi.moneymanager.common.utils.Constants.NEW_TRANSACTION_COUNT import com.navi.moneymanager.common.utils.Constants.SYNC_THRESHOLD_TIME @@ -42,7 +52,9 @@ import com.navi.moneymanager.common.utils.checkFinarkeinDataValidity import com.navi.moneymanager.common.utils.getGenericErrorBottomSheetData import com.navi.moneymanager.postonboard.dashboard.model.AddAccountState import com.navi.moneymanager.postonboard.dashboard.model.BankAccount +import com.navi.moneymanager.postonboard.dashboard.model.BankAccountStatus import com.navi.moneymanager.postonboard.dashboard.model.BankAccountsState +import com.navi.moneymanager.postonboard.dashboard.model.BankSectionData import com.navi.moneymanager.postonboard.dashboard.model.DashboardScreenUiEffect import com.navi.moneymanager.postonboard.dashboard.model.DashboardScreenUiEvent import com.navi.moneymanager.postonboard.dashboard.model.DashboardScreenUiState @@ -58,8 +70,12 @@ import com.navi.moneymanager.postonboard.transactionhistory.model.TransactionFil import com.navi.moneymanager.preonboard.finarkein.model.FinarkeinDataState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -318,9 +334,9 @@ constructor( } } - fun fetchFinarkeinData(callback: (FinarkeinDataState) -> Unit) { + fun fetchFinarkeinData(bankFipId: String? = null, callback: (FinarkeinDataState) -> Unit) { viewModelScope.safeLaunch(Dispatchers.IO) { - val response = repository.fetchFinarkeinData() + val response = repository.fetchFinarkeinData(bankFipId) sendEvent( DashboardScreenUiEvent.DismissBottomSheet( DashboardScreenBottomSheets.FinarkeinError::class.java @@ -402,8 +418,30 @@ constructor( } } + @OptIn(DelicateCoroutinesApi::class) private fun syncDashboardData() { - viewModelScope.safeLaunch(Dispatchers.IO) { + val shouldUseGlobalScopeForSyncData = + FirebaseRemoteConfigHelper.getBoolean(SHOULD_USE_GLOBAL_SCOPE_FOR_DASHBOARD_SYNC) && + FirebaseRemoteConfigHelper.getBoolean(MM_DISCOVERABILITY_ENABLED) + val syncScope = if (shouldUseGlobalScopeForSyncData) GlobalScope else viewModelScope + // Only check if syncJob is active when using GlobalScope + if (shouldUseGlobalScopeForSyncData) { + if (syncJob?.isActive == true) { + // Skip if a global sync job is already running + dashboardEventTracker.onDashboardSyncSkipped() + return + } + dashboardEventTracker.onDashboardSyncStarted("GlobalScope") + syncJob = initMMSync(syncScope) + } else { + // For viewModelScope, just launch without holding onto the job + dashboardEventTracker.onDashboardSyncStarted("ViewModelScope") + initMMSync(syncScope) + } + } + + private fun initMMSync(syncScope: CoroutineScope): Job { + return syncScope.safeLaunch(Dispatchers.IO) { val isUserOnBoarded = repository.fetchUserOnboardingStatus() if (isUserOnBoarded == false) { setEffect { @@ -432,7 +470,7 @@ constructor( lastRefreshTimestamp = lastRefreshSuccessfulTimestamp, ) refreshAndSyncDataUseCase.execute( - scope = viewModelScope, + scope = syncScope, configResponse = configData, ) } else { @@ -444,7 +482,7 @@ constructor( lastRefreshTimestamp = lastRefreshSuccessfulTimestamp, ) syncPreviousDataUseCase.execute( - scope = viewModelScope, + scope = syncScope, configResponse = configData, ) } @@ -563,4 +601,71 @@ constructor( upiDBSyncExecutor.execute(MMScreen.DASHBOARD.screen) } } + + fun handleMMCheckBalanceEntryFlow( + bundle: Bundle?, + bankSectionData: BankSectionData, + initFinarkeinForSelectedAccount: MutableState, + ) { + if (bankSectionData.state is BankAccountsState.Loaded) { + val checkBalanceLinkedAccountReference = + bundle?.getString(CHECK_BALANCE_LINKED_ACCOUNT_REFERENCE) + + checkBalanceLinkedAccountReference?.let { linkedAccRef -> + bankSectionData.state.data.accounts + .firstOrNull { + it.referenceId == linkedAccRef && + it.bankStatus == BankAccountStatus.SYNC_COMPLETED + } + ?.let { account -> + dashboardEventTracker.onSelectedBankForBalanceUpdated() + sendEvent( + DashboardScreenUiEvent.UpdateSelectedBankForBalance( + bankId = account.referenceId + ) + ) + } + } + ?: run { + val maskedAccountNumber = bundle?.getString(MASKED_ACCOUNT_NUMBER) + + if ( + initFinarkeinForSelectedAccount.value && + maskedAccountNumber.isNotNullAndNotEmpty() + ) { + initFinarkeinForSelectedAccount.value = false + + val bankFipId = bundle?.getString(BANK_FIP_ID) + + if ( + bankSectionData.state.data.accounts + .any { + it.bankFipId == bankFipId && + isSameAccount( + mmAccountNumber = it.maskedAccountNumber.orEmpty(), + npciAccountNumber = maskedAccountNumber.orEmpty(), + ) + } + .not() + ) { + dashboardEventTracker.onAddAccountStateHandled() + dashboardEventTracker.onFinarkeinDataFetchTriggered(bankFipId.orEmpty()) + sendEvent( + DashboardScreenUiEvent.HandleAddAccountState( + AddAccountState.Selected.BankSection + ) + ) + + setEffect { + DashboardScreenUiEffect.Navigation.FetchFinarkeinData(bankFipId) + } + } + } + } + } + } + + companion object { + private var syncJob: Job? = null + } } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/launcher/ui/LauncherScreen.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/launcher/ui/LauncherScreen.kt index 7a9acf7491..9a048e1884 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/launcher/ui/LauncherScreen.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/launcher/ui/LauncherScreen.kt @@ -9,6 +9,7 @@ package com.navi.moneymanager.preonboard.launcher.ui import android.os.Bundle import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -24,6 +25,7 @@ import com.navi.common.navigation.NavigationAction import com.navi.elex.theme.elexColors import com.navi.moneymanager.common.navigation.utils.MMScreen import com.navi.moneymanager.common.ui.composable.ScreenInit +import com.navi.moneymanager.common.utils.Constants.BANK_FIP_ID import com.navi.moneymanager.common.utils.Constants.IS_CONSENT_REVOKED import com.navi.moneymanager.common.utils.MMScreenEventLogger import com.navi.moneymanager.entry.ui.activity.MMActivity @@ -42,7 +44,8 @@ fun LauncherScreen( viewModel: LauncherViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val isConsentRevoked = remember { bundle?.getBoolean(IS_CONSENT_REVOKED).orFalse() } + val intentBundle = bundle ?: LocalActivity.current?.intent?.extras + val isConsentRevoked = remember { intentBundle?.getBoolean(IS_CONSENT_REVOKED).orFalse() } ScreenInit(screenName = MMScreen.LAUNCHER.screen, activity = activity) MMScreenEventLogger( @@ -75,16 +78,24 @@ fun LauncherScreen( when { state.isOnBoarded == true -> { viewModel.setEffect { - LauncherScreenUiEffect.Navigation.NavigateToCta(MMScreen.DASHBOARD.screen) + LauncherScreenUiEffect.Navigation.NavigateToCta( + ctaUrl = MMScreen.DASHBOARD.screen, + bundle = intentBundle, + ) } } state.valuePropScreenData != null -> { + val bankFipId = intentBundle?.getString(BANK_FIP_ID) + state.valuePropScreenData?.let { screenDefinition -> activity.getSharedVM().setValuePropScreenDefinition(screenDefinition) viewModel.setEffect { LauncherScreenUiEffect.Navigation.NavigateToCta( MMScreen.VALUE_PROP_SCREEN.screen, - Bundle().apply { putBoolean(IS_CONSENT_REVOKED, isConsentRevoked) }, + Bundle().apply { + putBoolean(IS_CONSENT_REVOKED, isConsentRevoked) + putString(BANK_FIP_ID, bankFipId) + }, ) } } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/repo/ValuePropScreenRepository.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/repo/ValuePropScreenRepository.kt index 1ad151b19e..8d12cb90e3 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/repo/ValuePropScreenRepository.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/repo/ValuePropScreenRepository.kt @@ -18,8 +18,11 @@ constructor( private val remoteDataProvider: RemoteDataProvider, private val dashboardDataProvider: DashboardDataProvider, ) { - suspend fun fetchFinarkeinData() = - remoteDataProvider.fetchFinarkeinData(screenName = MMScreen.VALUE_PROP_SCREEN.screen) + suspend fun fetchFinarkeinData(bankFipId: String?) = + remoteDataProvider.fetchFinarkeinData( + screenName = MMScreen.VALUE_PROP_SCREEN.screen, + bankFipId = bankFipId, + ) suspend fun getFinarkeinErrorBottomSheetData() = dashboardDataProvider.getFinarkeinErrorBottomSheetData() diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/ui/ValuePropScreen.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/ui/ValuePropScreen.kt index 532ab4bad3..46e765a53b 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/ui/ValuePropScreen.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/ui/ValuePropScreen.kt @@ -24,6 +24,7 @@ import com.navi.moneymanager.common.model.bottomSheet.ValuePropScreenBottomSheet import com.navi.moneymanager.common.navigation.utils.MMScreen import com.navi.moneymanager.common.ui.composable.ScreenInit import com.navi.moneymanager.common.ui.composable.bottomSheet.MMBottomSheet +import com.navi.moneymanager.common.utils.Constants.BANK_FIP_ID import com.navi.moneymanager.common.utils.Constants.IS_CONSENT_REVOKED import com.navi.moneymanager.common.utils.MMScreenEventLogger import com.navi.moneymanager.entry.ui.activity.MMActivity @@ -48,6 +49,7 @@ fun ValuePropScreen( val state by viewModel.state.collectAsStateWithLifecycle() val isConsentRevoked = remember { bundle?.getBoolean(IS_CONSENT_REVOKED).orFalse() } + val bankFipId = remember { activity.intent.extras?.getString(BANK_FIP_ID) } ScreenInit(screenName = MMScreen.VALUE_PROP_SCREEN.screen, activity = activity) @@ -94,7 +96,7 @@ fun ValuePropScreen( sharedVM.updateFinarkeinErrorBottomSheetState(show = false) } is ValuePropScreenUiEffect.FetchFinarkeinData -> { - viewModel.fetchFinarkeinData(effect.triggerApiAction) { + viewModel.fetchFinarkeinData(bankFipId, effect.triggerApiAction) { when (it) { is FinarkeinDataState.Success -> { activity.launchFinarkeinSdk( @@ -118,7 +120,7 @@ fun ValuePropScreen( } } is ValuePropScreenUiEffect.RetryFetchFinarkeinData -> { - viewModel.retryFetchFinarkeinData { + viewModel.retryFetchFinarkeinData(bankFipId) { when (it) { is FinarkeinDataState.Success -> { activity.launchFinarkeinSdk( diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/viewmodel/ValuePropViewModel.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/viewmodel/ValuePropViewModel.kt index e9577d005a..d75cb70f33 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/viewmodel/ValuePropViewModel.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/preonboard/valueprop/viewmodel/ValuePropViewModel.kt @@ -73,12 +73,13 @@ constructor( } fun fetchFinarkeinData( + bankFipId: String?, triggerApiAction: MMFetchFinarkeinData, callback: (FinarkeinDataState) -> Unit, ) { _triggerApiAction = triggerApiAction viewModelScope.safeLaunch(Dispatchers.IO) { - val response = valuePropScreenRepository.fetchFinarkeinData() + val response = valuePropScreenRepository.fetchFinarkeinData(bankFipId) if (response.isSuccessWithData() && checkFinarkeinDataValidity(response.data)) { callback(FinarkeinDataState.Success(response.data!!)) handleActions(triggerApiAction.onSuccess) @@ -89,9 +90,9 @@ constructor( } } - fun retryFetchFinarkeinData(callback: (FinarkeinDataState) -> Unit) { + fun retryFetchFinarkeinData(bankFipId: String?, callback: (FinarkeinDataState) -> Unit) { viewModelScope.safeLaunch(Dispatchers.IO) { - val response = valuePropScreenRepository.fetchFinarkeinData() + val response = valuePropScreenRepository.fetchFinarkeinData(bankFipId) sendEvent( ValuePropScreenUiEvent.DismissBottomSheet( ValuePropScreenBottomSheets.FinarkeinError::class.java diff --git a/android/navi-money-manager/src/main/res/values/strings.xml b/android/navi-money-manager/src/main/res/values/strings.xml index 00c1767337..647156f470 100644 --- a/android/navi-money-manager/src/main/res/values/strings.xml +++ b/android/navi-money-manager/src/main/res/values/strings.xml @@ -256,4 +256,6 @@ No bills added for this month recommended Due today + Check spend analysis + Your spend analysis for %1$s diff --git a/android/navi-money-manager/src/test/java/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelperTest.kt b/android/navi-money-manager/src/test/java/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelperTest.kt index 961fb408d3..35d7675d6a 100644 --- a/android/navi-money-manager/src/test/java/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelperTest.kt +++ b/android/navi-money-manager/src/test/java/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/DashboardBankSectionProviderHelperTest.kt @@ -129,6 +129,8 @@ class DashboardBankSectionProviderHelperTest { ), bankSyncFailedTitle = "Sync failed", bankSyncFailedDescription = "Bank sync failed", + bankFipId = null, + maskedAccountNumber = "FNRKXXXXXX0097", ) Assert.assertEquals(2, result.size) Assert.assertEquals(mockResponse, result[0]) @@ -166,6 +168,8 @@ class DashboardBankSectionProviderHelperTest { "https://public-assets.prod.navi-sa.in/money-manager/svg/bank/icic.svg", placeholder = ALL_BANK_ICON_SMALL, ), + bankFipId = null, + maskedAccountNumber = "FNRKXXXXXXX5874", bankSyncFailedTitle = "Sync failed", bankSyncFailedDescription = "Bank sync failed", ) diff --git a/android/navi-money-manager/src/test/java/com/navi/moneymanager/testsetup/AccountDataHelperTest.kt b/android/navi-money-manager/src/test/java/com/navi/moneymanager/testsetup/AccountDataHelperTest.kt index 0f8aee4956..a8fd4601f8 100644 --- a/android/navi-money-manager/src/test/java/com/navi/moneymanager/testsetup/AccountDataHelperTest.kt +++ b/android/navi-money-manager/src/test/java/com/navi/moneymanager/testsetup/AccountDataHelperTest.kt @@ -31,6 +31,7 @@ class AccountDataHelperTest @Inject constructor() { "https://public-assets.prod.navi-sa.in/money-manager/svg/bank/icic.svg", currentBalance = 42765.80859375, updatedAt = 1736426735357, + fipId = null, ), AccountOverview( linkedAccRef = "52ecac77-14f7-4090-b89d-3d822dfcde94", @@ -42,6 +43,7 @@ class AccountDataHelperTest @Inject constructor() { "https://public-assets.prod.navi-sa.in/money-manager/svg/bank/icic.svg", currentBalance = 528973.1875, updatedAt = 1736426761037, + fipId = null, ), ), BankAccount( @@ -128,6 +130,7 @@ class AccountDataHelperTest @Inject constructor() { "https://public-assets.prod.navi-sa.in/money-manager/svg/bank/icic.svg", currentBalance = null, updatedAt = 1736426761037, + fipId = null, ) ) } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/ui/LinkedAccountBalanceScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/ui/LinkedAccountBalanceScreen.kt index 7e700c1a37..8d0dc90c79 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/ui/LinkedAccountBalanceScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/ui/LinkedAccountBalanceScreen.kt @@ -11,6 +11,11 @@ package com.navi.pay.onboarding.account.common.ui import android.app.Activity import android.view.WindowManager +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +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 @@ -72,6 +77,12 @@ import com.navi.common.utils.CommonUtils.getDisplayableAmount import com.navi.design.font.FontWeightEnum import com.navi.design.font.getFontWeight import com.navi.design.font.naviFontFamily +import com.navi.moneymanager.common.analytics.MMDiscoverabilityEventTrackerImpl +import com.navi.moneymanager.common.ui.composable.checkbalance.CheckBalanceMMEntryPointWidget +import com.navi.moneymanager.common.utils.Constants.BANK_FIP_ID +import com.navi.moneymanager.common.utils.Constants.MASKED_ACCOUNT_NUMBER +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointState import com.navi.naviwidgets.extensions.NaviText import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics @@ -157,6 +168,21 @@ fun LinkedAccountBalanceScreen( } } + val showMMDiscoverabilityEntryPoint by + linkedAccountBalanceViewModel.showMMDiscoverabilityEntryPoint.collectAsStateWithLifecycle() + + DisposableEffect(lifecycleOwner, showMMDiscoverabilityEntryPoint) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && showMMDiscoverabilityEntryPoint) { + linkedAccountBalanceViewModel.mmDiscoverabilityEventTracker + .onMMDiscoverabilityEntryPointLoadTriggered() + linkedAccountBalanceViewModel.loadMMEntryPoint(linkedAccountEntity) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + val currentBalanceValue by linkedAccountBalanceViewModel.currentBalance.collectAsStateWithLifecycle() val outstandingBalanceValue by @@ -172,6 +198,8 @@ fun LinkedAccountBalanceScreen( linkedAccountBalanceViewModel.checkBalanceTransactionEntrypointConfig .collectAsStateWithLifecycle() val hideBalance by linkedAccountBalanceViewModel.hideBalance.collectAsStateWithLifecycle() + val mmEntryPointData by + linkedAccountBalanceViewModel.mmEntryPointData.collectAsStateWithLifecycle() val bottomSheetState = rememberModalBottomSheetState( @@ -243,6 +271,7 @@ fun LinkedAccountBalanceScreen( } RenderLinkedAccountBalanceScreen( + activity = naviPayActivity, navigator = navigator, onRefreshIconClicked = { if (!hideBalance) { @@ -280,6 +309,8 @@ fun LinkedAccountBalanceScreen( }, transactionEntrypointData = transactionEntryPointData, hideBalance = hideBalance, + mmEntryPointData = mmEntryPointData, + mmDiscoverabilityEventTracker = linkedAccountBalanceViewModel.mmDiscoverabilityEventTracker, redirectOnClick = { ctaData -> linkedAccountBalanceViewModel.redirectToScreenBasedOnCtaUrl(ctaData) naviPayAnalytics.onTransactionEntrypointClicked( @@ -294,6 +325,7 @@ fun LinkedAccountBalanceScreen( @Composable private fun RenderLinkedAccountBalanceScreen( + activity: NaviPayActivity, navigator: DestinationsNavigator, onRefreshIconClicked: () -> Unit, linkedAccountEntity: LinkedAccountEntity, @@ -305,6 +337,8 @@ private fun RenderLinkedAccountBalanceScreen( onHelpClicked: () -> Unit, transactionEntrypointData: CheckBalanceTransactionEntryPointConfig, hideBalance: Boolean, + mmEntryPointData: CheckBalanceMMEntryPointData, + mmDiscoverabilityEventTracker: MMDiscoverabilityEventTrackerImpl, redirectOnClick: (CtaData) -> Unit, ) { @@ -330,6 +364,7 @@ private fun RenderLinkedAccountBalanceScreen( .padding(padding) ) { AccountBalanceDetailHeader( + activity = activity, linkedAccountEntity = linkedAccountEntity, onRefreshIconClicked = onRefreshIconClicked, showRefreshLoader = showRefreshLoader, @@ -337,6 +372,8 @@ private fun RenderLinkedAccountBalanceScreen( outstandingBalance = outstandingBalance, showShimmer = showShimmer, hideBalance = hideBalance, + mmEntryPointData = mmEntryPointData, + mmDiscoverabilityEventTracker = mmDiscoverabilityEventTracker, ) TransactionEntryPointCard( @@ -371,9 +408,11 @@ private fun RenderLinkedAccountBalanceScreen( AccountType.CREDIT.name -> { NaviPayCreditCardSponsorView(modifier = Modifier.fillMaxWidth()) } + AccountType.UPICREDIT.name -> { NaviPayCreditLineSponsorView(modifier = Modifier.fillMaxWidth()) } + else -> { NaviPaySponsorView(modifier = Modifier.fillMaxWidth()) } @@ -424,6 +463,7 @@ fun RenderLinkedAccountBalanceScreenBottomSheet( }, onSecondaryButtonClicked = closeSheet, ) + is LinkedAccountBalanceScreenBottomSheetUIState.OtherErrors -> { BottomSheetContentWithIconHeaderPrimarySecondaryButtonCtaLoader( iconId = CommonR.drawable.ic_exclamation_red_border, @@ -436,6 +476,7 @@ fun RenderLinkedAccountBalanceScreenBottomSheet( onSecondaryButtonClicked = {}, ) } + is LinkedAccountBalanceScreenBottomSheetUIState.DailyLimitExceeded -> { BottomSheetContentWithIconHeaderPrimarySecondaryButtonCtaLoader( iconId = CommonR.drawable.ic_exclamation_red_border, @@ -469,6 +510,7 @@ fun RenderLinkedAccountBalanceScreenBottomSheet( @Composable fun AccountBalanceDetailHeader( + activity: NaviPayActivity, linkedAccountEntity: LinkedAccountEntity, onRefreshIconClicked: () -> Unit, showRefreshLoader: Boolean, @@ -476,6 +518,8 @@ fun AccountBalanceDetailHeader( outstandingBalance: String?, showShimmer: Boolean, hideBalance: Boolean, + mmEntryPointData: CheckBalanceMMEntryPointData, + mmDiscoverabilityEventTracker: MMDiscoverabilityEventTrackerImpl, ) { Box( modifier = @@ -487,14 +531,18 @@ fun AccountBalanceDetailHeader( when (AccountType.getAccountType(linkedAccountEntity.accountType)) { AccountType.SAVINGS -> { BankDetailsHeaderUI( + activity = activity, linkedAccountEntity = linkedAccountEntity, onRefreshIconClicked = onRefreshIconClicked, showRefreshLoader = showRefreshLoader, showShimmer = showShimmer, currentBalance = currentBalance, hideBalance = hideBalance, + mmEntryPointData = mmEntryPointData, + mmDiscoverabilityEventTracker = mmDiscoverabilityEventTracker, ) } + AccountType.UPICREDIT, AccountType.CREDIT -> { CreditDetailsHeaderUI( @@ -513,6 +561,7 @@ fun AccountBalanceDetailHeader( @Composable fun BankDetailsHeaderUI( + activity: NaviPayActivity, modifier: Modifier = Modifier, linkedAccountEntity: LinkedAccountEntity, onRefreshIconClicked: () -> Unit, @@ -520,6 +569,8 @@ fun BankDetailsHeaderUI( showShimmer: Boolean, currentBalance: String, hideBalance: Boolean, + mmEntryPointData: CheckBalanceMMEntryPointData, + mmDiscoverabilityEventTracker: MMDiscoverabilityEventTrackerImpl, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -623,6 +674,32 @@ fun BankDetailsHeaderUI( ) } } + + AnimatedVisibility( + visible = mmEntryPointData.mmEntryPointState != CheckBalanceMMEntryPointState.NoData, + enter = + expandVertically( + expandFrom = Alignment.Top, + animationSpec = + tween(500, easing = CubicBezierEasing(0.83f, 0.17f, 0.23f, 0.89f)), + ), + exit = shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(100)), + ) { + CheckBalanceMMEntryPointWidget( + data = mmEntryPointData, + getMMDiscoverabilityEventTracker = { mmDiscoverabilityEventTracker }, + onFooterClick = { ctaData -> + val bundle = ctaData.bundle + val fipId = bundle?.getString(BANK_FIP_ID).orEmpty() + val maskedAccountNumber = bundle?.getString(MASKED_ACCOUNT_NUMBER).orEmpty() + mmDiscoverabilityEventTracker.onMMDiscoverabilityWidgetFooterClicked( + fipId, + maskedAccountNumber, + ) + NaviPayRouter.onCtaClick(naviPayActivity = activity, ctaData = ctaData) + }, + ) + } } } @@ -654,7 +731,7 @@ fun CreditDetailsHeaderUI( color = NaviPayColor.borderDefault, shape = MaterialTheme.shapes.extraSmall, ) - .padding(horizontal = 16.dp, vertical = 24.dp), + .padding(all = 16.dp), ) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/viewmodel/LinkedAccountBalanceViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/viewmodel/LinkedAccountBalanceViewModel.kt index 74915bb564..f537fc1aba 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/viewmodel/LinkedAccountBalanceViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/onboarding/account/common/viewmodel/LinkedAccountBalanceViewModel.kt @@ -13,10 +13,21 @@ import com.google.gson.reflect.TypeToken import com.navi.base.cache.repository.NaviCacheRepository import com.navi.base.model.CtaData import com.navi.base.utils.ResourceProvider +import com.navi.base.utils.orFalse import com.navi.common.di.CoroutineDispatcherProvider +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.MM_DISCOVERABILITY_ENABLED import com.navi.common.network.ApiConstants.API_LIMIT_EXCEEDED import com.navi.common.network.models.RepoResult import com.navi.common.utils.SPACE +import com.navi.moneymanager.common.analytics.MMDiscoverabilityEventTrackerImpl +import com.navi.moneymanager.common.datasync.MMDiscoverabilitySyncPreviousDataUseCase +import com.navi.moneymanager.common.helper.CheckBalanceMMWidgetHelper +import com.navi.moneymanager.common.model.sectionHeader.MMJourneySource +import com.navi.moneymanager.common.utils.Constants.JOURNEY_SOURCE +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData +import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointState +import com.navi.moneymanager.postonboard.dashboard.repo.DashboardScreenRepository import com.navi.pay.NavGraphs import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics @@ -37,6 +48,7 @@ import com.navi.pay.destinations.OrderDetailsScreenDestination import com.navi.pay.entry.NaviPayActivityDataProvider import com.navi.pay.management.common.model.view.LinkedAccountBalanceScreenSource import com.navi.pay.management.common.utils.NaviPayPspManager +import com.navi.pay.onboarding.account.add.model.view.AccountType import com.navi.pay.onboarding.account.common.model.view.LinkedAccountBalanceBottomSheetStateHolder import com.navi.pay.onboarding.account.detail.model.view.LinkedAccountEntity import com.navi.pay.onboarding.account.linked.model.network.CheckBalanceResponse @@ -57,6 +69,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.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -73,6 +86,9 @@ constructor( private val naviPayActivityDataProvider: NaviPayActivityDataProvider, private val naviPayPspManager: NaviPayPspManager, private val naviPayCustomerStatusHandler: NaviPayCustomerStatusHandler, + private val checkBalanceMMWidgetHelper: CheckBalanceMMWidgetHelper, + private val syncPreviousDataUseCase: MMDiscoverabilitySyncPreviousDataUseCase, + private val dashboardRepository: DashboardScreenRepository, ) : NaviPayBaseVM() { private val linkedAccountEntity: LinkedAccountEntity = savedStateHandle["linkedAccountEntity"]!! @@ -95,6 +111,15 @@ constructor( MutableStateFlow(savedStateHandle["outstandingBalance"]) val outstandingBalance = _outstandingBalance.asStateFlow() + private val _mmEntryPointData = + MutableStateFlow( + CheckBalanceMMEntryPointData( + mmEntryPointState = CheckBalanceMMEntryPointState.NoData, + titleResId = R.string.empty, + ) + ) + val mmEntryPointData = _mmEntryPointData.asStateFlow() + private val source: String = savedStateHandle["source"] ?: "" private val linkedAccountBalanceScreenSource: LinkedAccountBalanceScreenSource = @@ -125,8 +150,10 @@ constructor( when (linkedAccountBalanceScreenSource) { LinkedAccountBalanceScreenSource.LINKED_ACCOUNTS_SCREEN -> LinkedAccountsScreenDestination + LinkedAccountBalanceScreenSource.LINKED_ACCOUNT_DETAIL_SCREEN -> LinkedAccountDetailScreenDestination + LinkedAccountBalanceScreenSource.ORDER_DETAILS_SCREEN -> OrderDetailsScreenDestination } @@ -147,10 +174,19 @@ constructor( private val _hideBalance = MutableStateFlow(false) val hideBalance = _hideBalance.asStateFlow() + private val _showMMDiscoverabilityEntryPoint = MutableStateFlow(false) + val showMMDiscoverabilityEntryPoint = _showMMDiscoverabilityEntryPoint.asStateFlow() + val helpCta = getHelpCtaData(screenName = screenName) private var timerJob: Job? = null + val mmDiscoverabilityEventTracker by lazy { + MMDiscoverabilityEventTrackerImpl( + parameters = mapOf(JOURNEY_SOURCE to MMJourneySource.ACCOUNT_AGGREGATOR.name) + ) + } + init { naviPayAnalytics.onCheckBalanceSuccess(source = source) viewModelScope.launch(Dispatchers.IO) { @@ -168,8 +204,66 @@ constructor( } fetchTransactionEntrypointConfig(screenName = screenName) runCountDownTimer() + checkAndShowMMDiscoverabilityEntryPoint() } + private fun checkAndShowMMDiscoverabilityEntryPoint() { + viewModelScope.launch(Dispatchers.IO) { + val accountType = AccountType.getAccountType(linkedAccountEntity.accountType) + val experimentEnabled = isMMDiscoverabilityExperimentEnabled() + + mmDiscoverabilityEventTracker.onMMDiscoverabilityCheckStarted( + accountType = accountType.name, + experimentEnabled = experimentEnabled, + bankCode = linkedAccountEntity.bankCode, + ) + + if (accountType == AccountType.SAVINGS && experimentEnabled) { + val mmConfig = syncPreviousDataUseCase.fetchAndSaveConfigResponse(screenName) + if ( + !checkBalanceMMWidgetHelper.isBankEligible( + linkedAccountEntity.bankCode, + mmConfig, + ) + ) { + mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible( + "bank_not_eligible" + ) + return@launch + } + mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointEligible() + _showMMDiscoverabilityEntryPoint.value = true + } else { + val reason = + if (accountType != AccountType.SAVINGS) "non_savings_account" + else "experiment_disabled" + mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible(reason) + return@launch + } + } + } + + fun loadMMEntryPoint(linkedAccountEntity: LinkedAccountEntity) { + viewModelScope.safeLaunch(Dispatchers.IO) { + val isUserOnboarded = dashboardRepository.fetchUserOnboardingStatus() + if (isUserOnboarded.orFalse()) syncPreviousDataUseCase.execute(viewModelScope) + checkBalanceMMWidgetHelper + .getCheckBalanceScreenEntryPointData( + npciBankCode = linkedAccountEntity.bankCode, + maskedAccountNumber = linkedAccountEntity.maskedAccountNumber, + syncStatus = syncPreviousDataUseCase.syncCompleteFlow(), + isUserOnboardedForMM = isUserOnboarded.orFalse(), + ) + .distinctUntilChanged() // emits only when the data actually changes + .collect { data -> _mmEntryPointData.update { data } } + } + } + + private suspend fun isMMDiscoverabilityExperimentEnabled() = + FirebaseRemoteConfigHelper.getBoolean(MM_DISCOVERABILITY_ENABLED) && + checkBalanceMMWidgetHelper.isMMDiscoverabilityExperimentEnabled() && + checkBalanceMMWidgetHelper.isMoneyManagerExperimentEnabled() + private suspend fun updateCustomerOnboardingEntity() { val pspType = savedStateHandle.get("pspType") if (pspType == null) { 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 74f410a5a9..b546a79244 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 @@ -8,6 +8,8 @@ package com.navi.pay.utils import com.navi.common.utils.Constants.AUTO_READ_OTP_CONSENT_KEY +import com.navi.common.utils.Constants.LITMUS_EXPERIMENT_CHECK_BALANCE_MM_EXPERIENCE +import com.navi.common.utils.Constants.LITMUS_EXPERIMENT_HPC_MM_ROLLOUT import com.navi.common.utils.Constants.LITMUS_EXPERIMENT_NAVIPAY_NAVI_POWER_PLAY import com.navi.moneymanager.common.utils.Constants.LITMUS_EXPERIMENT_TXN_HISTORY_SPEND_ANALYSIS_V2 @@ -207,6 +209,8 @@ val NAVI_PAY_LITMUS_EXPERIMENTS = LITMUS_EXPERIMENT_NAVIPAY_REVERSE_SMS_BINDING, LITMUS_EXPERIMENT_NAVIPAY_RCC_LANDING_EXP, LITMUS_EXPERIMENT_NAVIPAY_LITE_PPS, + LITMUS_EXPERIMENT_CHECK_BALANCE_MM_EXPERIENCE, + LITMUS_EXPERIMENT_HPC_MM_ROLLOUT, ) // Generic