TP-59832 | Mehul | Biller list caching E2E (#10255)

This commit is contained in:
Mehul Garg
2024-04-02 15:24:27 +05:30
committed by GitHub
parent 441dd07957
commit 1822ce6da8
27 changed files with 585 additions and 182 deletions

View File

@@ -16,9 +16,12 @@ internal const val BBPS_CATEGORY_TITLE = "BBPS_CATEGORY_TITLE"
internal const val BBPS_CATEGORY_ICON_URL = "BBPS_CATEGORY_ICON_URL"
internal const val BBPS_BILLER_SEARCH_BOX_PLACEHOLDER_TEXT =
"BBPS_BILLER_SEARCH_BOX_PLACEHOLDER_TEXT"
internal const val BBPS_BILLER_LIST_STATE_HEADING = "BBPS_BILLER_LIST_STATE_HEADING"
internal const val BBPS_BILLER_LIST_ALL_HEADING = "BBPS_BILLER_LIST_ALL_HEADING"
// Values
const val CONFIG_IN_DB_REFRESH_MIN_TIMESTAMP = 86400000L // 1 day
const val BILLER_LIST_REFRESH_TIMESTAMP = 86400000L // 1 day
// Date time format
const val DATE_TIME_FORMAT_DATE_MONTH_NAME_YEAR = "dd MMM yyyy"
@@ -61,6 +64,8 @@ const val ICON_FASTAG_BOTTOMSHEET =
// Preference Keys
const val KEY_CONFIG_DB_LAST_REFRESHED_TIMESTAMP = "KEY_CONFIG_DB_LAST_REFRESHED_TIMESTAMP"
const val KEY_BBPS_MY_BILLS_DB_LAST_REFRESHED_TIMESTAMP =
"KEY_BBPS_MY_BILLS_DB_LAST_REFRESHED_TIMESTAMP"
// Fixed values
const val NOTES_REGEX_CONDITION = "^[a-zA-Z0-9 \\-]*\$"

View File

@@ -0,0 +1,51 @@
/*
*
* * Copyright © 2024 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.bbps.common.model
import com.navi.bbps.common.usecase.BbpsRefreshConfigUseCase
import com.navi.bbps.common.usecase.BillerListUseCase
import com.navi.bbps.feature.mybills.MyBillsSyncJob
import com.navi.common.di.CoroutineDispatcherProvider
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
class NaviBbpsManager
@Inject
constructor(
private val dispatcherProvider: CoroutineDispatcherProvider,
private val billerListUseCase: BillerListUseCase,
private val bbpsRefreshConfigUseCase: BbpsRefreshConfigUseCase,
private val myBillsSyncJob: MyBillsSyncJob,
) {
private var isInitInProgress = false
fun init() {
CoroutineScope(dispatcherProvider.io).launch {
if (isInitInProgress) {
return@launch
}
isInitInProgress = true
myBillsSyncJob.refreshBillsAsync()
val taskList = mutableListOf<Deferred<Unit>>()
taskList.add(async { myBillsSyncJob.refreshBills() })
taskList.add(async { bbpsRefreshConfigUseCase.execute() })
taskList.add(async { billerListUseCase.execute() })
taskList.awaitAll()
isInitInProgress = false
}
}
}

View File

@@ -7,6 +7,9 @@
package com.navi.bbps.common.repository
import com.navi.bbps.feature.billerlist.dao.BillerListDao
import com.navi.bbps.feature.billerlist.model.network.BillerItemListResponse
import com.navi.bbps.feature.billerlist.model.view.BillerItemEntity
import com.navi.bbps.feature.customerinput.model.network.BillDetailsRequest
import com.navi.bbps.feature.customerinput.model.network.BillDetailsResponse
import com.navi.bbps.feature.mybills.MyBillsSyncJob
@@ -20,7 +23,8 @@ class BbpsCommonRepository
@Inject
constructor(
private val naviBbpsRetrofitService: NaviBbpsRetrofitService,
private val myBillsSyncJob: MyBillsSyncJob
private val myBillsSyncJob: MyBillsSyncJob,
private val billerListDao: BillerListDao
) : ResponseCallback() {
suspend fun fetchBillDetails(
@@ -41,4 +45,20 @@ constructor(
naviBbpsRetrofitService.getConfig(commaSeparatedConfigKeys = commaSeparatedConfigKeys)
)
}
suspend fun fetchAllBillers(): RepoResult<BillerItemListResponse> {
return apiResponseCallback(naviBbpsRetrofitService.getFullBillerList())
}
suspend fun getBillersFromLocalDb(): List<BillerItemEntity> {
return billerListDao.getAllBillers()
}
suspend fun refreshBillers(billers: List<BillerItemEntity>) {
billerListDao.refreshBillers(billers)
}
suspend fun getBillersByCategoryFromLocalDb(categoryId: String): List<BillerItemEntity> {
return billerListDao.getBillersByCategory(categoryId)
}
}

View File

@@ -0,0 +1,66 @@
/*
*
* * Copyright © 2024 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.bbps.common.usecase
import com.navi.bbps.common.BILLER_LIST_REFRESH_TIMESTAMP
import com.navi.bbps.common.BbpsSharedPreferences
import com.navi.bbps.common.KEY_BBPS_MY_BILLS_DB_LAST_REFRESHED_TIMESTAMP
import com.navi.bbps.common.repository.BbpsCommonRepository
import com.navi.bbps.feature.billerlist.BillerItemResponseToEntityMapper
import com.navi.bbps.feature.billerlist.model.view.BillerItemEntity
import javax.inject.Inject
import org.joda.time.DateTime
class BillerListUseCase
@Inject
constructor(
private val bbpsCommonRepository: BbpsCommonRepository,
private val bbpsSharedPreferences: BbpsSharedPreferences,
private val billerItemResponseToEntityMapper: BillerItemResponseToEntityMapper
) {
suspend fun execute() {
val existingBillers = bbpsCommonRepository.getBillersFromLocalDb()
val lastFetchBillerListTimeStamp =
bbpsSharedPreferences.getLong(
key = KEY_BBPS_MY_BILLS_DB_LAST_REFRESHED_TIMESTAMP,
defValue = -1L
)
val shouldFetchBillerList =
existingBillers.isEmpty() ||
(DateTime.now().millis - lastFetchBillerListTimeStamp >
BILLER_LIST_REFRESH_TIMESTAMP)
if (shouldFetchBillerList) {
val billerListResponse = bbpsCommonRepository.fetchAllBillers()
val billers = billerListResponse.data?.billers.orEmpty()
if (billers.isNullOrEmpty()) {
return
}
refreshBillers(
billers =
billerItemResponseToEntityMapper.mapBillerItemResponseToEntityList(
billers = billers
)
)
bbpsSharedPreferences.saveLong(
key = KEY_BBPS_MY_BILLS_DB_LAST_REFRESHED_TIMESTAMP,
value = System.currentTimeMillis()
)
}
}
private suspend fun refreshBillers(billers: List<BillerItemEntity>) {
bbpsCommonRepository.refreshBillers(billers)
}
}

View File

@@ -10,19 +10,48 @@ package com.navi.bbps.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_DATABASE_BILLERS
import com.navi.bbps.db.converter.PaymentAmountExactnessConverter
import com.navi.bbps.db.converter.StringMapConverter
import com.navi.bbps.feature.billerlist.dao.BillerListDao
import com.navi.bbps.feature.billerlist.model.view.BillerItemEntity
import com.navi.bbps.feature.mybills.MyBillsDao
import com.navi.bbps.feature.mybills.model.view.MyBillEntity
@Database(entities = [MyBillEntity::class], version = 1, exportSchema = false)
@Database(
entities = [MyBillEntity::class, BillerItemEntity::class],
version = 2,
exportSchema = false
)
@TypeConverters(PaymentAmountExactnessConverter::class, StringMapConverter::class)
abstract class NaviBbpsAppDatabase : RoomDatabase() {
abstract fun myBillsDao(): MyBillsDao
abstract fun billerListDao(): BillerListDao
companion object {
const val NAVI_BBPS_DATABASE_NAME = "navi-bbps.db"
const val NAVI_BBPS_TABLE_MY_SAVED_BILLS = "my_saved_bills"
const val NAVI_BBPS_DATABASE_BILLERS = "billers"
}
}
val NAVI_BBPS_DATABASE_MIGRATION_1_2 =
object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Create the new table for BillerItemEntity
database.execSQL(
"CREATE TABLE IF NOT EXISTS $NAVI_BBPS_DATABASE_BILLERS (" +
"`billerId` TEXT NOT NULL PRIMARY KEY, " +
"`billerName` TEXT NOT NULL, " +
"`billerLogoUrl` TEXT, " +
"`status` TEXT NOT NULL, " +
"`isAdhoc` INTEGER NOT NULL, " +
"`state` TEXT NOT NULL, " +
"`categoryId` TEXT NOT NULL)"
)
}
}

View File

@@ -12,6 +12,7 @@ import androidx.room.Room
import com.navi.bbps.common.BbpsSharedPreferences
import com.navi.bbps.common.utils.BbpsSessionCache
import com.navi.bbps.common.utils.BbpsSessionCacheImpl
import com.navi.bbps.db.NAVI_BBPS_DATABASE_MIGRATION_1_2
import com.navi.bbps.db.NaviBbpsAppDatabase
import dagger.Binds
import dagger.Module
@@ -34,12 +35,19 @@ class NaviBbpsDbModule {
NaviBbpsAppDatabase::class.java,
NaviBbpsAppDatabase.NAVI_BBPS_DATABASE_NAME
)
.addMigrations(NAVI_BBPS_DATABASE_MIGRATION_1_2)
.fallbackToDestructiveMigration()
.build()
@ActivityRetainedScoped
@Provides
fun providesMyBillsDao(naviBbpsAppDatabase: NaviBbpsAppDatabase) =
naviBbpsAppDatabase.myBillsDao()
@ActivityRetainedScoped
@Provides
fun providesBillerListDao(naviBbpsAppDatabase: NaviBbpsAppDatabase) =
naviBbpsAppDatabase.billerListDao()
}
@Module

View File

@@ -7,31 +7,19 @@
package com.navi.bbps.entry
import androidx.lifecycle.viewModelScope
import com.navi.bbps.common.NaviBbpsScreen
import com.navi.bbps.common.model.NaviBbpsManager
import com.navi.bbps.common.model.NaviBbpsVmData
import com.navi.bbps.common.usecase.BbpsRefreshConfigUseCase
import com.navi.bbps.common.viewmodel.NaviBbpsBaseVM
import com.navi.bbps.feature.mybills.MyBillsSyncJob
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
@HiltViewModel
class NaviBbpsMainViewModel
@Inject
constructor(
private val bbpsRefreshConfigUseCase: BbpsRefreshConfigUseCase,
private val myBillsSyncJob: MyBillsSyncJob
) : NaviBbpsBaseVM(naviBbpsVmData = NaviBbpsVmData(screen = NaviBbpsScreen.NAVI_BBPS_MAIN)) {
class NaviBbpsMainViewModel @Inject constructor(private val naviBbpsManager: NaviBbpsManager) :
NaviBbpsBaseVM(naviBbpsVmData = NaviBbpsVmData(screen = NaviBbpsScreen.NAVI_BBPS_MAIN)) {
init {
refreshConfig()
}
private fun refreshConfig() {
viewModelScope.safeLaunch(Dispatchers.IO) { bbpsRefreshConfigUseCase.execute() }
myBillsSyncJob.refreshBillsAsync()
naviBbpsManager.init()
}
fun getDefaultScreenName() = ""

View File

@@ -0,0 +1,30 @@
/*
*
* * Copyright © 2024 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.bbps.feature.billerlist
import com.navi.bbps.feature.billerlist.model.network.BillerItemResponse
import com.navi.bbps.feature.billerlist.model.view.BillerItemEntity
import javax.inject.Inject
class BillerItemResponseToEntityMapper @Inject constructor() {
fun mapBillerItemResponseToEntityList(
billers: List<BillerItemResponse>
): List<BillerItemEntity> {
return billers.map { response ->
BillerItemEntity(
billerId = response.billerId,
billerName = response.billerName,
billerLogoUrl = response.billerLogoUrl,
status = response.status ?: "",
isAdhoc = response.isAdhoc ?: false,
state = response.state ?: "",
categoryId = response.categoryId
)
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.navi.base.utils.BaseUtils
import com.navi.base.utils.NaviNetworkConnectivity
import com.navi.bbps.R
import com.navi.bbps.common.CATEGORY_ID_FASTAG
import com.navi.bbps.common.CATEGORY_ID_MOBILE_POSTPAID
import com.navi.bbps.common.DATE_TIME_FORMAT_DATE_MONTH_NAME_YEAR
@@ -40,6 +41,7 @@ import com.navi.bbps.feature.customerinput.model.network.DeviceDetails
import com.navi.bbps.feature.customerinput.model.view.BillDetailsEntity
import com.navi.bbps.feature.customerinput.model.view.BillerDetailsEntity
import com.navi.bbps.feature.destinations.PayBillScreenDestination
import com.navi.bbps.feature.mybills.MyBillsRepository
import com.navi.bbps.feature.paybill.model.network.PaymentAmountExactness
import com.navi.bbps.feature.paybill.model.view.PayBillSource
import com.navi.common.di.CoroutineDispatcherProvider
@@ -78,6 +80,7 @@ constructor(
private val bbpsCommonRepository: BbpsCommonRepository,
private val naviNetworkConnectivity: NaviNetworkConnectivity,
private val naviBbpsDateUtils: NaviBbpsDateUtils,
private val myBillsRepository: MyBillsRepository,
private val deviceLocationProvider: DeviceLocationProvider
) : NaviBbpsBaseVM(naviBbpsVmData = NaviBbpsVmData(screen = NaviBbpsScreen.NAVI_BBPS_BILLER_LIST)) {
@@ -95,6 +98,9 @@ constructor(
private val _navigateToNextScreen = MutableSharedFlow<Direction>()
val navigateToNextScreen = _navigateToNextScreen.asSharedFlow()
private val _isPayBillShimmerVisible = MutableStateFlow(false)
val isPayBillShimmerVisible = _isPayBillShimmerVisible.asStateFlow()
val phoneNumber = BaseUtils.getPhoneNumber().toString()
private val _myNumberState =
@@ -151,6 +157,14 @@ constructor(
initSearchFlow()
}
private fun replaceIfPresent(billerListStateHeading: String?, stateValue: String): String {
if (billerListStateHeading.isNullOrEmpty())
return resourceProvider
.get()
.getString(resId = R.string.bbps_state_bills_heading, stateValue)
return billerListStateHeading.replace(STATE_PLACEHOLDER, stateValue)
}
fun updateSearchQueryStringState(searchQuery: String) {
_searchQuery.update { searchQuery }
}
@@ -159,6 +173,10 @@ constructor(
_billerListState.update { billerListState }
}
private fun updatePayBillShimmerVisibility(isVisible: Boolean) {
_isPayBillShimmerVisible.update { isVisible }
}
suspend fun navigateToNextScreen(direction: Direction) {
_navigateToNextScreen.emit(direction)
}
@@ -166,7 +184,7 @@ constructor(
val showSpaceBelowSearchBar =
combine(_isScreenFastagRecharge, _billerListState) { isScreenFastagRecharge, billerListState
->
!isScreenFastagRecharge && (billerListState !is BillerListState.FetchBillLoading)
!isScreenFastagRecharge && !isPayBillShimmerVisible.value
}
.stateIn(
scope = viewModelScope,
@@ -176,7 +194,7 @@ constructor(
fun onRecentBillItemClicked(billItemEntity: BillItemEntity) {
viewModelScope.safeLaunch(dispatcherProvider.io) {
updateBillerListState(BillerListState.FetchBillLoading)
updatePayBillShimmerVisibility(true)
val billDetailsRequest =
BillDetailsRequest(
billerId = billItemEntity.billerId,
@@ -195,10 +213,11 @@ constructor(
delay(300) // updating state to loaded after navigation
updatePayBillShimmerVisibility(false)
updateBillerListState(createLoadedStateFromBillerResponse(billerListResponse))
} else {
notifyError(billDetailsResponse)
updateBillerListState(createLoadedStateFromBillerResponse(billerListResponse))
updatePayBillShimmerVisibility(false)
}
}
}
@@ -256,7 +275,7 @@ constructor(
fun onBillerItemClickedForPostpaid(billerItemEntity: BillerItemEntity) {
viewModelScope.safeLaunch(dispatcherProvider.io) {
// If all fields are valid, navigate to next screen
updateBillerListState(BillerListState.FetchBillLoading)
updatePayBillShimmerVisibility(true)
val billerDetailsResponse =
billerListRepository.fetchBillerDetails(billerItemEntity = billerItemEntity)
@@ -277,7 +296,7 @@ constructor(
}
} else {
notifyError(billerDetailsResponse)
updateBillerListState(createLoadedStateFromBillerResponse(billerListResponse))
updatePayBillShimmerVisibility(false)
}
}
}
@@ -312,11 +331,10 @@ constructor(
)
delay(300) // updating state to loaded after navigation
updateBillerListState(createLoadedStateFromBillerResponse(billerListResponse))
updatePayBillShimmerVisibility(false)
} else {
notifyError(billDetailsResponse)
updateBillerListState(createLoadedStateFromBillerResponse(billerListResponse))
updatePayBillShimmerVisibility(false)
}
}
@@ -383,7 +401,7 @@ constructor(
@OptIn(FlowPreview::class)
private fun initSearchFlow() {
searchQuery
.debounce(600)
.debounce { if (it.isEmpty()) 0 else 600 }
.map { searchInput -> searchInput.trim() }
.distinctUntilChanged()
.flowOn(dispatcherProvider.io)
@@ -392,7 +410,10 @@ constructor(
searchJob?.cancel()
searchJob =
viewModelScope.launch {
fetchBillerList(isFromSearch = true, searchQuery = searchInput)
fetchBillerList(
isFromSearch = getIsFromSearch(searchQuery.value),
searchQuery = searchInput
)
}
} catch (e: Exception) {
FirebaseCrashlytics.getInstance().recordException(e)
@@ -401,6 +422,11 @@ constructor(
.launchIn(viewModelScope)
}
// if search query is not empty, then it is from search
private fun getIsFromSearch(searchQuery: String): Boolean {
return searchQuery.isNotEmpty()
}
private fun BillerGroupItemResponse.toBillerGroupItemEntity(): BillerGroupItemEntity {
return BillerGroupItemEntity(
title = this.title,
@@ -412,15 +438,20 @@ constructor(
return BillerItemEntity(
billerId = this.billerId,
billerName = this.billerName,
billerLogoUrl = this.billerLogoUrl,
status = this.status,
isAdhoc = this.isAdhoc
billerLogoUrl = this.billerLogoUrl ?: "",
status = this.status ?: "",
isAdhoc = this.isAdhoc ?: false,
state = this.state ?: "",
categoryId = this.categoryId
)
}
private var billerListResponse: RepoResult<BillerListResponse> = RepoResult()
private suspend fun fetchBillerList(isFromSearch: Boolean = false, searchQuery: String = "") {
val categoryIdClicked = billCategoryEntity.categoryId
val cachedBillers = bbpsCommonRepository.getBillersByCategoryFromLocalDb(categoryIdClicked)
if (isFromSearch) {
_isSearchBillerRunning.update { true }
} else {
@@ -428,37 +459,113 @@ constructor(
}
val deviceLocation = deviceLocationProvider.getDeviceLocation()
billerListResponse =
billerListRepository.fetchBillerList(
categoryId = billCategoryEntity.categoryId,
billerListRequest =
BillerListRequest(
latitude = deviceLocation.latitude,
longitude = deviceLocation.longitude,
state = deviceLocation.state,
searchParams = searchQuery
)
)
naviBbpsAnalytics.onBillersFetched(
deviceLocation = deviceLocation,
billerGroupsCount = billerListResponse.data?.billerGroups?.size ?: 0,
recentBillsCount = billerListResponse.data?.recentBills?.bills?.size ?: 0
)
delay(200) // To avoid jerky transition during plan refresh
_isSearchBillerRunning.update { false }
if (billerListResponse.isSuccessWithData()) {
val fullBillerList =
billerListResponse.data!!.billerGroups.orEmpty().flatMap { it.billers.orEmpty() }
updateBillerListState(createLoadedStateFromBillerResponse(billerListResponse))
updateRecentBillsState()
_isQueriedListEmpty.update { fullBillerList.isEmpty() }
if (cachedBillers.isEmpty() || searchQuery.isNotEmpty()) {
billerListResponse =
billerListRepository.fetchBillerList(
categoryId = billCategoryEntity.categoryId,
billerListRequest =
BillerListRequest(
latitude = deviceLocation.latitude,
longitude = deviceLocation.longitude,
state = deviceLocation.state,
searchParams = searchQuery
)
)
naviBbpsAnalytics.onBillersFetched(
deviceLocation = deviceLocation,
billerGroupsCount = billerListResponse.data?.billerGroups?.size ?: 0,
recentBillsCount = billerListResponse.data?.recentBills?.bills?.size ?: 0
)
_isSearchBillerRunning.update { false }
if (billerListResponse.isSuccessWithData()) {
val fullBillerList =
billerListResponse.data!!.billerGroups.orEmpty().flatMap {
it.billers.orEmpty()
}
updateBillerListState(createLoadedStateFromBillerResponse(billerListResponse))
updateRecentBillsState()
_isQueriedListEmpty.update { fullBillerList.isEmpty() }
} else {
updateBillerListState(BillerListState.Error)
notifyError(billerListResponse)
}
} else {
updateBillerListState(BillerListState.Error)
notifyError(billerListResponse)
_isQueriedListEmpty.update { cachedBillers.isEmpty() }
val stateBillers = cachedBillers.filter { it.state == deviceLocation.state }
val billerGroupItemEntities = mutableListOf<BillerGroupItemEntity>()
if (stateBillers.isNotEmpty()) {
val billerGroupItem =
BillerGroupItemEntity(
title =
replaceIfPresent(
billerListStateHeading = billCategoryEntity.billerListStateHeading,
stateValue = deviceLocation.state
),
billers = stateBillers
)
billerGroupItemEntities.add(billerGroupItem)
}
billerGroupItemEntities.add(
BillerGroupItemEntity(
title =
billCategoryEntity.billerListAllHeading.ifEmpty {
resourceProvider
.get()
.getString(R.string.bbps_all_biller_list_default_heading)
},
billers = cachedBillers
)
)
val recentBillsEntity: RecentBillsEntity =
if (categoryIdClicked != CATEGORY_ID_MOBILE_POSTPAID) {
getRecentBills()
} else {
RecentBillsEntity(title = "", bills = listOf())
}
updateBillerListState(
BillerListState.Loaded(
recentBills = recentBillsEntity,
billerGroups = billerGroupItemEntities
)
)
updateRecentBillsState()
}
}
private suspend fun getRecentBills(): RecentBillsEntity {
val savedBills =
myBillsRepository.fetchSavedBillsByCategory(category = billCategoryEntity.categoryId)
val billItemEntities =
savedBills.map { myBillEntity ->
BillItemEntity(
billerId = myBillEntity.billerId,
billerName = myBillEntity.billerName,
billerLogoUrl = myBillEntity.billerLogoUrl,
status = myBillEntity.status,
isAdhoc = myBillEntity.isAdhoc,
paymentAmountExactness = myBillEntity.paymentAmountExactness,
formattedLastPaidDate = myBillEntity.formattedLastPaidDate,
primaryCustomerParam = myBillEntity.primaryCustomerParamValue,
customerParams = myBillEntity.customerParams
)
}
return RecentBillsEntity(
title = resourceProvider.get().getString(R.string.bbps_recent_bills),
bills = billItemEntities
)
}
private fun createLoadedStateFromBillerResponse(
billerListResponse: RepoResult<BillerListResponse>
): BillerListState.Loaded {
@@ -506,13 +613,19 @@ constructor(
billerName = it.billerName,
billerLogoUrl = it.billerLogoUrl,
status = it.status,
isAdhoc = it.isAdhoc
isAdhoc = it.isAdhoc,
state = it.state,
categoryId = it.categoryId
)
}
)
}
)
}
companion object {
const val STATE_PLACEHOLDER = "{state}"
}
}
class MyNumberState(val isVisible: Boolean, val phoneContactEntity: PhoneContactEntity)

View File

@@ -0,0 +1,36 @@
/*
*
* * Copyright © 2024 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.bbps.feature.billerlist.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_DATABASE_BILLERS
import com.navi.bbps.feature.billerlist.model.view.BillerItemEntity
@Dao
interface BillerListDao {
@Transaction
suspend fun refreshBillers(billers: List<BillerItemEntity>) {
deleteAll()
insertBillers(billers = billers)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertBillers(billers: List<BillerItemEntity>)
@Query("SELECT * FROM $NAVI_BBPS_DATABASE_BILLERS")
suspend fun getAllBillers(): List<BillerItemEntity>
@Query("SELECT * FROM $NAVI_BBPS_DATABASE_BILLERS WHERE categoryId = :categoryId")
suspend fun getBillersByCategory(categoryId: String): List<BillerItemEntity>
@Query("DELETE FROM $NAVI_BBPS_DATABASE_BILLERS") suspend fun deleteAll()
}

View File

@@ -0,0 +1,14 @@
/*
*
* * Copyright © 2024 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.bbps.feature.billerlist.model.network
import com.google.gson.annotations.SerializedName
data class BillerItemListResponse(
@SerializedName("billers") val billers: List<BillerItemResponse>?
)

View File

@@ -40,6 +40,8 @@ data class BillerItemResponse(
@SerializedName("billerId") val billerId: String,
@SerializedName("billerName") val billerName: String,
@SerializedName("billerLogoUrl") val billerLogoUrl: String?,
@SerializedName("status") val status: String,
@SerializedName("isAdhoc") val isAdhoc: Boolean
@SerializedName("status") val status: String?,
@SerializedName("isAdhoc") val isAdhoc: Boolean?,
@SerializedName("state") val state: String?,
@SerializedName("categoryId") val categoryId: String,
)

View File

@@ -8,6 +8,9 @@
package com.navi.bbps.feature.billerlist.model.view
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_DATABASE_BILLERS
import com.navi.bbps.feature.paybill.model.network.PaymentAmountExactness
import kotlinx.parcelize.Parcelize
@@ -33,10 +36,13 @@ data class BillItemEntity(
data class BillerGroupItemEntity(val title: String, val billers: List<BillerItemEntity>)
@Parcelize
@Entity(tableName = NAVI_BBPS_DATABASE_BILLERS)
data class BillerItemEntity(
val billerId: String,
@PrimaryKey val billerId: String,
val billerName: String,
val billerLogoUrl: String?,
val status: String,
val isAdhoc: Boolean
val isAdhoc: Boolean,
val state: String,
val categoryId: String
) : Parcelable

View File

@@ -15,7 +15,5 @@ sealed class BillerListState {
val billerGroups: List<BillerGroupItemEntity>,
) : BillerListState()
data object FetchBillLoading : BillerListState()
data object Error : BillerListState()
}

View File

@@ -92,6 +92,8 @@ fun BillerListScreen(
val phoneNumberDetailState by billerListViewModel.myNumberState.collectAsStateWithLifecycle()
val showSpaceBelowSearchBar by
billerListViewModel.showSpaceBelowSearchBar.collectAsStateWithLifecycle()
val isPayBillShimmerVisible by
billerListViewModel.isPayBillShimmerVisible.collectAsStateWithLifecycle()
val bottomSheetState =
rememberModalBottomSheetState(
@@ -185,7 +187,7 @@ fun BillerListScreen(
title = billCategoryEntity.title,
onNavigationIconClick = { onBackClick() },
)
if (billerListState !is BillerListState.FetchBillLoading) {
if (!isPayBillShimmerVisible) {
RenderSearchBarSection(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
phoneNumberDetailState = phoneNumberDetailState,
@@ -219,12 +221,10 @@ fun BillerListScreen(
)
onRecentBillItemClicked(it)
},
isSearchBillerRunning = isSearchBillerRunning
isSearchBillerRunning = isSearchBillerRunning,
isPayBillShimmerVisible = isPayBillShimmerVisible
)
}
is BillerListState.FetchBillLoading -> {
RenderPayBillScreenLoading()
}
is BillerListState.Error -> {}
}
}

View File

@@ -74,7 +74,8 @@ fun RenderBillerListScreen(
openSheet: () -> Unit,
onBillerItemClicked: (BillerItemEntity) -> Unit,
onRecentBillItemClicked: (BillItemEntity) -> Unit,
isSearchBillerRunning: Boolean
isSearchBillerRunning: Boolean,
isPayBillShimmerVisible: Boolean
) {
val isScreenFastagRecharge by
billerListViewModel.isScreenFastagRecharge.collectAsStateWithLifecycle()
@@ -94,126 +95,137 @@ fun RenderBillerListScreen(
}
}
Column(
modifier =
Modifier.fillMaxSize()
.padding(
start = NaviBbpsDimens.horizontalMargin,
end = NaviBbpsDimens.horizontalMargin,
bottom = 16.dp
)
.nestedScroll(nestedScrollConnection)
) {
if (isScreenFastagRecharge) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
text = stringResource(id = R.string.bbps_how_to_find_your_fastag_bank),
fontFamily = ttComposeFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR),
fontSize = 12.sp,
color = NaviBbpsColor.textTertiary,
modifier = Modifier.align(Alignment.CenterVertically)
)
Text(
text = stringResource(id = R.string.bbps_view),
fontFamily = ttComposeFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR),
fontSize = 14.sp,
color = NaviBbpsColor.textPrimary,
textDecoration = TextDecoration.Underline,
modifier =
Modifier.align(Alignment.CenterVertically).noRippleClickableWithDebounce {
keyboardController?.customHide(context = context, view = view)
openSheet()
}
)
}
Spacer(modifier = Modifier.height(24.dp))
}
AnimatedVisibility(
modifier = Modifier.padding(vertical = 16.dp),
visible = isSearchBillerRunning
) {
Column {
Row(modifier = Modifier.fillMaxWidth()) {
NaviBbpsLottieAnimation(
modifier = Modifier.size(16.dp),
lottieFileName = GENERIC_LOADER_LOTTIE_FILE_NAME,
showLottieInfiniteTimes = true
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier =
Modifier.fillMaxSize()
.padding(
start = NaviBbpsDimens.horizontalMargin,
end = NaviBbpsDimens.horizontalMargin,
bottom = 16.dp
)
Spacer(modifier = Modifier.width(8.dp))
.nestedScroll(nestedScrollConnection)
) {
if (isScreenFastagRecharge) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
text = stringResource(id = R.string.bbps_searching),
text = stringResource(id = R.string.bbps_how_to_find_your_fastag_bank),
fontFamily = ttComposeFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR),
fontSize = 12.sp,
color = NaviBbpsColor.textTertiary,
modifier = Modifier.align(Alignment.CenterVertically)
)
Text(
text = stringResource(id = R.string.bbps_view),
fontFamily = ttComposeFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR),
fontSize = 12.sp,
color = NaviBbpsColor.textTertiary
fontSize = 14.sp,
color = NaviBbpsColor.textPrimary,
textDecoration = TextDecoration.Underline,
modifier =
Modifier.align(Alignment.CenterVertically)
.noRippleClickableWithDebounce {
keyboardController?.customHide(context = context, view = view)
openSheet()
}
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
if (isQueriedListEmpty) {
Column(
modifier =
Modifier.fillMaxSize().align(Alignment.CenterHorizontally).padding(top = 32.dp)
AnimatedVisibility(
modifier = Modifier.padding(vertical = 16.dp),
visible = isSearchBillerRunning
) {
Spacer(modifier = Modifier.height(16.dp))
Image(
painter = painterResource(id = R.drawable.ic_bbps_no_billers_icon),
contentDescription = stringResource(id = R.string.bbps_no_billers_found),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.bbps_no_billers_found),
fontFamily = ttComposeFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR),
fontSize = 14.sp,
color = NaviBbpsColor.textTertiary,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Column {
Row(modifier = Modifier.fillMaxWidth()) {
NaviBbpsLottieAnimation(
modifier = Modifier.size(16.dp),
lottieFileName = GENERIC_LOADER_LOTTIE_FILE_NAME,
showLottieInfiniteTimes = true
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.bbps_searching),
fontFamily = ttComposeFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR),
fontSize = 12.sp,
color = NaviBbpsColor.textTertiary
)
}
}
}
}
LazyColumn(modifier = Modifier.fillMaxSize(), flingBehavior = maxScrollFlingBehavior()) {
if (isSearchQueryEmpty and isRecentBillsMoreThanZero) {
item { RecentBillTitle(billerListState = billerListState) }
items(count = billerListState.recentBills.bills.size) { item ->
val billItem = billerListState.recentBills.bills[item]
RecentBillerItem(
billItemEntity = billItem,
primaryCustomerParamValue = billItem.primaryCustomerParam,
isLastPaidDateVisible =
billItem.formattedLastPaidDate.isNotNullAndNotEmpty(),
onRecentBillItemClicked = { onRecentBillItemClicked(billItem) }
if (isQueriedListEmpty) {
Column(
modifier =
Modifier.fillMaxSize()
.align(Alignment.CenterHorizontally)
.padding(top = 32.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Image(
painter = painterResource(id = R.drawable.ic_bbps_no_billers_icon),
contentDescription = stringResource(id = R.string.bbps_no_billers_found),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.bbps_no_billers_found),
fontFamily = ttComposeFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR),
fontSize = 14.sp,
color = NaviBbpsColor.textTertiary,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
billerListState.billerGroups.forEach { group ->
if (isSearchQueryEmpty) {
item { BillerTitle(billerGroupItemEntity = group) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
flingBehavior = maxScrollFlingBehavior()
) {
if (isSearchQueryEmpty and isRecentBillsMoreThanZero) {
item { RecentBillTitle(billerListState = billerListState) }
items(count = billerListState.recentBills.bills.size) { item ->
val billItem = billerListState.recentBills.bills[item]
RecentBillerItem(
billItemEntity = billItem,
primaryCustomerParamValue = billItem.primaryCustomerParam,
isLastPaidDateVisible =
billItem.formattedLastPaidDate.isNotNullAndNotEmpty(),
onRecentBillItemClicked = { onRecentBillItemClicked(billItem) }
)
}
}
items(
count = group.billers.size,
key = { billerItem -> "${group.billers[billerItem].billerId}${group.title}" }
) { billerItem ->
val item = group.billers[billerItem]
BillerItem(
billerItemEntity = item,
onItemClicked = { onBillerItemClicked(item) }
)
billerListState.billerGroups.forEach { group ->
if (isSearchQueryEmpty) {
item { BillerTitle(billerGroupItemEntity = group) }
}
items(
count = group.billers.size,
key = { billerItem ->
"${group.billers[billerItem].billerId}${group.title}"
}
) { billerItem ->
val item = group.billers[billerItem]
BillerItem(
billerItemEntity = item,
onItemClicked = { onBillerItemClicked(item) }
)
}
}
}
}
if (isPayBillShimmerVisible) RenderPayBillScreenLoading()
}
}

View File

@@ -263,7 +263,9 @@ constructor(
categoryId = myBillEntity.categoryId,
title = myBillEntity.categoryName,
iconUrl = "",
searchBoxPlaceholderText = ""
searchBoxPlaceholderText = "",
billerListStateHeading = "",
billerListAllHeading = ""
),
payBillScreenSource =
PayBillSource.Others(
@@ -323,7 +325,9 @@ constructor(
categoryId = myBillEntity.categoryId,
title = myBillEntity.categoryName,
iconUrl = "",
searchBoxPlaceholderText = ""
searchBoxPlaceholderText = "",
billerListStateHeading = "",
billerListAllHeading = ""
)
)
)

View File

@@ -164,7 +164,9 @@ constructor(
.getString(
R.string.bbps_default_searchbox_placeholder
)
}
},
billerListStateHeading = category.billerListStateHeading ?: "",
billerListAllHeading = category.billerListAllHeading ?: ""
)
}
)

View File

@@ -25,5 +25,7 @@ data class CategoryItemResponse(
@SerializedName("title") val title: String,
@SerializedName("iconUrl") val iconUrl: String?,
@SerializedName("order") val order: Int? = 0,
@SerializedName("searchBoxPlaceholderText") val searchBoxPlaceholderText: String?
@SerializedName("searchBoxPlaceholderText") val searchBoxPlaceholderText: String?,
@SerializedName("billerListStateHeading") val billerListStateHeading: String?,
@SerializedName("billerListAllHeading") val billerListAllHeading: String?,
)

View File

@@ -26,7 +26,9 @@ data class BillCategoryEntity(
@PrimaryKey val categoryId: String,
val title: String,
val iconUrl: String,
val searchBoxPlaceholderText: String
val searchBoxPlaceholderText: String,
val billerListStateHeading: String,
val billerListAllHeading: String
) : Parcelable {
val fallbackImageResId
get() = R.drawable.ic_bbps_biller_placeholder

View File

@@ -56,7 +56,9 @@ fun BbpsRoutingLauncherScreen(
title = bundle.getString(BBPS_CATEGORY_TITLE) ?: "",
iconUrl = bundle.getString(BBPS_CATEGORY_ICON_URL) ?: "",
searchBoxPlaceholderText =
bundle.getString(BBPS_BILLER_SEARCH_BOX_PLACEHOLDER_TEXT) ?: ""
bundle.getString(BBPS_BILLER_SEARCH_BOX_PLACEHOLDER_TEXT) ?: "",
billerListStateHeading = "",
billerListAllHeading = "",
),
isRootScreen = true
)
@@ -69,7 +71,9 @@ fun BbpsRoutingLauncherScreen(
title = bundle.getString(BBPS_CATEGORY_TITLE) ?: "",
iconUrl = bundle.getString(BBPS_CATEGORY_ICON_URL) ?: "",
searchBoxPlaceholderText =
bundle.getString(BBPS_BILLER_SEARCH_BOX_PLACEHOLDER_TEXT) ?: ""
bundle.getString(BBPS_BILLER_SEARCH_BOX_PLACEHOLDER_TEXT) ?: "",
billerListStateHeading = "",
billerListAllHeading = ""
),
isRootScreen = true
)

View File

@@ -31,7 +31,6 @@ import com.ramcosta.composedestinations.spec.Direction
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -215,7 +214,6 @@ constructor(
listOf()
}
}
delay(300) // a bit lesser than transition animation duration
updateContactListUIState(
contactListState = ContactListState.Loaded(allContactList = _allContactList.value)
)

View File

@@ -22,7 +22,7 @@ interface MyBillsDao {
fun getAllBills(): Flow<List<MyBillEntity>>
@Query("SELECT * FROM $NAVI_BBPS_TABLE_MY_SAVED_BILLS WHERE categoryId == :categoryId")
fun getBillsByCategory(categoryId: String): Flow<List<MyBillEntity>>
suspend fun getBillsByCategory(categoryId: String): List<MyBillEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(myBills: List<MyBillEntity>)

View File

@@ -24,4 +24,7 @@ constructor(
}
fun fetchMySavedBills() = myBillsDao.getAllBills()
suspend fun fetchSavedBillsByCategory(category: String) =
myBillsDao.getBillsByCategory(category)
}

View File

@@ -584,7 +584,9 @@ constructor(
categoryId = billCategoryEntity.categoryId,
title = billCategoryEntity.title,
iconUrl = billCategoryEntity.iconUrl,
searchBoxPlaceholderText = billCategoryEntity.searchBoxPlaceholderText
searchBoxPlaceholderText = billCategoryEntity.searchBoxPlaceholderText,
billerListStateHeading = billCategoryEntity.billerListStateHeading,
billerListAllHeading = billCategoryEntity.billerListAllHeading
)
)
)

View File

@@ -8,6 +8,7 @@
package com.navi.bbps.network.service
import com.navi.bbps.common.model.network.BbpsGenericResponse
import com.navi.bbps.feature.billerlist.model.network.BillerItemListResponse
import com.navi.bbps.feature.billerlist.model.network.BillerListRequest
import com.navi.bbps.feature.billerlist.model.network.BillerListResponse
import com.navi.bbps.feature.billhistorydetail.model.network.BillTransactionHistoryResponse
@@ -102,4 +103,7 @@ interface NaviBbpsRetrofitService {
suspend fun getConfig(
@Query("configKeyList") commaSeparatedConfigKeys: String
): Response<GenericResponse<Any?>>
@GET("/billpay-gateway/$NAVI_BBPS_API_VERSION/billpay/categories/billers")
suspend fun getFullBillerList(): Response<GenericResponse<BillerItemListResponse>>
}

View File

@@ -79,6 +79,8 @@
<string name="bbps_max_accepted_amt">Maximum accepted amount is ₹%s</string>
<string name="bbps_exact_accepted_amt">Only amount accepted is ₹%s</string>
<string name="bbps_default_searchbox_placeholder">Search…</string>
<string name="bbps_state_biller_list_default_heading">Billers of your state</string>
<string name="bbps_all_biller_list_default_heading">All billers</string>
<string name="bbps_my_number">My number</string>
<string name="bbps_delete_bill_confirmation_title">Are you sure you want to delete?</string>
<string name="bbps_delete_bill_confirmation_message">All your bill payment history will get removed. Are you sure you want to delete this account?</string>
@@ -118,4 +120,6 @@
<string name="bbps_recharge_plan_disclaimer_text">Disclaimer- We support most of the recharges, but please check with your operator before you proceed.</string>
<string name="bbps_disclaimer_with_dash">Disclaimer-</string>
<string name="bbps_view_contacts">View contacts</string>
<string name="bbps_recent_bills">Recent bills</string>
<string name="bbps_state_bills_heading">Billers in %s</string>
</resources>