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 e924e8302a..e3047e43dc 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 @@ -142,6 +142,8 @@ object FirebaseRemoteConfigHelper { 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 MM_WEEKLY_HISAAB_ENABLED = "MM_WEEKLY_HISAAB_ENABLED" + const val MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS = "MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS" 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" 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 8e743cb597..95fdab1577 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 @@ -1096,3 +1096,34 @@ interface MMDiscoverabilityEventTracker { @EventName("mm_discoverability_entry_point_load_triggered") fun onMMDiscoverabilityEntryPointLoadTriggered() } + +@AutoGenerate +interface MMWeeklyHisaabEventTracker { + + @EventName("mm_hisaab_widget_visible") fun onMMHisaabWidgetVisible() + + @EventName("mm_mrr_current_month_spends_clicked") fun onMMMRRCurrentMonthSpendsClicked() + + @EventName("mm_mrr_previous_month_spends_clicked") fun onMMMRRPreviousMonthSpendsClicked() + + @EventName("mm_mrr_spends_comparison_balance_insight_clicked") + fun onMMWeeklyHisaabBalanceInsightClicked() + + @EventName("mm_mrr_spends_comparison_balance_insight") + fun onMMWeeklyHisaabBalanceInsight(state: String, color: String) + + @EventName("mm_weekly_hisaab_remote_config_disabled") + fun onMMWeeklyHisaabDisabledViaRemoteConfig() + + @EventName("mm_weekly_hisaab_config_api_called") fun onMMWeeklyHisaabConfigApiCalled() + + @EventName("mm_weekly_hisaab_litmus_enabled") fun onMMWeeklyHisaablitmusEnabled() + + @EventName("mm_weekly_hisaab_litmus_disabled") + fun onMMWeeklyHisaabLitmusDisabled(reason: String) + + @EventName("mm_weekly_hisaab_config_api_failed") + fun onMMWeeklyHisaabConfigApiFailed(errorMessage: String, statusCode: String) + + @EventName("mm_upi_txn_sync_skipped_already_running") fun onUpiSyncSkippedAlreadyRunning() +} 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 index 9490267c79..3dd18da6e7 100644 --- 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 @@ -15,7 +15,13 @@ 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.illustration.repository.ImageRepository.Companion.SPIKE_ICON_URL +import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.WHITE_ARROW_DOWN +import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.YELLOW_ARROW_DIAGONAL_RIGHT_UP import com.navi.moneymanager.common.model.CategorySummary +import com.navi.moneymanager.common.model.SpendComparisonType +import com.navi.moneymanager.common.model.SpendTrendColor +import com.navi.moneymanager.common.model.SpendingTrend 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 @@ -41,6 +47,7 @@ import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointSt import com.navi.naviwidgets.utils.NaviWidgetLottieUtils.TUK_TUK_LOTTIE import com.navi.naviwidgets.utils.ZERO import javax.inject.Inject +import kotlin.math.roundToInt class CheckBalanceScreenEntryPointHelper @Inject constructor() { @@ -196,4 +203,49 @@ class CheckBalanceScreenEntryPointHelper @Inject constructor() { ), ) } + + fun evaluateSpendingTrend( + currentMonthTotalSpend: Double, + previousMonthTotalSpend: Double, + ): SpendingTrend? { + if (currentMonthTotalSpend == 0.0 || previousMonthTotalSpend == 0.0) return null + + val percentChange = + (((currentMonthTotalSpend - previousMonthTotalSpend) / previousMonthTotalSpend) * 100) + .roundToInt() + + return when { + percentChange > 50 -> + SpendingTrend( + SpendComparisonType.HIGHER, + SpendTrendColor.RED, + percentChange, + iconUrl = SPIKE_ICON_URL, + ) + + percentChange in 1..50 -> + SpendingTrend( + SpendComparisonType.HIGHER, + SpendTrendColor.AMBER, + percentChange, + iconUrl = SPIKE_ICON_URL, + ) + + percentChange < 0 -> + SpendingTrend( + SpendComparisonType.LESS, + SpendTrendColor.GREEN, + kotlin.math.abs(percentChange), + iconUrl = WHITE_ARROW_DOWN, + ) + + else -> + SpendingTrend( + SpendComparisonType.SIMILAR, + SpendTrendColor.GREEN, + 0, + iconUrl = YELLOW_ARROW_DIAGONAL_RIGHT_UP, + ) + } + } } 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 index e0c6847b54..d19b8a92b1 100644 --- 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 @@ -20,6 +20,7 @@ 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.SyncStatusBundle import com.navi.moneymanager.common.model.database.AccountOverview import com.navi.moneymanager.common.network.di.RoomDataStoreInfoProvider import com.navi.moneymanager.common.network.model.MMConfigResponse @@ -300,10 +301,3 @@ constructor( } } } - -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/checkbalance/provider/CheckBalanceScreenEntryPointUPIDataProviderImpl.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/provider/CheckBalanceScreenEntryPointUPIDataProviderImpl.kt new file mode 100644 index 0000000000..f84f1f0000 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/checkbalance/provider/CheckBalanceScreenEntryPointUPIDataProviderImpl.kt @@ -0,0 +1,187 @@ +/* + * + * * 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.formatToInrWithDecimals +import com.navi.common.utils.monthAbbreviations +import com.navi.moneymanager.common.dataprovider.data.checkbalance.helper.CheckBalanceScreenEntryPointHelper +import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMWeeklyHisaabConfigResponseHelper +import com.navi.moneymanager.common.dataprovider.domain.upi.CheckBalanceScreenEntryPointUPIDataProvider +import com.navi.moneymanager.common.dataprovider.utils.MMQueryExecutor.Companion.executeQueryFlow +import com.navi.moneymanager.common.db.upi.database.UpiSpendDatabase +import com.navi.moneymanager.common.model.DateInfo +import com.navi.moneymanager.common.model.SelectedMonth +import com.navi.moneymanager.common.utils.Constants.DEBIT +import com.navi.moneymanager.common.utils.MonthConstants +import com.navi.moneymanager.common.utils.combineWithFlatMapLatest +import com.navi.moneymanager.common.utils.getDateInfoFromTimestamp +import com.navi.moneymanager.common.utils.getPreviousMonthDayLimit +import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData +import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonData +import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonViewState +import com.navi.moneymanager.entry.model.checkbalance.SpendItemData +import dagger.Lazy +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +class CheckBalanceScreenEntryPointUPIDataProviderImpl +@Inject +constructor( + private val upiSpendDatabase: Lazy, + private val entryPointHelper: CheckBalanceScreenEntryPointHelper, + private val mmWeeklyHisaabConfigResponseHelper: MMWeeklyHisaabConfigResponseHelper, +) : CheckBalanceScreenEntryPointUPIDataProvider { + + override suspend fun getMMWeeklyHisaabWidgetData(): Flow { + val dateInfo = getDateInfoFromTimestamp(System.currentTimeMillis()) + + val currentMonth = dateInfo.month + val currentYear = dateInfo.year + val currentDay = dateInfo.day + val previousMonthDay = getPreviousMonthDayLimit(dateInfo) + + val previousMonth = if (currentMonth == 0) 11 else currentMonth - 1 + val previousYear = if (currentMonth == 0) currentYear - 1 else currentYear + + return combineWithFlatMapLatest( + flow1 = + getMonthlyTransactionTotal( + month = currentMonth, + year = currentYear, + dayLimit = currentDay, + transactionType = DEBIT, + ), + flow2 = + getMonthlyTransactionTotal( + month = previousMonth, + year = previousYear, + dayLimit = previousMonthDay, + transactionType = DEBIT, + ), + combineTransform = { currentMonthTotalSpend, previousMonthTotalSpend -> + Pair(currentMonthTotalSpend, previousMonthTotalSpend) + }, + flatMapLatestTransform = { (currentMonthTotalSpend, previousMonthTotalSpend) -> + emit( + MMWeeklyHisaabWidgetData( + monthName = MonthConstants.monthNames[currentMonth], + currentMonthSpend = + SpendItemData( + monthName = monthAbbreviations[currentMonth], + selectedMonth = + SelectedMonth(month = currentMonth, year = currentYear), + currentDay = currentDay, + spendAmount = + currentMonthTotalSpend.formatToInrWithDecimals( + showDecimalOnlyForFractional = true + ), + ), + previousMonthSpend = + SpendItemData( + monthName = monthAbbreviations[previousMonth], + selectedMonth = + SelectedMonth(month = previousMonth, year = previousYear), + currentDay = previousMonthDay, + spendAmount = + previousMonthTotalSpend.formatToInrWithDecimals( + showDecimalOnlyForFractional = true + ), + ), + spendComparison = + getSpendComparisonViewState( + currentMonthTotalSpend = currentMonthTotalSpend, + previousMonthTotalSpend = previousMonthTotalSpend, + dateInfo = dateInfo, + entryPointHelper = entryPointHelper, + mmWeeklyHisaabConfigResponseHelper = + mmWeeklyHisaabConfigResponseHelper, + ), + ) + ) + }, + ) + } + + suspend fun getSpendComparisonViewState( + currentMonthTotalSpend: Double, + previousMonthTotalSpend: Double, + dateInfo: DateInfo, + entryPointHelper: CheckBalanceScreenEntryPointHelper, + mmWeeklyHisaabConfigResponseHelper: MMWeeklyHisaabConfigResponseHelper, + ): SpendComparisonViewState { + val isUserNotUpiLoyal = + mmWeeklyHisaabConfigResponseHelper + .getWeeklyHisaabConfig() + ?.isUserUPILoyalInPreviousMonth + ?.not() == true + + if (isUserNotUpiLoyal) return SpendComparisonViewState.Hide + + val spendingTrend = + entryPointHelper.evaluateSpendingTrend( + currentMonthTotalSpend = currentMonthTotalSpend, + previousMonthTotalSpend = previousMonthTotalSpend, + ) + + return spendingTrend?.let { + SpendComparisonViewState.Show( + SpendComparisonData( + percentage = spendingTrend.percentChange, + changeType = spendingTrend.text, + currentMonthName = MonthConstants.monthNames[dateInfo.month], + daysLeft = dateInfo.remainingDaysInMonth, + iconUrl = spendingTrend.iconUrl, + color = spendingTrend.color, + selectedMonth = SelectedMonth(month = dateInfo.month, year = dateInfo.year), + ) + ) + } ?: SpendComparisonViewState.Hide + } + + /** + * Fetches total UPI transaction amount for a given month, year, and transaction type, limited + * up to the specified day (e.g., current date in month). + * + * This function directly queries the database and emits the total sum as a Flow, and + * will emit updates only when the sum changes. + * + * @param month The target month (0-based: January = 0) + * @param year The target year (e.g., 2025) + * @param dayLimit The last day of transaction range (e.g., current day in month) + * @param transactionType The type of transaction to include (e.g., "DEBIT" or "CREDIT") + * @return A Flow emitting the total transaction amount (in INR) for the given period. + */ + suspend fun getMonthlyTransactionTotal( + month: Int, + year: Int, + dayLimit: Int, + transactionType: String, + ): Flow { + return executeQueryFlow( + queryName = + upiSpendDatabase.get().transactionsDao()::fetchUpiTransactionsByMonthAndType + .name, + methodName = ::getMonthlyTransactionTotal.name, + flow = + upiSpendDatabase + .get() + .transactionsDao() + .fetchUpiTransactionsByMonthAndType( + year = year, + month = month, + currentDate = dayLimit, + transactionType = transactionType, + ), + ) + // Convert the list of transactions into a sum of amounts + .map { txnList -> txnList.sumOf { it.txnAmount ?: 0.0 } } + .distinctUntilChanged() + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/MMWeeklyHisaabConfigResponseHelper.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/MMWeeklyHisaabConfigResponseHelper.kt new file mode 100644 index 0000000000..84ae39923d --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/data/dashboard/helper/MMWeeklyHisaabConfigResponseHelper.kt @@ -0,0 +1,96 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.dataprovider.data.dashboard.helper + +import com.google.gson.Gson +import com.navi.base.cache.model.NaviCacheEntity +import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.moneymanager.common.network.di.MoneyManagerGsonDeserializer +import com.navi.moneymanager.common.network.di.MoneyManagerGsonSerializer +import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse +import com.navi.moneymanager.common.utils.DbCacheConstants.MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY +import dagger.Lazy +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow + +/** + * Helper class for managing the weekly Hisaab config response, including caching in memory and + * saving to persistent storage via NaviCacheRepository. + */ +@Singleton +class MMWeeklyHisaabConfigResponseHelper +@Inject +constructor( + private val naviCacheRepository: Lazy, + @MoneyManagerGsonDeserializer private val gsonDeserializer: Gson, + @MoneyManagerGsonSerializer private val gsonSerializer: Gson, +) { + + // In-memory cache of the weekly Hisaab config to avoid repeated DB reads + private var weeklyHisaabConfigResponse: MMWeeklHisaabResponse? = null + + /** + * Saves the given weekly Hisaab config both in memory and in the Navi cache repository. + * + * @param data The response object to be saved. + */ + suspend fun saveWeeklyHisaabConfigToDB(data: MMWeeklHisaabResponse) { + + weeklyHisaabConfigResponse = data + + val dataJson = gsonSerializer.toJson(data) + + val cacheEntity = + NaviCacheEntity( + key = MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY, + value = dataJson, + version = 1, + ) + + naviCacheRepository.get().save(cacheEntity) + } + + /** + * Returns the weekly Hisaab config either from memory (if cached), or from the database if not + * previously loaded. + * + * @return MMWeeklHisaabResponse if found, or null otherwise. + */ + suspend fun getWeeklyHisaabConfig(): MMWeeklHisaabResponse? { + return if (weeklyHisaabConfigResponse == null) { + getWeeklyHisaabConfigFromDB() + .filterNotNull() // + .firstOrNull() // + ?.also { response -> weeklyHisaabConfigResponse = response } + } else { + weeklyHisaabConfigResponse + } + } + + /** + * Returns a cold flow that emits the weekly Hisaab config from the cache repository, + * deserialized from JSON. + * + * @return Flow emitting the config when available. + */ + private fun getWeeklyHisaabConfigFromDB() = flow { + val cacheEntity = + naviCacheRepository.get().getAsFlow(MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY) + + cacheEntity.collect { entity -> + emit( + entity?.value?.let { value -> + gsonDeserializer.fromJson(value, MMWeeklHisaabResponse::class.java) + } + ) + } + } +} 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 1cfc7eb259..3fb752ff70 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 @@ -23,6 +23,7 @@ import com.navi.moneymanager.common.network.model.CustomerProfileData import com.navi.moneymanager.common.network.model.FetchConsentUrlResponse import com.navi.moneymanager.common.network.model.FinarkeinDataResponse import com.navi.moneymanager.common.network.model.MMConfigResponse +import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse import com.navi.moneymanager.common.network.model.PollingStatusResponse import com.navi.moneymanager.common.network.model.RefreshDataResponse import com.navi.moneymanager.common.network.model.SpendGoalData @@ -284,4 +285,14 @@ class RemoteDataProviderImpl @Inject constructor(private val retrofitService: Re ), ) } + + override suspend fun fetchMMWeeklyHisaabData( + screenName: String + ): RepoResult { + return apiResponseCallback( + response = retrofitService.fetchMMWeeklyHisaabData(), + 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 836397255c..398846aca3 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 @@ -15,6 +15,7 @@ import com.navi.moneymanager.common.dataprovider.data.addcategory.provider.AddCa 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.checkbalance.provider.CheckBalanceScreenEntryPointUPIDataProviderImpl 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 @@ -40,6 +41,7 @@ import com.navi.moneymanager.common.dataprovider.domain.RemoteDataProvider import com.navi.moneymanager.common.dataprovider.domain.SpendAnalysisLocalDataProvider import com.navi.moneymanager.common.dataprovider.domain.TransactionDataProvider import com.navi.moneymanager.common.dataprovider.domain.TransactionHistoryDataProvider +import com.navi.moneymanager.common.dataprovider.domain.upi.CheckBalanceScreenEntryPointUPIDataProvider import com.navi.moneymanager.common.dataprovider.domain.upi.TxnHistoryUpiSpendDataProvider import com.navi.moneymanager.common.dataprovider.domain.upi.UpiSpendAddCategoryDataProvider import com.navi.moneymanager.common.dataprovider.domain.upi.UpiSpendAnalysisLocalDataProvider @@ -147,6 +149,11 @@ abstract class DataProviderModule { abstract fun bindCheckBalanceScreenEntryPointDataProvider( checkBalanceScreenEntryPointDataProvider: CheckBalanceScreenEntryPointDataProviderImpl ): CheckBalanceScreenEntryPointDataProvider + + @Binds + abstract fun bindCheckBalanceScreenEntryPointUPIDataProvider( + checkBalanceScreenEntryPointUPIDataProvider: CheckBalanceScreenEntryPointUPIDataProviderImpl + ): CheckBalanceScreenEntryPointUPIDataProvider } @Module 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 c411e03612..ff8988561f 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 @@ -17,6 +17,7 @@ import com.navi.moneymanager.common.network.model.CustomerProfileData import com.navi.moneymanager.common.network.model.FetchConsentUrlResponse import com.navi.moneymanager.common.network.model.FinarkeinDataResponse import com.navi.moneymanager.common.network.model.MMConfigResponse +import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse import com.navi.moneymanager.common.network.model.OnboardingStatusResponse import com.navi.moneymanager.common.network.model.PollingStatusResponse import com.navi.moneymanager.common.network.model.RefreshDataResponse @@ -94,4 +95,6 @@ interface RemoteDataProvider { requestBody: AddBillRequestBody, billId: String, ): RepoResult + + suspend fun fetchMMWeeklyHisaabData(screenName: String): RepoResult } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/upi/CheckBalanceScreenEntryPointUPIDataProvider.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/upi/CheckBalanceScreenEntryPointUPIDataProvider.kt new file mode 100644 index 0000000000..c78b8e7673 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/dataprovider/domain/upi/CheckBalanceScreenEntryPointUPIDataProvider.kt @@ -0,0 +1,16 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.dataprovider.domain.upi + +import com.navi.moneymanager.common.dataprovider.domain.LocalDataProvider +import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData +import kotlinx.coroutines.flow.Flow + +interface CheckBalanceScreenEntryPointUPIDataProvider : LocalDataProvider { + suspend fun getMMWeeklyHisaabWidgetData(): Flow +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/UpiSpendDBSyncExecutor.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/UpiSpendDBSyncExecutor.kt index 60871e7aab..790bb7df49 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/UpiSpendDBSyncExecutor.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/datasync/UpiSpendDBSyncExecutor.kt @@ -23,6 +23,11 @@ import com.navi.moneymanager.common.network.model.UpiSpendTransactionData import dagger.Lazy import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Singleton class UpiSpendDBSyncExecutor @@ -33,23 +38,47 @@ constructor( private val upiSpendDatabase: Lazy, private val mmConfigResponseHelper: MMConfigResponseHelper, ) { + private val mutex = Mutex() + private val _syncState = MutableSharedFlow(replay = 1) + private val syncState: SharedFlow = _syncState.asSharedFlow() + private var isExecuting = false - suspend fun execute(screenName: String): DataSyncState { - val config = fetchAndSaveConfigResponse(screenName) ?: return DataSyncState.Failed + suspend fun execute(screenName: String): SharedFlow { + mutex.withLock { + if (!isExecuting) { + isExecuting = true + executeInternal(screenName) + isExecuting = false + } + } + return syncState + } + + private suspend fun executeInternal(screenName: String) { + val config = + fetchAndSaveConfigResponse(screenName) + ?: run { + _syncState.emit(DataSyncState.Failed) + return + } val cutoffTimestamp = - config.timestampConfig?.twelveMonthsOldTimestamp ?: return DataSyncState.Failed + config.timestampConfig?.twelveMonthsOldTimestamp + ?: run { + _syncState.emit(DataSyncState.Failed) + return + } val startTimestamp = upiSpendLocalDataSyncManager.getMostRecentTransactionTimestamp(cutoffTimestamp) - return if (syncTransactions(startTimestamp)) { + if (syncTransactions(startTimestamp)) { upiSpendLocalDataSyncManager.updateTotalSyncCompleteFlag() UpiTransactionHistorySpendAnalyserEventTrackerImpl().onDataSyncCompleted() - DataSyncState.Completed + _syncState.emit(DataSyncState.Completed) } else { UpiTransactionHistorySpendAnalyserEventTrackerImpl().onDataSyncFailed() - DataSyncState.Failed + _syncState.emit(DataSyncState.Failed) } } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/upi/dao/UpiSpendTransactionDao.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/upi/dao/UpiSpendTransactionDao.kt index f252c2dfff..29643ba2f6 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/upi/dao/UpiSpendTransactionDao.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/db/upi/dao/UpiSpendTransactionDao.kt @@ -100,4 +100,16 @@ interface UpiSpendTransactionDao { startDate: Long, endDate: Long, ): Flow> + + @Query( + "SELECT upiTxnId, naviUpiTxnId, txnTimestamp, txnMonth, txnAmount, type, counterPartyName, finalCategory " + + "FROM $UPI_SPEND_TRANSACTION_TABLE " + + "WHERE txnYear = :year AND txnMonth = :month AND txnDay <= :currentDate AND type = :transactionType " + ) + fun fetchUpiTransactionsByMonthAndType( + year: Int, + month: Int, + currentDate: Int, + transactionType: String, + ): Flow> } 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 index 8119a8fafc..ad0a961d0f 100644 --- 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 @@ -12,9 +12,11 @@ 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.dataprovider.domain.upi.CheckBalanceScreenEntryPointUPIDataProvider 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 com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -23,6 +25,7 @@ class CheckBalanceMMWidgetHelper constructor( private val litmusExperimentsUseCase: LitmusExperimentsUseCase, private val checkBalanceDataProvider: CheckBalanceScreenEntryPointDataProvider, + private val checkBalanceUPIDataProvider: CheckBalanceScreenEntryPointUPIDataProvider, ) { suspend fun getCheckBalanceScreenEntryPointData( npciBankCode: String, @@ -38,6 +41,10 @@ constructor( ) } + suspend fun getMMWeeklyHisaabWidgetData(): Flow { + return checkBalanceUPIDataProvider.getMMWeeklyHisaabWidgetData() + } + suspend fun isMMDiscoverabilityExperimentEnabled(): Boolean { val experimentInfo = litmusExperimentsUseCase.execute( 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 d5842ee42e..4c59a5a531 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 @@ -82,6 +82,7 @@ class ImageRepository : IllustrationRepository { const val WHITE_ARROW_DOWN = "WHITE_ARROW_DOWN" const val DOUBLE_RIGHT_ARROW_BLUE = "DOUBLE_RIGHT_ARROW_BLUE" const val CONTACTS_ACCESS_GREY_BG = "CONTACTS_ACCESS_GREY_BG" + const val YELLOW_ARROW_DIAGONAL_RIGHT_UP = "YELLOW_ARROW_DIAGONAL_RIGHT_UP" } override fun getResourceId(illustrationName: String, isDarkThemeEnabled: Boolean): Int { @@ -292,6 +293,8 @@ class ImageRepository : IllustrationRepository { "https://public-assets.prod.navi-sa.in/money-manager/svg/category-details/ic_double_right_arrow_blue.svg" CONTACTS_ACCESS_GREY_BG -> "https://public-assets.prod.navi-sa.in/money-manager/svg/category-details/ic_contact_access_grey_bg.svg" + YELLOW_ARROW_DIAGONAL_RIGHT_UP -> + "https://public-assets.prod.navi-sa.in/money-manager/svg/common/ic_yellow_arrow_diagonal_right_up.svg" else -> EMPTY } } diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/CheckBalanceMMEntryPointData.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/CheckBalanceMMEntryPointData.kt new file mode 100644 index 0000000000..92b7d6e314 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/CheckBalanceMMEntryPointData.kt @@ -0,0 +1,46 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.model + +import androidx.compose.ui.graphics.Color +import com.navi.moneymanager.common.datasync.model.DataSyncState +import com.navi.moneymanager.common.model.database.AccountOverview + +data class SpendingTrend( + val text: SpendComparisonType, + val color: SpendTrendColor, + val percentChange: Int, + val iconUrl: String, +) + +data class SyncStatusBundle( + val isFirstMonthSynced: Boolean, + val isTotalSyncCompleted: Boolean, + val syncState: DataSyncState, + val linkedAccounts: List, +) + +enum class SpendComparisonType { + HIGHER, + LESS, + SIMILAR, +} + +enum class SpendTrendColor { + RED, + AMBER, + GREEN, +} + +fun SpendTrendColor.toColor(): Color { + return when (this) { + SpendTrendColor.RED -> Color(0xFFCA6100) + SpendTrendColor.AMBER -> Color(0xFF906D00) + SpendTrendColor.GREEN -> Color(0xFF1B8332) + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/DateInfo.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/DateInfo.kt new file mode 100644 index 0000000000..64dcf90dc8 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/model/DateInfo.kt @@ -0,0 +1,10 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.model + +data class DateInfo(val day: Int, val month: Int, val year: Int, val remainingDaysInMonth: Int) diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/MMWeeklHisaabResponse.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/MMWeeklHisaabResponse.kt new file mode 100644 index 0000000000..09cd508e45 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/network/model/MMWeeklHisaabResponse.kt @@ -0,0 +1,13 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.common.network.model + +data class MMWeeklHisaabResponse( + val isHisaabEnabled: Boolean?, + val isUserUPILoyalInPreviousMonth: Boolean?, +) 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 1b582a4df2..8e7350f8b3 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 @@ -19,6 +19,7 @@ import com.navi.moneymanager.common.network.model.EmptyRequestBody import com.navi.moneymanager.common.network.model.FetchConsentUrlResponse import com.navi.moneymanager.common.network.model.FinarkeinDataResponse import com.navi.moneymanager.common.network.model.MMConfigResponse +import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse import com.navi.moneymanager.common.network.model.OnboardingStatusResponse import com.navi.moneymanager.common.network.model.PollingStatusResponse import com.navi.moneymanager.common.network.model.RefreshDataResponse @@ -167,6 +168,10 @@ interface RetrofitService { @Query("configType") configType: String = BILL_CALENDAR ): Response> + @RetryPolicy + @GET("/money-manager/core/insights/hisaab") + suspend fun fetchMMWeeklyHisaabData(): Response> + private companion object { const val MONEY_MANAGER_INSIGHTS = "MONEY_MANAGER_INSIGHTS" const val MONEY_MANAGER = "MONEY_MANAGER" diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/checkbalance/MMWeeklyHisaabWidget.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/checkbalance/MMWeeklyHisaabWidget.kt new file mode 100644 index 0000000000..0be1affd85 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/checkbalance/MMWeeklyHisaabWidget.kt @@ -0,0 +1,379 @@ +/* + * + * * 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.border +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.wrapContentWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.navi.base.utils.EMPTY +import com.navi.base.utils.orZero +import com.navi.common.utils.CommonUtils.getDisplayableAmount +import com.navi.common.utils.PERCENT +import com.navi.common.utils.onClickWithDebounce +import com.navi.design.font.FontWeightEnum +import com.navi.design.font.getFontWeight +import com.navi.design.font.naviFontFamily +import com.navi.design.utils.BackgroundDrawableData +import com.navi.design.utils.DrawableShape +import com.navi.moneymanager.R +import com.navi.moneymanager.common.analytics.MMWeeklyHisaabEventTrackerImpl +import com.navi.moneymanager.common.illustration.model.IllustrationSource +import com.navi.moneymanager.common.illustration.model.IllustrationType +import com.navi.moneymanager.common.illustration.model.ImageProperties +import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.CHEVRON_PURPLE_RIGHT_ICON +import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM +import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_SMALL +import com.navi.moneymanager.common.illustration.ui.Illustration +import com.navi.moneymanager.common.model.SelectedMonth +import com.navi.moneymanager.common.model.SpendComparisonType +import com.navi.moneymanager.common.model.toColor +import com.navi.moneymanager.common.ui.composable.base.MMText +import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData +import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonData +import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonViewState +import com.navi.moneymanager.entry.model.checkbalance.SpendItemData +import com.navi.naviwidgets.extensions.CustomTooltip +import com.navi.naviwidgets.models.response.TooltipProperties +import com.navi.uitron.model.ui.PeakDirection +import com.navi.uitron.model.ui.PeakPosition +import com.navi.uitron.model.ui.ToolTipShapeData + +@Composable +fun MMWeeklyHisaabWidget( + data: MMWeeklyHisaabWidgetData, + currentBalance: String, + hideBalance: Boolean, + isBalanceFetchLoading: Boolean, + onSpendAmountClick: (selectedMonth: SelectedMonth?) -> Unit, + getEventTracker: () -> MMWeeklyHisaabEventTrackerImpl, +) { + LaunchedEffect(Unit) { getEventTracker().onMMHisaabWidgetVisible() } + Box( + modifier = + Modifier.fillMaxWidth() + .wrapContentHeight() + .background(Color(0xFFF5F5F5)) + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + Column( + modifier = + Modifier.fillMaxWidth() + .background(color = Color(0xFFFFFFFF), shape = MaterialTheme.shapes.extraSmall) + .border( + width = 1.dp, + color = Color(0xFFEBEBEB), + shape = MaterialTheme.shapes.extraSmall, + ) + .wrapContentHeight() + .padding(16.dp) + ) { + MMText( + text = stringResource(R.string.monthly_overview_title, data.monthName.orEmpty()), + fontSize = 14.sp, + fontWeight = FontWeightEnum.NAVI_BODY_DEMI_BOLD, + color = Color(0xFF000000), + lineHeight = 20.sp, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + SpendItem( + spendData = data.currentMonthSpend, + onSpendAmountClick = { selectedMonth -> + getEventTracker().onMMMRRCurrentMonthSpendsClicked() + onSpendAmountClick(selectedMonth) + }, + ) + when (data.spendComparison) { + is SpendComparisonViewState.Show -> { + LaunchedEffect(Unit) { + getEventTracker() + .onMMWeeklyHisaabBalanceInsight( + state = "show", + color = data.spendComparison.spendComparisonData.color.name, + ) + } + CustomTooltip( + properties = + TooltipProperties( + background = + BackgroundDrawableData( + shape = DrawableShape.RECTANGLE.name, + cornerRadius = 6, + peakAspectRatio = 2.1f, + backgroundColor = "#F7F6F8", + ), + tooltipShapeProperties = + ToolTipShapeData( + peakHeight = 8, + peakDirection = PeakDirection.TOP, + peakPosition = PeakPosition.CENTER, + peakRadius = 4, + bottomPeakRadius = 0, + offsetPercentage = 0.73f, + ), + ), + modifier = + Modifier.fillMaxWidth() + .wrapContentHeight() + .padding(top = 4.dp, bottom = 10.dp), + ) { + SpendComparisonBox( + data = data.spendComparison.spendComparisonData, + currentBalance = currentBalance, + hideBalance = hideBalance, + isBalanceFetchLoading = isBalanceFetchLoading, + onClicked = { selectedMonth -> + getEventTracker().onMMWeeklyHisaabBalanceInsightClicked() + onSpendAmountClick(selectedMonth) + }, + ) + } + } + + SpendComparisonViewState.Hide -> { + LaunchedEffect(Unit) { + getEventTracker() + .onMMWeeklyHisaabBalanceInsight(state = "hide", color = EMPTY) + } + Spacer(modifier = Modifier.height(12.dp)) + } + } + SpendItem( + spendData = data.previousMonthSpend, + onSpendAmountClick = { selectedMonth -> + getEventTracker().onMMMRRPreviousMonthSpendsClicked() + onSpendAmountClick(selectedMonth) + }, + ) + } + } +} + +@Composable +fun SpendItem( + spendData: SpendItemData?, + onSpendAmountClick: (selectedMonth: SelectedMonth?) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + MMText( + text = stringResource(R.string.spends), + fontSize = 12.sp, + fontWeight = FontWeightEnum.NAVI_BODY_REGULAR, + color = Color(0xFF141414), + lineHeight = 16.sp, + ) + Spacer(modifier = Modifier.width(4.dp)) + MMText( + text = + spendDateRangeText( + month = spendData?.monthName.orEmpty(), + currentDay = spendData?.currentDay.orZero(), + ), + fontSize = 12.sp, + fontWeight = FontWeightEnum.NAVI_HEADLINE_REGULAR, + color = Color(0xFF141414), + lineHeight = 16.sp, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.onClickWithDebounce( + indication = null, + onClick = { onSpendAmountClick(spendData?.selectedMonth) }, + ), + ) { + MMText( + text = spendData?.spendAmount.orEmpty(), + fontSize = 14.sp, + fontWeight = FontWeightEnum.NAVI_HEADLINE_REGULAR, + color = Color(0xFF191919), + lineHeight = 22.sp, + ) + Spacer(modifier = Modifier.width(4.dp)) + Illustration( + illustrationType = + IllustrationType.Image( + source = + IllustrationSource.Remote( + CHEVRON_PURPLE_RIGHT_ICON, + placeholder = IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM, + ) + ), + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +fun SpendComparisonBox( + data: SpendComparisonData, + currentBalance: String, + hideBalance: Boolean, + isBalanceFetchLoading: Boolean, + onClicked: (SelectedMonth) -> Unit, +) { + val percentChangeText = + getSpendChangeText(percentage = data.percentage, changeType = data.changeType) + + val monthComparisonText = + stringResource(R.string.spend_change_month_comparison, data.currentMonthName) + + val balanceLeftText = + getBalanceLeftText( + hideBalance = hideBalance, + currentBalance = currentBalance, + daysLeft = data.daysLeft, + currentMonthName = data.currentMonthName, + isBalanceFetchLoading = isBalanceFetchLoading, + ) + + Column( + modifier = + Modifier.fillMaxWidth() + .onClickWithDebounce(indication = null) { onClicked(data.selectedMonth) } + .padding(start = 6.dp, end = 6.dp, bottom = 9.dp, top = 17.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Illustration( + illustrationType = + IllustrationType.Image( + source = + IllustrationSource.Remote( + url = data.iconUrl, + placeholder = IMAGE_TRANSPARENT_PLACEHOLDER_SMALL, + ), + properties = + ImageProperties(colorFilter = ColorFilter.tint(data.color.toColor())), + ), + modifier = Modifier.size(16.dp), + ) + MMText( + text = + buildAnnotatedString { + withStyle( + SpanStyle( + color = data.color.toColor(), + fontSize = 14.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + ) + ) { + append(percentChangeText) + } + withStyle( + SpanStyle( + color = Color(0xFF1C1C1E), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), + ) + ) { + append(" ") + append(monthComparisonText) + } + }, + modifier = Modifier.wrapContentWidth().padding(start = 4.dp), + ) + } + MMText( + text = balanceLeftText, + color = data.color.toColor(), + fontSize = 12.sp, + fontStyle = FontStyle.Italic, + fontWeight = FontWeightEnum.NAVI_HEADLINE_REGULAR, + modifier = Modifier.wrapContentWidth().padding(start = 18.dp, top = 2.dp), + ) + } +} + +@Composable +fun spendDateRangeText(month: String, currentDay: Int): String { + return if (currentDay == 1) { + stringResource(id = R.string.spend_date_single, month, currentDay) + } else { + stringResource(id = R.string.spend_date_range, month, currentDay) + } +} + +@Composable +fun SpendComparisonType.toLocalizedString(): String { + return when (this) { + SpendComparisonType.HIGHER -> stringResource(id = R.string.higher) + SpendComparisonType.LESS -> stringResource(id = R.string.less) + SpendComparisonType.SIMILAR -> stringResource(id = R.string.similar_spends) + } +} + +@Composable +fun getBalanceLeftText( + hideBalance: Boolean, + isBalanceFetchLoading: Boolean, + currentBalance: String, + daysLeft: Int, + currentMonthName: String, +): String { + val balanceDisplay = + if (hideBalance || isBalanceFetchLoading) ". . . ." + else currentBalance.getDisplayableAmount() + val dayText = + pluralStringResource( + id = R.plurals.balance_day_left, + count = daysLeft, + formatArgs = arrayOf(daysLeft), + ) + + return stringResource(R.string.budget_remaining_info, balanceDisplay, dayText, currentMonthName) +} + +@Composable +fun getSpendChangeText(percentage: Int, changeType: SpendComparisonType): String { + return if (percentage == 0 && changeType == SpendComparisonType.SIMILAR) { + stringResource(R.string.similar_spends) + } else { + stringResource( + R.string.spend_change_percent_label, + "$percentage$PERCENT", + changeType.toLocalizedString(), + ) + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/upi/UpiSpendAnalyserWidgetContainer.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/upi/UpiSpendAnalyserWidgetContainer.kt index 944aa2bf86..8c4a9d4639 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/upi/UpiSpendAnalyserWidgetContainer.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/ui/composable/upi/UpiSpendAnalyserWidgetContainer.kt @@ -10,6 +10,7 @@ package com.navi.moneymanager.common.ui.composable.upi import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,7 +55,11 @@ fun UpiSpendAnalyserWidgetContainer(content: @Composable () -> Unit, onCrossIcon .background(color = Color(0xFFF9F9FA), shape = CircleShape) .align(Alignment.TopEnd) .clip(CircleShape) - .clickable(onClick = onCrossIconClick), + .clickable( + onClick = onCrossIconClick, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), ) { Illustration( illustrationType = 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 e2c1c1f6a4..2a2774dab3 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 @@ -116,6 +116,8 @@ object DbCacheConstants { const val MONEY_MANAGER_VALUE_PROPOSITION_SCREEN_KEY = "MONEY_MANAGER_VALUE_PROPOSITION_SCREEN_KEY" const val MONEY_MANAGER_CONFIG_RESPONSE_KEY = "MONEY_MANAGER_CONFIG_RESPONSE_KEY" + const val MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY = + "MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY" } object ScreenNameConstants { diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Utils.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Utils.kt index 1e192bae0b..064420337a 100644 --- a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Utils.kt +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/common/utils/Utils.kt @@ -7,9 +7,11 @@ package com.navi.moneymanager.common.utils +import android.app.Activity import android.content.Context import android.icu.util.Calendar import android.icu.util.TimeZone +import android.os.Bundle import android.view.Gravity import android.widget.Toast import androidx.compose.foundation.interaction.MutableInteractionSource @@ -51,6 +53,7 @@ import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.navi.base.deeplink.DeepLinkManager import com.navi.base.model.CtaData import com.navi.base.model.LineItem import com.navi.base.utils.BaseUtils.getDayMonthAndYearFromTimestamp @@ -77,6 +80,8 @@ import com.navi.moneymanager.common.illustration.model.IllustrationType import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_SMALL import com.navi.moneymanager.common.model.ChipData import com.navi.moneymanager.common.model.ChipIllustration +import com.navi.moneymanager.common.model.DateInfo +import com.navi.moneymanager.common.model.SelectedMonth import com.navi.moneymanager.common.model.SingleChipSelectionData import com.navi.moneymanager.common.model.SpendGoalState import com.navi.moneymanager.common.model.sectionHeader.MMJourneySource @@ -89,6 +94,8 @@ import com.navi.moneymanager.common.utils.Constants.PRODUCT_HELP_PAGE import com.navi.moneymanager.common.utils.Constants.PRODUCT_HELP_SCREEN_NAME import com.navi.moneymanager.common.utils.Constants.REDIRECTION_CTA import com.navi.moneymanager.common.utils.Constants.RUPEE_SYMBOL +import com.navi.moneymanager.common.utils.Constants.SELECTED_MONTH +import com.navi.moneymanager.common.utils.Constants.SOURCE import com.navi.moneymanager.common.utils.MonthConstants.monthNames import com.navi.moneymanager.preonboard.launcher.model.GenericErrorBottomSheetData import java.text.NumberFormat @@ -580,3 +587,72 @@ fun getNormalisedPhoneNumber(phoneNumber: String?): String { return phoneNumber.filter { it.isAsciiDigit() }.takeLast(n = 10) } + +fun getDateInfoFromTimestamp(timeStamp: Long): DateInfo { + val calendar = Calendar.getInstance() + calendar.timeInMillis = timeStamp + + val day = calendar.get(Calendar.DAY_OF_MONTH) + val month = calendar.get(Calendar.MONTH) // 0-based + val year = calendar.get(Calendar.YEAR) + + val maxDaysInMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH) + val remainingDays = maxDaysInMonth - day + 1 + + return DateInfo(day = day, month = month, year = year, remainingDaysInMonth = remainingDays) +} + +fun navigateToUpiSpendAnalysisScreen( + activity: Activity, + screenName: String, + selectedMonth: SelectedMonth, + bundle: Bundle?, +) { + val additionalData = bundle ?: Bundle() + additionalData.putParcelable(SELECTED_MONTH, selectedMonth) + DeepLinkManager.getDeepLinkListener() + ?.navigateTo( + activity = activity, + ctaData = + CtaData( + url = screenName, + parameters = + listOf(LineItem(key = SOURCE, value = MMJourneySource.NAVI_UPI.name)), + ), + bundle = additionalData, + finish = false, + ) +} + +fun getPreviousMonthDayLimit(dateInfo: DateInfo): Int { + val calendar = Calendar.getInstance() + + val currentDay = dateInfo.day + val currentMonth = dateInfo.month + val currentYear = dateInfo.year + + // Set calendar to current date + calendar.set(currentYear, currentMonth, currentDay) + + // Check if the current day is the last day of the current month + val isLastDayOfCurrentMonth = currentDay == calendar.getActualMaximum(Calendar.DAY_OF_MONTH) + + // Calculate previous month and year + val previousMonth = if (currentMonth == 0) 11 else currentMonth - 1 + val previousYear = if (currentMonth == 0) currentYear - 1 else currentYear + + // Set calendar to the first day of the previous month to calculate its maximum days + calendar.set(previousYear, previousMonth, 1) + val maxDayInPreviousMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH) + + // Return the appropriate day limit for the previous month: + // - If the current day is the last day of the current month, + // or if it exceeds the number of days in the previous month (e.g., March 30 vs. Feb 28), + // use the last day of the previous month. + // - Otherwise, use the same day as in the current month. + return if (isLastDayOfCurrentMonth || currentDay > maxDayInPreviousMonth) { + maxDayInPreviousMonth + } else { + currentDay + } +} diff --git a/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/entry/model/checkbalance/MMWeeklyHisaabWidgetData.kt b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/entry/model/checkbalance/MMWeeklyHisaabWidgetData.kt new file mode 100644 index 0000000000..70e7c23e44 --- /dev/null +++ b/android/navi-money-manager/src/main/kotlin/com/navi/moneymanager/entry/model/checkbalance/MMWeeklyHisaabWidgetData.kt @@ -0,0 +1,46 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.moneymanager.entry.model.checkbalance + +import com.navi.moneymanager.common.model.SelectedMonth +import com.navi.moneymanager.common.model.SpendComparisonType +import com.navi.moneymanager.common.model.SpendTrendColor + +data class MMWeeklyHisaabWidgetData( + val monthName: String? = null, + val currentMonthSpend: SpendItemData? = null, + val spendComparison: SpendComparisonViewState, + val previousMonthSpend: SpendItemData? = null, +) + +data class SpendItemData( + val monthName: String?, + val currentDay: Int, + val spendAmount: String?, + val selectedMonth: SelectedMonth, +) + +data class SpendComparisonData( + val percentage: Int, + val changeType: SpendComparisonType, + val currentMonthName: String, + val daysLeft: Int, + val iconUrl: String, + val color: SpendTrendColor, + val selectedMonth: SelectedMonth, +) + +sealed class SpendComparisonViewState { + // Show the view with actual spend comparison data + data class Show(val spendComparisonData: SpendComparisonData) : SpendComparisonViewState() + + // Hide the view when: + // 1. Either current or previous month spend is 0 + // 2. User was not loyal last month + object Hide : SpendComparisonViewState() +} 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 2cb860ae01..008bdbe85a 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 @@ -10,7 +10,9 @@ package com.navi.moneymanager.postonboard.dashboard.repo import androidx.compose.runtime.MutableState import com.navi.base.utils.EMPTY import com.navi.base.utils.orZero +import com.navi.common.network.models.isSuccess import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMConfigResponseHelper +import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMWeeklyHisaabConfigResponseHelper import com.navi.moneymanager.common.dataprovider.data.datastore.DataStoreInfoProvider import com.navi.moneymanager.common.dataprovider.domain.DashboardDataProvider import com.navi.moneymanager.common.dataprovider.domain.RemoteDataProvider @@ -24,6 +26,7 @@ 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.network.model.MMConfigResponse +import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse import com.navi.moneymanager.common.utils.Constants.USER_NAME import com.navi.moneymanager.postonboard.dashboard.model.BankAccountsState import com.navi.moneymanager.postonboard.dashboard.model.FeedbackSectionData @@ -46,6 +49,7 @@ constructor( private val database: Lazy, private val upiSpendDatabase: Lazy, private val mmConfigResponseHelper: MMConfigResponseHelper, + private val mmWeeklyHisaabConfigResponseHelper: MMWeeklyHisaabConfigResponseHelper, @RoomDataStoreInfoProvider private val dbDataStoreProvider: DataStoreInfoProvider, ) { @@ -210,4 +214,19 @@ constructor( } } } + + suspend fun fetchAndSaveWeeklyHisaabConfigResponse( + screenName: String, + onApiFailed: (Int?, String?) -> Unit, + ): MMWeeklHisaabResponse? { + val networkResponse = remoteDataProvider.fetchMMWeeklyHisaabData(screenName = screenName) + val data = networkResponse.data + return if (networkResponse.isSuccess() && data != null) { + mmWeeklyHisaabConfigResponseHelper.saveWeeklyHisaabConfigToDB(data) + mmWeeklyHisaabConfigResponseHelper.getWeeklyHisaabConfig() + } else { + onApiFailed(networkResponse.statusCode, networkResponse.error?.message) + null + } + } } 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 cdc11cedbe..f68aa2de75 100644 --- a/android/navi-money-manager/src/main/res/values/strings.xml +++ b/android/navi-money-manager/src/main/res/values/strings.xml @@ -267,4 +267,18 @@ Check spend analysis Your spend analysis for %1$s Add contacts to sync contact names automatically + %1$s overview + Spends + %1$s %2$s + in %1$s vs last month + \u20B9%1$s left for %2$s in %3$s + (%1$s %2$d) + (%1$s 1 - %2$d) + less + higher + Similar spends + + %d day + %d days + 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 b2e91021c5..da658b1ef0 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 @@ -78,11 +78,17 @@ 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.analytics.MMWeeklyHisaabEventTrackerImpl +import com.navi.moneymanager.common.model.SelectedMonth +import com.navi.moneymanager.common.navigation.utils.MMScreen import com.navi.moneymanager.common.ui.composable.checkbalance.CheckBalanceMMEntryPointWidget +import com.navi.moneymanager.common.ui.composable.checkbalance.MMWeeklyHisaabWidget import com.navi.moneymanager.common.utils.Constants.BANK_FIP_ID import com.navi.moneymanager.common.utils.Constants.MASKED_ACCOUNT_NUMBER +import com.navi.moneymanager.common.utils.navigateToUpiSpendAnalysisScreen import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointState +import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData import com.navi.naviwidgets.extensions.NaviText import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics @@ -172,12 +178,22 @@ fun LinkedAccountBalanceScreen( val showMMDiscoverabilityEntryPoint by linkedAccountBalanceViewModel.showMMDiscoverabilityEntryPoint.collectAsStateWithLifecycle() - DisposableEffect(lifecycleOwner, showMMDiscoverabilityEntryPoint) { + val showMMWeeklyHisaabWidget by + linkedAccountBalanceViewModel.showMMWeeklyHisaabWidget.collectAsStateWithLifecycle() + + DisposableEffect(lifecycleOwner, showMMDiscoverabilityEntryPoint, showMMWeeklyHisaabWidget) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && showMMDiscoverabilityEntryPoint) { - linkedAccountBalanceViewModel.mmDiscoverabilityEventTracker - .onMMDiscoverabilityEntryPointLoadTriggered() - linkedAccountBalanceViewModel.loadMMEntryPoint(linkedAccountEntity) + if (event == Lifecycle.Event.ON_RESUME) { + when { + showMMDiscoverabilityEntryPoint -> { + linkedAccountBalanceViewModel.mmDiscoverabilityEventTracker + .onMMDiscoverabilityEntryPointLoadTriggered() + linkedAccountBalanceViewModel.loadMMEntryPoint(linkedAccountEntity) + } + showMMWeeklyHisaabWidget -> { + linkedAccountBalanceViewModel.loadMMWeeklyHisaabWidget() + } + } } } lifecycleOwner.lifecycle.addObserver(observer) @@ -202,6 +218,9 @@ fun LinkedAccountBalanceScreen( val mmEntryPointData by linkedAccountBalanceViewModel.mmEntryPointData.collectAsStateWithLifecycle() + val weeklyHisaabWidgetData by + linkedAccountBalanceViewModel.mmWeeklyHisaabWidgetData.collectAsStateWithLifecycle() + val bottomSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, @@ -321,6 +340,8 @@ fun LinkedAccountBalanceScreen( bankAccountId = linkedAccountEntity.accountId, ) }, + weeklyHisaabWidgetData = weeklyHisaabWidgetData, + mmWeeklyHisaabEventTrackerImpl = linkedAccountBalanceViewModel.mmWeeklyHisaabEventTracker, ) } @@ -341,6 +362,8 @@ private fun RenderLinkedAccountBalanceScreen( mmEntryPointData: CheckBalanceMMEntryPointData, mmDiscoverabilityEventTracker: MMDiscoverabilityEventTrackerImpl, redirectOnClick: (CtaData) -> Unit, + weeklyHisaabWidgetData: MMWeeklyHisaabWidgetData?, + mmWeeklyHisaabEventTrackerImpl: MMWeeklyHisaabEventTrackerImpl, ) { val context = LocalContext.current as Activity @@ -377,6 +400,36 @@ private fun RenderLinkedAccountBalanceScreen( mmDiscoverabilityEventTracker = mmDiscoverabilityEventTracker, ) + AnimatedVisibility( + visible = weeklyHisaabWidgetData != null, + 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)), + ) { + weeklyHisaabWidgetData?.let { + MMWeeklyHisaabWidget( + currentBalance = currentBalance, + data = it, + hideBalance = hideBalance, + isBalanceFetchLoading = showShimmer, + getEventTracker = { mmWeeklyHisaabEventTrackerImpl }, + onSpendAmountClick = { selectedMonth -> + navigateToUpiSpendAnalysisScreen( + activity = activity, + screenName = MMScreen.DASHBOARD.screen, + selectedMonth = selectedMonth ?: SelectedMonth(0, 0), + bundle = null, + ) + }, + ) + } + } + TransactionEntryPointCard( transactionEntrypointData = transactionEntrypointData, accountType = linkedAccountEntity.accountType, 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 8e534fc194..3b7196d1c0 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 @@ -17,16 +17,22 @@ 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.firebaseremoteconfig.FirebaseRemoteConfigHelper.MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS +import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.MM_WEEKLY_HISAAB_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.analytics.MMWeeklyHisaabEventTrackerImpl import com.navi.moneymanager.common.datasync.MMDiscoverabilitySyncPreviousDataUseCase +import com.navi.moneymanager.common.datasync.UpiSpendDBSyncExecutor +import com.navi.moneymanager.common.datasync.model.DataSyncState 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.entry.model.checkbalance.MMWeeklyHisaabWidgetData import com.navi.moneymanager.postonboard.dashboard.repo.DashboardScreenRepository import com.navi.pay.R import com.navi.pay.analytics.NaviPayAnalytics @@ -62,16 +68,20 @@ import com.ramcosta.composedestinations.spec.Direction import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull @HiltViewModel class LinkedAccountBalanceViewModel @@ -89,6 +99,7 @@ constructor( private val checkBalanceMMWidgetHelper: CheckBalanceMMWidgetHelper, private val syncPreviousDataUseCase: MMDiscoverabilitySyncPreviousDataUseCase, private val dashboardRepository: DashboardScreenRepository, + private val upiSpendDBSyncExecutor: UpiSpendDBSyncExecutor, ) : NaviPayBaseVM() { private val linkedAccountEntity: LinkedAccountEntity = savedStateHandle["linkedAccountEntity"]!! @@ -120,6 +131,9 @@ constructor( ) val mmEntryPointData = _mmEntryPointData.asStateFlow() + private val _mmWeeklyHisaabWidgetData = MutableStateFlow(null) + val mmWeeklyHisaabWidgetData = _mmWeeklyHisaabWidgetData.asStateFlow() + private val source: String = savedStateHandle["source"] ?: "" private val linkedAccountBalanceScreenSource: LinkedAccountBalanceScreenSource = @@ -178,6 +192,9 @@ constructor( private val _showMMDiscoverabilityEntryPoint = MutableStateFlow(false) val showMMDiscoverabilityEntryPoint = _showMMDiscoverabilityEntryPoint.asStateFlow() + private val _showMMWeeklyHisaabWidget = MutableStateFlow(false) + val showMMWeeklyHisaabWidget = _showMMWeeklyHisaabWidget.asStateFlow() + val helpCta = getHelpCtaData(screenName = screenName) private var timerJob: Job? = null @@ -188,6 +205,12 @@ constructor( ) } + val mmWeeklyHisaabEventTracker by lazy { + MMWeeklyHisaabEventTrackerImpl( + parameters = mapOf(JOURNEY_SOURCE to MMJourneySource.NAVI_UPI.name) + ) + } + init { naviPayAnalytics.onCheckBalanceSuccess(source = source) viewModelScope.launch(Dispatchers.IO) { @@ -205,42 +228,99 @@ constructor( } fetchTransactionEntrypointConfig(screenName = screenName) runCountDownTimer() - checkAndShowMMDiscoverabilityEntryPoint() + checkAndShowMMEntryPoint() } - private fun checkAndShowMMDiscoverabilityEntryPoint() { - viewModelScope.safeLaunch(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" - ) + @OptIn(DelicateCoroutinesApi::class) + private fun checkAndShowMMEntryPoint() { + if (upiTxnsyncJob?.isActive == true) { + // Skip if a global sync job is already running + mmWeeklyHisaabEventTracker.onUpiSyncSkippedAlreadyRunning() + return + } + upiTxnsyncJob = + GlobalScope.safeLaunch(Dispatchers.IO) { + val isWeeklyHisaabEnabledViaRemoteConfig = + FirebaseRemoteConfigHelper.getBoolean(MM_WEEKLY_HISAAB_ENABLED) + if (!isWeeklyHisaabEnabledViaRemoteConfig) { + mmWeeklyHisaabEventTracker.onMMWeeklyHisaabDisabledViaRemoteConfig() + checkMMDiscoverability() return@safeLaunch } - mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointEligible() - _showMMDiscoverabilityEntryPoint.value = true - } else { - val reason = - if (accountType != AccountType.SAVINGS) "non_savings_account" - else "experiment_disabled" - mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible(reason) - return@safeLaunch + val accountType = AccountType.getAccountType(linkedAccountEntity.accountType) + withTimeoutOrNull( + FirebaseRemoteConfigHelper.getLong( + MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS, + 10000L, + ) + ) { + val configResponse = + dashboardRepository.fetchAndSaveWeeklyHisaabConfigResponse( + screenName = screenName, + onApiFailed = { statusCode, errorMessage -> + mmWeeklyHisaabEventTracker.onMMWeeklyHisaabConfigApiFailed( + statusCode = statusCode.toString(), + errorMessage = errorMessage.orEmpty(), + ) + }, + ) + configResponse?.let { response -> + val isHisaabEnabled = response.isHisaabEnabled == true + val isPrimaryAccount = linkedAccountEntity.isAccountPrimary + val isSavingsAccount = accountType == AccountType.SAVINGS + if (isHisaabEnabled && isPrimaryAccount && isSavingsAccount) { + mmWeeklyHisaabEventTracker.onMMWeeklyHisaablitmusEnabled() + upiSpendDBSyncExecutor.execute(screenName).collectLatest { status -> + if (status == DataSyncState.Completed) { + _showMMWeeklyHisaabWidget.value = true + } + } + return@withTimeoutOrNull true + } else { + val reason = + when { + !isHisaabEnabled -> "litmus_disabled" + !isPrimaryAccount -> "not_primary_account" + else -> "not_savings_account" + } + mmWeeklyHisaabEventTracker.onMMWeeklyHisaabLitmusDisabled(reason) + checkMMDiscoverability() + return@withTimeoutOrNull false + } + } + return@withTimeoutOrNull false + } } + } + + private suspend fun checkMMDiscoverability() { + val experimentEnabled = isMMDiscoverabilityExperimentEnabled() + val accountType = AccountType.getAccountType(linkedAccountEntity.accountType) + + 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 + } + mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointEligible() + _showMMDiscoverabilityEntryPoint.value = true + } else { + val reason = + if (accountType != AccountType.SAVINGS) "non_savings_account" + else "experiment_disabled" + mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible(reason) + return } } @@ -260,6 +340,15 @@ constructor( } } + fun loadMMWeeklyHisaabWidget() { + viewModelScope.safeLaunch(Dispatchers.IO) { + checkBalanceMMWidgetHelper + .getMMWeeklyHisaabWidgetData() + .distinctUntilChanged() // emits only when the data actually changes + .collect { data -> _mmWeeklyHisaabWidgetData.update { data } } + } + } + private suspend fun isMMDiscoverabilityExperimentEnabled() = FirebaseRemoteConfigHelper.getBoolean(MM_DISCOVERABILITY_ENABLED) && checkBalanceMMWidgetHelper.isMMDiscoverabilityExperimentEnabled() && @@ -537,6 +626,10 @@ constructor( override val screenName: String get() = NAVI_PAY_CHECK_BALANCE_SCREEN + + companion object { + private var upiTxnsyncJob: Job? = null + } } sealed class LinkedAccountBalanceScreenBottomSheetUIState { diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/ui/OrderHistoryScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/ui/OrderHistoryScreen.kt index 131525142a..67bc594c26 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/ui/OrderHistoryScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/ui/OrderHistoryScreen.kt @@ -90,6 +90,7 @@ import com.navi.base.utils.EMPTY import com.navi.common.R as CommonR import com.navi.common.utils.clickableDebounce import com.navi.common.utils.navigateUp +import com.navi.common.utils.onClickWithDebounce import com.navi.design.font.FontWeightEnum import com.navi.design.font.getFontWeight import com.navi.design.font.naviFontFamily @@ -1034,7 +1035,10 @@ fun NewMonthView( color = NaviPayColor.textPrimary, ) Row( - modifier = Modifier.wrapContentWidth().clickableDebounce { onInfoIconClick?.invoke() }, + modifier = + Modifier.wrapContentWidth().onClickWithDebounce(indication = null) { + onInfoIconClick?.invoke() + }, verticalAlignment = Alignment.CenterVertically, ) { NaviText( diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/viewmodel/OrderHistoryViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/viewmodel/OrderHistoryViewModel.kt index 3b92b2dcc6..1699ec9b5c 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/viewmodel/OrderHistoryViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/tstore/list/viewmodel/OrderHistoryViewModel.kt @@ -525,10 +525,10 @@ constructor( if (upiSpendAnalysisWidgetHelper.isTotalSyncCompleted()) { upiSpendAnalysisFirstSyncCompleted.update { true } } - - val dataSyncState = upiDBSyncExecutor.execute(screenName) - if (dataSyncState is DataSyncState.Completed) { - isUpiSpendAnalysisSyncCompleted.update { true } + upiDBSyncExecutor.execute(screenName).collect { state -> + if (state == DataSyncState.Completed) { + isUpiSpendAnalysisSyncCompleted.update { true } + } } } }