diff --git a/android/navi-coin/src/main/java/com/navi/coin/network/RetrofitService.kt b/android/navi-coin/src/main/java/com/navi/coin/network/RetrofitService.kt index 2749ac8a39..d860a81cfc 100644 --- a/android/navi-coin/src/main/java/com/navi/coin/network/RetrofitService.kt +++ b/android/navi-coin/src/main/java/com/navi/coin/network/RetrofitService.kt @@ -111,4 +111,13 @@ interface RetrofitService { suspend fun getReferralData( @Header("X-Target") target: String ): Response> + + @GET("/rudolph/scratch-card/history") + @RetryPolicy + suspend fun getScratchCards( + @Header("X-Target") target: String, + @Query("pageSection") pageSection: String, + @Query("pageNumber") pageNumber: String, + @Query("pageSize") pageSize: String, + ): Response> } diff --git a/android/navi-coin/src/main/java/com/navi/coin/repo/pagingsource/ScratchCardHistoryCustomPager.kt b/android/navi-coin/src/main/java/com/navi/coin/repo/pagingsource/ScratchCardHistoryCustomPager.kt new file mode 100644 index 0000000000..2e978297a8 --- /dev/null +++ b/android/navi-coin/src/main/java/com/navi/coin/repo/pagingsource/ScratchCardHistoryCustomPager.kt @@ -0,0 +1,212 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.coin.repo.pagingsource + +import com.navi.base.utils.isNull +import com.navi.coin.models.model.ScratchCard +import com.navi.coin.models.model.ScratchCardHistoryResponse +import com.navi.coin.models.states.ScratchCardPaginatedHistoryScreenState +import com.navi.coin.repo.repository.ScratchCardHistoryScreenRepo +import com.navi.common.checkmate.model.MetricInfo +import com.navi.common.network.models.RepoResult +import com.navi.common.network.models.isSuccessWithData +import com.navi.rr.common.constants.SCRATCH_CARD_HISTORY_SCREEN +import com.navi.rr.utils.custompager.BaseLazyGridPager +import com.navi.rr.utils.custompager.CustomPagerConfig +import com.navi.rr.utils.custompager.PagerItemState +import com.navi.rr.utils.ext.isOdd +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class ScratchCardHistoryCustomPager( + private val repository: ScratchCardHistoryScreenRepo, + private val scope: CoroutineScope, + pagerConfig: CustomPagerConfig = CustomPagerConfig(), +) : BaseLazyGridPager(scope, pagerConfig) { + + private var fetchType = FetchType.ACTIVE + + private var buffer: ScratchCard? = null + + private var previousAPICallIsUnScratched: Boolean = false + + private val _scratchCardHistoryListData = + MutableStateFlow( + ScratchCardPaginatedHistoryScreenState.Loading + ) + val scratchCardHistoryListData = _scratchCardHistoryListData.asStateFlow() + + private val DEFAULT_START_SIZE = 26 + + private val DEFAULT_ACTIVE_PAGE_SIZE = 50 + + init { + pageSize = DEFAULT_ACTIVE_PAGE_SIZE + pageNumber = 0 + } + + override suspend fun fetchData( + pageNumber: Int, + pageSize: Int, + metricInfo: MetricInfo> + ): RepoResult { + return repository.getScratchCards( + metricInfo = metricInfo, + pageSection = getPageSectionByType(fetchType), + pageSize = pageSize, + pageNumber = pageNumber + ) + } + + private fun getPageSectionByType(fetchType: FetchType): String { + return when (fetchType) { + FetchType.ACTIVE -> FetchType.ACTIVE.name + FetchType.PROCESSED -> FetchType.PROCESSED.name + FetchType.EXPIRED -> FetchType.EXPIRED.name + else -> FetchType.EXPIRED.name + } + } + + override fun handleIfEndOfListState(data: ScratchCardHistoryResponse?) { + if (data?.isNextPageAvailable == false) { + if (isEndOfTotalList().not()) { + handleEndCase() + } else { + super.handleIfEndOfListState(data) + if (pagerStates.value.pagingItems.size == 0) + _scratchCardHistoryListData.update { + ScratchCardPaginatedHistoryScreenState.Empty + } + } + } + } + + // This function is used to set the next api to be called if we reach the end of previous api + private fun handleEndCase() { + when (fetchType) { + FetchType.ACTIVE -> { + fetchType = FetchType.PROCESSED + previousAPICallIsUnScratched = true + + // set page size and number to default + pageNumber = DEFAULT_START_KEY + pageSize = DEFAULT_PAGE_SIZE + } + FetchType.PROCESSED -> { + fetchType = FetchType.EXPIRED + pageNumber = DEFAULT_START_KEY + } + FetchType.EXPIRED -> { + fetchType = FetchType.END_OF_LIST + if (buffer.isNull()) pagerItemState.update { PagerItemState.EndOfList } + } + else -> Unit + } + } + + override fun performPostLoadActions(data: ScratchCardHistoryResponse?) { + handleMinimumData(data) + } + + // this handles the case when the data is less than 10 and the next api call is to be made + private fun handleMinimumData(data: ScratchCardHistoryResponse?) { + if (fetchType == FetchType.END_OF_LIST) return + + val uiListSize = pagerList.value.size + val dataSize = data?.scratchCardHistoryList?.size ?: 0 + + val isListSizeLessThanDefaultSize = uiListSize < DEFAULT_START_SIZE + val isDataSizeLessThanDefaultPageSize = dataSize < pageSize + val isUnscratchedDataOdd = previousAPICallIsUnScratched && uiListSize.isOdd() + + if ( + isListSizeLessThanDefaultSize || + isDataSizeLessThanDefaultPageSize || + isUnscratchedDataOdd + ) { + previousAPICallIsUnScratched = false + loadNextPage() + } + } + + override fun handleSuccessData(data: ScratchCardHistoryResponse?) { + if ( + pagerList.value.isEmpty() && + data?.scratchCardHistoryList.isNullOrEmpty() && + fetchType == FetchType.EXPIRED + ) { + _scratchCardHistoryListData.update { ScratchCardPaginatedHistoryScreenState.Empty } + } else { + val scratchCardList = alterDataIfRequired(data) + pagerList.update { currentList -> + currentList.apply { currentList.addAll(scratchCardList) } + } + _scratchCardHistoryListData.update { ScratchCardPaginatedHistoryScreenState.Success } + } + super.handleSuccessData(data) + } + + // This function is used to make the history ui as symmetric as possible by keeping even size + // list + private fun alterDataIfRequired(data: ScratchCardHistoryResponse?): List { + val list = data?.scratchCardHistoryList.orEmpty().toMutableList() + + // Will return if the list is of unscratched cards + if (fetchType == FetchType.ACTIVE) { + return list + } + val bufferSize = if (buffer != null) 1 else 0 + + val totalSizeIsOdd = (pagerList.value.size + list.size + bufferSize).isOdd() + val alterData = totalSizeIsOdd && isEndOfTotalList().not() + + // add stored scratch card if present using buffer + buffer?.let { + list.add(0, it) + buffer = null + } + + // if it is the end of all api calls then no need to alter data + if (fetchType == FetchType.EXPIRED && data?.isNextPageAvailable == false) return list + + // alter data if total size is odd + if (alterData && list.isNotEmpty()) { + buffer = list.removeLast() + } + return list + } + + override fun isEndOfTotalList() = fetchType == FetchType.END_OF_LIST + + override fun refresh() { + fetchType = FetchType.ACTIVE + buffer = null + pageSize = DEFAULT_ACTIVE_PAGE_SIZE + super.refresh() + } + + override fun handleErrorState(data: ScratchCardHistoryResponse?) { + _scratchCardHistoryListData.update { ScratchCardPaginatedHistoryScreenState.Error } + } + + override val metricInfo: MetricInfo> + get() = + MetricInfo.CoinMetric( + screen = SCRATCH_CARD_HISTORY_SCREEN, + isNae = { !it.isSuccessWithData() } + ) + + private enum class FetchType { + ACTIVE, + PROCESSED, + EXPIRED, + END_OF_LIST + } +} diff --git a/android/navi-coin/src/main/java/com/navi/coin/repo/repository/ScratchCardHistoryScreenRepo.kt b/android/navi-coin/src/main/java/com/navi/coin/repo/repository/ScratchCardHistoryScreenRepo.kt index 5c44e6a453..58222c9b40 100644 --- a/android/navi-coin/src/main/java/com/navi/coin/repo/repository/ScratchCardHistoryScreenRepo.kt +++ b/android/navi-coin/src/main/java/com/navi/coin/repo/repository/ScratchCardHistoryScreenRepo.kt @@ -17,6 +17,7 @@ import com.navi.common.forge.model.ScreenDefinition import com.navi.common.model.ModuleNameV2 import com.navi.common.network.models.RepoResult import com.navi.common.utils.Constants.GZIP +import com.navi.rr.common.models.XTarget import com.navi.rr.common.network.retrofit.ResponseHandler import com.navi.rr.utils.cachemanager.CacheHandlerProxy import javax.inject.Inject @@ -47,6 +48,7 @@ constructor( ) } + // Deprecated api for scratch card list suspend fun fetchScratchCardListData( pageNo: Int, pageSize: Int, @@ -64,4 +66,22 @@ constructor( ) ) } + + suspend fun getScratchCards( + pageNumber: Int, + pageSize: Int, + pageSection: String, + metricInfo: MetricInfo> + ): RepoResult { + return responseHandler.handleResponse( + metricInfo = metricInfo, + response = + retrofitService.getScratchCards( + target = XTarget.RUDOLPH.name, + pageSection = pageSection, + pageNumber = pageNumber.toString(), + pageSize = pageSize.toString() + ) + ) + } } diff --git a/android/navi-coin/src/main/java/com/navi/coin/ui/compose/common/ScratchCardListRenderer.kt b/android/navi-coin/src/main/java/com/navi/coin/ui/compose/common/ScratchCardListRenderer.kt index 70f177782c..44de6c1a41 100644 --- a/android/navi-coin/src/main/java/com/navi/coin/ui/compose/common/ScratchCardListRenderer.kt +++ b/android/navi-coin/src/main/java/com/navi/coin/ui/compose/common/ScratchCardListRenderer.kt @@ -8,12 +8,14 @@ package com.navi.coin.ui.compose.common import android.annotation.SuppressLint +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -30,9 +32,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -42,10 +45,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -58,8 +63,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.paging.LoadState -import androidx.paging.compose.LazyPagingItems import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest @@ -88,6 +91,7 @@ import com.navi.coin.utils.constant.Constants.SCRATCHED_SCRATCH_CARD_URL_KEY import com.navi.coin.utils.constant.Constants.SCRATCH_CARD_EXPIRED_BOTTOM_SHEET import com.navi.coin.utils.constant.Constants.SCRATCH_CARD_IN_PROGRESS_BOTTOM_SHEET import com.navi.coin.utils.constant.Constants.SCRATCH_CARD_RECEIVED_BOTTOM_SHEET +import com.navi.coin.utils.constant.Constants.ScratchCardHistoryScreen.VIEW_MORE_SCROLL_DOWN_IN_DP import com.navi.coin.utils.ext.scaledSp import com.navi.coin.utils.formatDuration import com.navi.coin.vm.ScratchCardScreenVM @@ -101,8 +105,13 @@ import com.navi.rr.common.widgetFactory.WidgetRenderer import com.navi.rr.utils.NaviRRAnalytics import com.navi.rr.utils.constants.EventConstants.BOTTOM_SHEET_TYPE import com.navi.rr.utils.constants.EventConstants.SCRATCH_CARD_HISTORY_BOTTOM_SHEET_VISIBLE_EVENT +import com.navi.rr.utils.custompager.PagerItemState +import com.navi.rr.utils.custompager.PagerStateHolder import com.navi.uitron.model.UiTronResponse import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @SuppressLint("MutableCollectionMutableState") @OptIn(ExperimentalFoundationApi::class) @@ -111,17 +120,23 @@ fun ScratchCardListRenderer( modifier: Modifier, content: List>, viewModel: ScratchCardScreenVM, - scratchHistoryCardList: LazyPagingItems, metadata: Map?, renderBottomSheet: ((String, ScratchCard) -> Unit)?, showScratchCard: (ScratchCard) -> Unit, isScratchCardDisplayed: Boolean, setScratchCardDisplayed: (Boolean) -> Unit, + pagerStates: PagerStateHolder, ) { val analyticsHandler by remember { mutableStateOf(viewModel.analyticsHandler.Rewards()) } val paginatedHistoryScreenState = - viewModel.scratchCardHistoryListSource.scratchCardHistoryListData - .collectAsStateWithLifecycle() + viewModel.scratchCardPager.scratchCardHistoryListData.collectAsStateWithLifecycle() + + var hasMoreItemsToLoad by remember { mutableStateOf(true) } + var isLoading by remember { mutableStateOf(false) } + val localDensity = LocalDensity.current + val scrollToInPixel = remember { with(localDensity) { VIEW_MORE_SCROLL_DOWN_IN_DP.dp.toPx() } } + val coroutineScope = rememberCoroutineScope() + var clickedViewMore by remember { mutableStateOf(false) } BoxWithConstraints { val screenHeight = maxHeight Column(modifier = Modifier.fillMaxSize()) { @@ -136,24 +151,29 @@ fun ScratchCardListRenderer( ) { when (paginatedHistoryScreenState.value) { is ScratchCardPaginatedHistoryScreenState.Success -> { - LaunchedEffect( - scratchHistoryCardList.itemCount, - scratchHistoryCardList - ) { + LaunchedEffect(pagerStates.pagingItems.size) { viewModel.onScratchCardListChanged( - scratchHistoryCardList, + pagerStates.pagingItems, isScratchCardDisplayed, setScratchCardDisplayed ) + + if (clickedViewMore) { + animateScrollDown( + coroutineScope = coroutineScope, + lazyGridState = viewModel.scratchCardPager.state, + scrollToInPixel = scrollToInPixel + ) + clickedViewMore = false + } } } else -> Unit } - val lazyGridState = rememberLazyGridState() LazyVerticalGrid( columns = GridCells.Fixed(2), - state = lazyGridState, + state = viewModel.scratchCardPager.state, ) { items(count = content.size, span = { GridItemSpan(2) }) { WidgetRenderer(widget = content[it], viewModel = viewModel) @@ -196,26 +216,48 @@ fun ScratchCardListRenderer( lazyGridScope = this@LazyVerticalGrid, viewModel = viewModel, paginatedHistoryScreenState = paginatedHistoryScreenState.value, - scratchHistoryCardList = scratchHistoryCardList, metadata = metadata, renderBottomSheet = renderBottomSheet, showScratchCard = showScratchCard, - analyticsHandler = analyticsHandler + analyticsHandler = analyticsHandler, + pagerStates = pagerStates ) - } + when (pagerStates.pagerLoadingItemState) { + is PagerItemState.Loading -> { + hasMoreItemsToLoad = true + isLoading = true + } + is PagerItemState.Error -> { + hasMoreItemsToLoad = false + clickedViewMore = false + item(span = { GridItemSpan(2) }) { + RewardPaginationErrorComponent() { + viewModel.scratchCardPager.retry() + } + } + } + is PagerItemState.Loaded -> { + hasMoreItemsToLoad = true + isLoading = false + } + is PagerItemState.EndOfList -> { + hasMoreItemsToLoad = false + } + else -> {} + } - scratchHistoryCardList.apply { - when { - loadState.refresh is LoadState.Loading -> { - RewardHistoryLoadingComponent() - } - loadState.refresh is LoadState.Error -> { - val error = - scratchHistoryCardList.loadState.refresh as LoadState.Error - RewardPaginationErrorComponent(scratchHistoryCardList::retry) - } - loadState.append is LoadState.Error -> { - RewardPaginationErrorComponent(scratchHistoryCardList::retry) + item(span = { GridItemSpan(2) }) { + if (hasMoreItemsToLoad) { + if (pagerStates.pagingItems.size > 0) { + ViewMoreButton(isLoading) { + if (isLoading.not()) { + viewModel.scratchCardPager.loadNextPage() + clickedViewMore = true + } + } + } else { + RewardHistoryLoadingComponent() + } } } } @@ -226,15 +268,81 @@ fun ScratchCardListRenderer( } } +private fun animateScrollDown( + coroutineScope: CoroutineScope, + lazyGridState: LazyGridState, + scrollToInPixel: Float +) { + coroutineScope.launch { + delay(300) + lazyGridState.animateScrollBy( + value = scrollToInPixel, + animationSpec = tween(durationMillis = 300) + ) + } +} + +@Composable +private fun ViewMoreButton(loading: Boolean, onClick: () -> Unit) { + Box(modifier = Modifier, contentAlignment = Alignment.Center) { + Box( + modifier = + Modifier.padding(top = 24.dp, bottom = 32.dp) + .shadow( + elevation = 8.dp, + spotColor = Color(0xFFB0C0D9).copy(0.3f), + clip = true, + shape = RoundedCornerShape(4.dp) + ) + .width(156.dp) + .height(32.dp) + .background(color = Color.White, shape = RoundedCornerShape(4.dp)) + .clickable(interactionSource = NoRippleIndicationSource(), indication = null) { + onClick.invoke() + }, + contentAlignment = Alignment.Center + ) { + if (loading) { + NaviCoinLottieAnimation( + modifier = Modifier.width(32.dp), + lottie = R.raw.progress_loader_purple_dotted, + isRemoteLottie = false + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "View more", + textAlign = TextAlign.Center, + style = + TextStyle( + fontSize = 12.scaledSp(), + lineHeight = 18.scaledSp(), + fontFamily = ttComposeFontFamily, + fontWeight = getFontWeight(FontWeightEnum.TT_SEMI_BOLD), + color = ctaTertiary, + ), + modifier = Modifier.padding(end = 8.dp) + ) + Icon( + painter = painterResource(WidgetsR.drawable.ic_arrow_black_down), + contentDescription = "", + modifier = Modifier.size(14.dp) + ) + } + } + } + } +} + fun scratchCardList( lazyGridScope: LazyGridScope, viewModel: ScratchCardScreenVM, paginatedHistoryScreenState: ScratchCardPaginatedHistoryScreenState, - scratchHistoryCardList: LazyPagingItems, metadata: Map?, renderBottomSheet: ((String, ScratchCard) -> Unit)?, showScratchCard: (ScratchCard) -> Unit, - analyticsHandler: NaviRRAnalytics.Rewards + analyticsHandler: NaviRRAnalytics.Rewards, + pagerStates: PagerStateHolder ) { if (paginatedHistoryScreenState is ScratchCardPaginatedHistoryScreenState.Empty) { @@ -259,11 +367,11 @@ fun scratchCardList( ) } } - } else if (paginatedHistoryScreenState is ScratchCardPaginatedHistoryScreenState.Success) { - lazyGridScope.items(count = scratchHistoryCardList.itemCount) { index -> - val item = scratchHistoryCardList[index] + } else { + lazyGridScope.items(count = pagerStates.pagingItems.size) { index -> + val item = pagerStates.pagingItems[index] val firstElement = index % 2 == 0 - item?.let { + item.let { if ( item.status == ScratchCardStatus.LOCKED.name || item.status == ScratchCardStatus.EXPIRED.name diff --git a/android/navi-coin/src/main/java/com/navi/coin/ui/compose/screen/ScratchCardHistoryScreen.kt b/android/navi-coin/src/main/java/com/navi/coin/ui/compose/screen/ScratchCardHistoryScreen.kt index 59edf343c0..76eaedaa5f 100644 --- a/android/navi-coin/src/main/java/com/navi/coin/ui/compose/screen/ScratchCardHistoryScreen.kt +++ b/android/navi-coin/src/main/java/com/navi/coin/ui/compose/screen/ScratchCardHistoryScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.Text import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -57,8 +58,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.navi.base.deeplink.DeepLinkManager import com.navi.base.deeplink.util.DeeplinkConstants.PRODUCT_HELP_PAGE @@ -106,6 +105,7 @@ import com.navi.rr.scratchcard.model.ScratchCardResponse import com.navi.rr.scratchcard.ui.compose.ScratchCardRenderer import com.navi.rr.utils.NaviRRAnalytics import com.navi.rr.utils.constants.EventConstants +import com.navi.rr.utils.custompager.PagerStateHolder import com.navi.rr.utils.ext.clickable import com.navi.rr.utils.filterAndPrioritize import com.navi.uitron.utils.setShimmerEffect @@ -130,9 +130,7 @@ fun ScratchCardHistoryScreen( var isScratchCardDisplayed by remember { mutableStateOf(false) } val scratchCardHistoryScreenState = viewModel.scratchCardHistoryScreenState.collectAsStateWithLifecycle() - - val scratchCardHistoryList = viewModel.scratchCardHistoryListPager.collectAsLazyPagingItems() - + val pagerStates by viewModel.scratchCardPager.pagerStates.collectAsState() val currentScratchCard by viewModel.scratchCardUseCases.scratchCard.collectAsStateWithLifecycle() val nextScratchCard by @@ -191,13 +189,13 @@ fun ScratchCardHistoryScreen( } } - LaunchedEffect(Unit) { viewModel.refreshData(scratchCardHistoryList) } + LaunchedEffect(Unit) { viewModel.refreshData(pagerStates.pagingItems) } LaunchedEffect(Unit) { viewModel.scratchCardUseCases.scratchingComplete.collect { if (it) { viewModel.clearScratchCardList() - viewModel.refreshData(scratchCardHistoryList) + viewModel.refreshData(pagerStates.pagingItems) viewModel.scratchCardUseCases.setScratchingComplete(false) } } @@ -251,14 +249,14 @@ fun ScratchCardHistoryScreen( state = state, viewModel = viewModel, navigator = navigator, - scratchCardHistoryList = scratchCardHistoryList, + pagerStates = pagerStates, renderBottomSheet = { bottomSheetId, scratchCard -> renderBottomSheet(bottomSheetId, scratchCard) }, showScratchCard = { startingCard -> viewModel.setScratchCardList( filterAndPrioritize( - list = scratchCardHistoryList.itemSnapshotList.items, + list = pagerStates.pagingItems, condition = { it.status == ScratchCardStatus.LOCKED.name }, valueToStartCondition = { startingCard.refId == it.refId } ) @@ -364,7 +362,7 @@ fun ScratchCardHistoryScreen( onBackPress = { viewModel.clearScratchCardList() if (viewModel.userHasScratchedCard) { - viewModel.refreshData(scratchCardHistoryList) + viewModel.refreshData(pagerStates.pagingItems) } } ) { response -> @@ -414,7 +412,7 @@ fun ScratchCardHistoryScreen( .orFalse(), onBackPress = { viewModel.clearScratchCardList() - viewModel.refreshData(scratchCardHistoryList) + viewModel.refreshData(pagerStates.pagingItems) } ) { response -> scratchAnalyticsTrigger(response, analyticsHandler) @@ -446,7 +444,7 @@ fun ScratchCardHistoryScreen( Modifier.clickable(disableRipple = true) { viewModel.clearScratchCardList() if (viewModel.userHasScratchedCard) - viewModel.refreshData(scratchCardHistoryList) + viewModel.refreshData(pagerStates.pagingItems) }, painter = painterResource(CommonR.drawable.ic_close_cross_white), contentDescription = null @@ -471,11 +469,11 @@ fun ScratchCardHistorySuccessScreen( state: ScratchCardHistoryScreenState.Success, viewModel: ScratchCardScreenVM, navigator: DestinationsNavigator, - scratchCardHistoryList: LazyPagingItems, renderBottomSheet: ((String, ScratchCard) -> Unit)?, showScratchCard: (ScratchCard) -> Unit, isScratchCardDisplayed: Boolean, setScratchCardDisplayed: (Boolean) -> Unit, + pagerStates: PagerStateHolder, ) { val systemUiController = rememberSystemUiController() val uiTronTopConfigHeightInPx = dpToPx(396) @@ -506,12 +504,12 @@ fun ScratchCardHistorySuccessScreen( modifier = Modifier.padding(it), content = state.data.screenStructure?.content?.widgets ?: listOf(), viewModel = viewModel, - scratchHistoryCardList = scratchCardHistoryList, renderBottomSheet = renderBottomSheet, showScratchCard = showScratchCard, metadata = state.data.metaData, isScratchCardDisplayed = isScratchCardDisplayed, setScratchCardDisplayed = setScratchCardDisplayed, + pagerStates = pagerStates ) }, backgroundColor = Color.Transparent diff --git a/android/navi-coin/src/main/java/com/navi/coin/utils/constant/Constants.kt b/android/navi-coin/src/main/java/com/navi/coin/utils/constant/Constants.kt index fa27fb687e..f3118da9ba 100644 --- a/android/navi-coin/src/main/java/com/navi/coin/utils/constant/Constants.kt +++ b/android/navi-coin/src/main/java/com/navi/coin/utils/constant/Constants.kt @@ -111,6 +111,10 @@ object Constants { const val EARN_UPTO = "Earn Upto" const val FOR_ = "for " } + + object ScratchCardHistoryScreen { + const val VIEW_MORE_SCROLL_DOWN_IN_DP = 115 + } } sealed class Status(val value: String) { diff --git a/android/navi-coin/src/main/java/com/navi/coin/vm/ScratchCardScreenVM.kt b/android/navi-coin/src/main/java/com/navi/coin/vm/ScratchCardScreenVM.kt index 04890328b3..011e016f61 100644 --- a/android/navi-coin/src/main/java/com/navi/coin/vm/ScratchCardScreenVM.kt +++ b/android/navi-coin/src/main/java/com/navi/coin/vm/ScratchCardScreenVM.kt @@ -8,16 +8,12 @@ package com.navi.coin.vm import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.cachedIn -import androidx.paging.compose.LazyPagingItems import com.navi.base.utils.orZero import com.navi.coin.models.model.ScratchCard import com.navi.coin.models.states.ScratchCardHistoryScreenState import com.navi.coin.models.states.ScratchCardStatus import com.navi.coin.navigator.CoinNavigationActions -import com.navi.coin.repo.pagingsource.ScratchCardHistoryListSource +import com.navi.coin.repo.pagingsource.ScratchCardHistoryCustomPager import com.navi.coin.repo.repository.ScratchCardHistoryScreenRepo import com.navi.coin.usecase.ScratchCardUseCases import com.navi.coin.utils.constant.Constants @@ -32,6 +28,7 @@ import com.navi.rr.common.constants.SCRATCH_CARD_HISTORY_SCREEN import com.navi.rr.common.di.qualifiers.CountDownHelperQualifier import com.navi.rr.common.models.RRErrorData import com.navi.rr.utils.cachingPngs +import com.navi.rr.utils.custompager.CustomPagerConfig import com.navi.rr.utils.facade.ICountDownHelper import com.navi.uitron.model.UiTronResponse import com.navi.uitron.model.action.SchedulerAction @@ -44,10 +41,12 @@ import com.navi.uitron.model.data.UiTronActionData import com.ramcosta.composedestinations.spec.Direction import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -56,10 +55,20 @@ class ScratchCardScreenVM @Inject constructor( private val scratchCardHistoryScreenRepo: ScratchCardHistoryScreenRepo, - var scratchCardHistoryListSource: ScratchCardHistoryListSource, @CountDownHelperQualifier val countDownHelper: ICountDownHelper, scratchCardUseCasesFactory: ScratchCardUseCases.Factory, // Inject the factory ) : CoinBaseVM() { + val scratchCardPager = + ScratchCardHistoryCustomPager( + repository = scratchCardHistoryScreenRepo, + scope = viewModelScope.plus(SupervisorJob()), + pagerConfig = + CustomPagerConfig( + autoLoadEnabled = false, + prefetchDistance = 0, + triggerInitialLoad = false + ) + ) val scratchCardUseCases: ScratchCardUseCases = scratchCardUseCasesFactory.create(viewModelScope) @@ -88,27 +97,9 @@ constructor( } } - val scratchCardHistoryListPager = - Pager( - config = - PagingConfig( - pageSize = scratchCardHistoryListSource.DEFAULT_PAGE_SIZE, - prefetchDistance = scratchCardHistoryListSource.DEFAULT_PAGE_SIZE, - initialLoadSize = scratchCardHistoryListSource.DEFAULT_PAGE_SIZE * 5 - ), - pagingSourceFactory = { - ScratchCardHistoryListSource( - scratchCardHistoryScreenRepo = scratchCardHistoryScreenRepo - ) - .also { scratchCardHistoryListSource = it } - } - ) - .flow - .cachedIn(scope = viewModelScope) - - fun refreshData(scratchCardHistoryList: LazyPagingItems) { + fun refreshData(scratchCardHistoryList: List) { launch { - scratchCardHistoryListSource.refresh() + scratchCardPager.refresh() fetchScratchCardTopUiTronConfigs() userHasScratchedCard = false cancelAllTimer(scratchCardHistoryList) @@ -265,8 +256,8 @@ constructor( handle[key] = value } - fun createAllTimers(scratchCardList: LazyPagingItems) { - scratchCardList.itemSnapshotList + fun createAllTimers(scratchCardList: List) { + scratchCardList .filter { it?.status == ScratchCardStatus.LOCKED.name && it.expiresAfter.orZero() < ONE_DAY_IN_MILLI_SECONDS @@ -285,10 +276,9 @@ constructor( } } - private fun cancelAllTimer(scratchCardList: LazyPagingItems) { - scratchCardList.itemSnapshotList - .filter { it?.status == ScratchCardStatus.LOCKED.name } - .filterNotNull() + private fun cancelAllTimer(scratchCardList: List) { + scratchCardList + .filter { it.status == ScratchCardStatus.LOCKED.name } .forEach { handle[it.refId.toString()] = null } launch { countDownHelper.cancelAllTimers() } } @@ -353,13 +343,13 @@ constructor( } fun onScratchCardListChanged( - scratchHistoryCardList: LazyPagingItems, + scratchHistoryCardList: List, isScratchCardDisplayed: Boolean, setScratchCardDisplayed: (Boolean) -> Unit, ) { createAllTimers(scratchHistoryCardList) if (!isScratchCardDisplayed) { - scratchHistoryCardList.itemSnapshotList + scratchHistoryCardList .filterNotNull() .filter { it.status == ScratchCardStatus.LOCKED.name } .let { diff --git a/android/navi-rr/src/main/java/com/navi/rr/common/models/XTarget.kt b/android/navi-rr/src/main/java/com/navi/rr/common/models/XTarget.kt new file mode 100644 index 0000000000..5b959af3dc --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/common/models/XTarget.kt @@ -0,0 +1,12 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.common.models + +enum class XTarget { + RUDOLPH, +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyGridPager.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyGridPager.kt new file mode 100644 index 0000000000..bd2411283d --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyGridPager.kt @@ -0,0 +1,41 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.utils.custompager + +import androidx.compose.foundation.lazy.grid.LazyGridState +import kotlinx.coroutines.CoroutineScope + +/** + * Base class for paginating a lazy grid in Jetpack Compose. This class extends [BaseLazyPager] to + * handle paginated data loading with support for a Compose [LazyGridState]. + * + * @param IndividualResponseType The type of individual items in the list. + * @param NetworkResponseType The type of the network response received when loading data. + * @param scope [CoroutineScope] used to observe scroll state and trigger data loading. + * @param config Configuration object that determines the pager's behavior, such as prefetch + * distance and auto-loading. + */ +abstract class BaseLazyGridPager( + private val scope: CoroutineScope, + private val config: CustomPagerConfig +) : BaseLazyPager(scope = scope, config = config) { + + /** The state object that keeps track of the scroll state in the lazy grid. */ + val state by lazy { LazyGridState() } + + /** + * Determines the index of the last visible item in the lazy grid. Adjusts the value based on + * the offset. + * + * @return The index of the last visible item relative to the currently loaded pages. + */ + override fun getLastPageVisibleItemIndex(): Int { + val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + return (lastVisibleItemIndex - offset).coerceAtLeast(0) + } +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyListPager.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyListPager.kt new file mode 100644 index 0000000000..7dab7757da --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyListPager.kt @@ -0,0 +1,41 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.utils.custompager + +import androidx.compose.foundation.lazy.LazyListState +import kotlinx.coroutines.CoroutineScope + +/** + * Base class for paginating a lazy list in Jetpack Compose. This class extends [BaseLazyPager] to + * handle paginated data loading with support for a Compose [LazyListState]. + * + * @param IndividualResponseType The type of individual items in the list. + * @param NetworkResponseType The type of the network response received when loading data. + * @param scope [CoroutineScope] used to observe scroll state and trigger data loading. + * @param config Configuration object that determines the pager's behavior, such as prefetch + * distance and auto-loading. + */ +abstract class BaseLazyListPager( + private val scope: CoroutineScope, + private val config: CustomPagerConfig +) : BaseLazyPager(scope = scope, config = config) { + + /** The state object that keeps track of the scroll state in the lazy list. */ + val state by lazy { LazyListState() } + + /** + * Determines the index of the last visible item in the lazy list. Adjusts the value based on + * the offset. + * + * @return The index of the last visible item relative to the currently loaded pages. + */ + override fun getLastPageVisibleItemIndex(): Int { + val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + return (lastVisibleItemIndex - offset).coerceAtLeast(0) + } +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyPager.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyPager.kt new file mode 100644 index 0000000000..6b65aebd5c --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BaseLazyPager.kt @@ -0,0 +1,52 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.utils.custompager + +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Base class for lazy pagination in Jetpack Compose. This class extends [BasePager] to handle + * paginated data loading. + * + * @param IndividualResponseType The type of individual items in the list. + * @param NetworkResponseType The type of the network response received when loading data. + * @param scope [CoroutineScope] used to observe scroll state and trigger data loading. + * @param config Configuration object that determines the pager's behavior, such as prefetch + * distance and auto-loading. + */ +abstract class BaseLazyPager( + private val scope: CoroutineScope, + config: CustomPagerConfig +) : BasePager(scope = scope, config = config) { + /** + * Initializes the pager. If auto-load is enabled in the [config], it will start observing the + * scroll state to load pages automatically. + */ + init { + if (config.autoLoadEnabled) observeScrollState() + } + + /** + * Observes the scroll state of the lazy grid/list using [snapshotFlow]. When the last visible + * item is near the end, it triggers loading the next page. + */ + private fun observeScrollState() { + scope.launch { + snapshotFlow { getLastPageVisibleItemIndex() } + .collect { lastPageVisibleItem -> + if (lastPageVisibleItem != 0) { + handleScrollState(lastPageVisibleItem) + } + } + } + } + + abstract fun getLastPageVisibleItemIndex(): Int +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BasePager.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BasePager.kt new file mode 100644 index 0000000000..0aa6456ab0 --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/BasePager.kt @@ -0,0 +1,197 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.utils.custompager + +import com.navi.common.checkmate.model.MetricInfo +import com.navi.common.network.models.RepoResult +import com.navi.common.utils.isValidResponse +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Base class for a custom pager implementation that supports paginated data loading. + * + * @param IndividualResponseType The type of each item in the list. + * @param NetworkResponseType The type of the response received from the network. + * @param scope CoroutineScope used to launch coroutines for data loading operations. + * @param config Configuration parameters for the pager such as prefetch distance. + */ +abstract class BasePager( + private val scope: CoroutineScope, + private val config: CustomPagerConfig +) { + protected val DEFAULT_PAGE_SIZE: Int = 10 + protected val DEFAULT_START_KEY: Int = 0 + + protected var pageSize: Int = DEFAULT_PAGE_SIZE + protected var pageNumber: Int = DEFAULT_START_KEY + + /** Offset for calculating the last visible item index of the last fetched page */ + protected var offset = 0 + + private val isReadyToLoad: Boolean + get() = pagerItemState.value == PagerItemState.Loaded + + /** + * MutableStateFlow representing the current state of the pager item (Idle, Loading, Loaded, + * Error, EndOfList) + */ + protected val pagerItemState = MutableStateFlow(PagerItemState.Idle) + + /** MutableStateFlow holding the list of items that have been loaded */ + protected val pagerList = MutableStateFlow>(mutableListOf()) + + /** + * Combines the list of items and pager item state into a [PagerStateHolder]. Provides a stream + * of pager states and the list of loaded items. + */ + val pagerStates: StateFlow> = + combine( + pagerList, + pagerItemState, + ) { pagingItems, pagerItemState -> + PagerStateHolder( + pagingItems = pagingItems, + pagerLoadingItemState = pagerItemState, + ) + } + .stateIn( + scope = scope, + started = SharingStarted.Lazily, + initialValue = + PagerStateHolder( + pagingItems = pagerList.value, + pagerLoadingItemState = pagerItemState.value, + ) + ) + + init { + if (config.triggerInitialLoad || config.autoLoadEnabled) startInitialLoading() + } + + /** + * Handles the scroll state and triggers loading of the next page if necessary. + * + * @param lastPageVisibleItemIndex The index of the last visible item in the list. + */ + protected fun handleScrollState(lastPageVisibleItemIndex: Int) { + if (shouldLoadNextPage(lastPageVisibleItemIndex)) { + loadNextPage() + } + } + + /** + * Determines whether the next page should be loaded based on the scroll state. + * + * @param lastVisibleItemIndex The index of the last visible item of the last page fetched. + * @return true if the next page should be loaded, false otherwise. + */ + private fun shouldLoadNextPage(lastVisibleItemIndex: Int): Boolean { + if (config.prefetchDistance == 0) return false + val scrollConditionSatisfied = (lastVisibleItemIndex >= config.prefetchDistance) + return scrollConditionSatisfied && isReadyToLoad + } + + /** Loads the next page of data. Can be overridden to provide custom load behavior. */ + open fun loadNextPage() { + if (pagerStates.value.pagerLoadingItemState == PagerItemState.Loading) return + scope.launch { load() } + } + + /** Retries loading the next page in case of an error. */ + open fun retry() { + loadNextPage() + } + + /** + * Loads data for the next page. This function is responsible for managing the loading, success, + * error, and end-of-list states. + */ + private suspend fun load() { + handleLoadingState() + updateState(PagerItemState.Loading) + val response = fetchData(pageNumber, pageSize, metricInfo) + if (response.isValidResponse()) { + pageNumber++ + handleSuccessData(response.data) + updateState(PagerItemState.Loaded) + handleIfEndOfListState(response.data) + performPostLoadActions(response.data) + } else { + handleErrorState(response.data) + updateState(PagerItemState.Error("Something went wrong")) + } + } + + protected fun updateState(pagerItem: PagerItemState) { + pagerItemState.update { pagerItem } + } + + protected fun handleLoadingState() { + pagerItemState.update { PagerItemState.Loading } + } + + /** + * Abstract function to fetch the page data from the network. Must be implemented by subclasses. + * + * @param pageNumber The current page number. + * @param pageSize The number of items per page. + * @param metricInfo Metric information to track the pager's performance. + * @return [RepoResult] containing the network response. + */ + abstract suspend fun fetchData( + pageNumber: Int, + pageSize: Int, + metricInfo: MetricInfo>, + ): RepoResult + + protected open fun handleSuccessData(data: NetworkResponseType?) { + offset = pagerList.value.size // Update offset to reflect the size of the list + } + + protected open fun handleIfEndOfListState(data: NetworkResponseType?) { + if (isEndOfTotalList()) { + updateState(PagerItemState.EndOfList) + } + } + + abstract fun handleErrorState(data: NetworkResponseType?) + + open fun refresh() { + scope.launch { + offset = 0 + pageNumber = DEFAULT_START_KEY + pagerItemState.update { PagerItemState.Idle } // Reset to idle before reloading + pagerList.update { mutableListOf() } + load() + } + } + + private fun startInitialLoading() { + loadNextPage() + } + + /** + * Metric information to track the pager's performance, such as the number of pages or items + * loaded. + */ + abstract val metricInfo: MetricInfo> + + /** + * Performs any post-load actions after the data has been loaded. Can be overridden to provide + */ + abstract fun performPostLoadActions(data: NetworkResponseType?) + + abstract fun isEndOfTotalList(): Boolean +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/CustomPagerConfig.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/CustomPagerConfig.kt new file mode 100644 index 0000000000..19acf3ecc2 --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/CustomPagerConfig.kt @@ -0,0 +1,19 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.utils.custompager + +data class CustomPagerConfig( + val triggerInitialLoad: Boolean = true, + var autoLoadEnabled: Boolean = false, + val prefetchDistance: Int = 5, +) + +data class PagerStateHolder( + val pagingItems: MutableList, + val pagerLoadingItemState: PagerItemState, +) diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/PagerItemState.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/PagerItemState.kt new file mode 100644 index 0000000000..46e9912d4e --- /dev/null +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/custompager/PagerItemState.kt @@ -0,0 +1,20 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.rr.utils.custompager + +sealed interface PagerItemState { + object Loaded : PagerItemState + + object Loading : PagerItemState + + object EndOfList : PagerItemState + + class Error(message: String) : PagerItemState + + object Idle : PagerItemState +} diff --git a/android/navi-rr/src/main/java/com/navi/rr/utils/ext/GenericExt.kt b/android/navi-rr/src/main/java/com/navi/rr/utils/ext/GenericExt.kt index fff39ef44d..f8debba374 100644 --- a/android/navi-rr/src/main/java/com/navi/rr/utils/ext/GenericExt.kt +++ b/android/navi-rr/src/main/java/com/navi/rr/utils/ext/GenericExt.kt @@ -19,3 +19,7 @@ fun List.thirdOrNull(): T? { } fun T?.toJson(): String = getGsonBuilders().toJson(this).orElse("{}") + +fun Int.isEven(): Boolean = this % 2 == 0 + +fun Int.isOdd(): Boolean = this % 2 != 0