From 30207ed3848a669c59839c1061f6ba1bf47567d5 Mon Sep 17 00:00:00 2001 From: Yun Cheng <129205442+ycheng-kickstarter@users.noreply.github.com> Date: Mon, 22 Jul 2024 20:54:50 -0400 Subject: [PATCH] MBL-1473: Add infinite scroll to PPO screen (#2072) --- app/build.gradle | 1 + .../data/PledgedProjectsOverviewQueryData.kt | 8 +- .../ui/PledgedProjectsOverviewActivity.kt | 15 +- .../ui/PledgedProjectsOverviewScreen.kt | 4 +- .../PledgedProjectsOverviewViewModel.kt | 118 +++++++--- .../PledgedProjectsOverviewViewModelTest.kt | 207 ++++++++++++------ 6 files changed, 250 insertions(+), 103 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ed7ce5f1b2..090b77eccb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -297,6 +297,7 @@ dependencies { // Paging 3 for compose dependency implementation "androidx.paging:paging-compose:3.3.0" + implementation "androidx.paging:paging-testing:3.3.0" // Testing testImplementation "junit:junit:4.13.2" diff --git a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/data/PledgedProjectsOverviewQueryData.kt b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/data/PledgedProjectsOverviewQueryData.kt index 0da0112a36..2ae13f3663 100644 --- a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/data/PledgedProjectsOverviewQueryData.kt +++ b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/data/PledgedProjectsOverviewQueryData.kt @@ -1,8 +1,8 @@ package com.kickstarter.features.pledgedprojectsoverview.data data class PledgedProjectsOverviewQueryData( - val first: Int?, - val after: String?, - val last: Int?, - val before: String? + val first: Int? = null, + val after: String? = null, + val last: Int? = null, + val before: String? = null ) diff --git a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewActivity.kt b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewActivity.kt index e227e538ea..e4836d39cb 100644 --- a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewActivity.kt +++ b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewActivity.kt @@ -19,8 +19,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems -import com.kickstarter.features.pledgedprojectsoverview.data.PledgedProjectsOverviewQueryData import com.kickstarter.features.pledgedprojectsoverview.viewmodel.PledgedProjectsOverviewViewModel import com.kickstarter.libs.MessagePreviousScreenType import com.kickstarter.libs.RefTag @@ -48,11 +48,7 @@ class PledgedProjectsOverviewActivity : AppCompatActivity() { val data = result.data?.getBooleanExtra(IntentKey.FIX_PAYMENT_SUCCESS, false) data?.let { if (it.isTrue()) { - viewModel.getPledgedProjects( - PledgedProjectsOverviewQueryData( - 25, null, null, null - ) - ) + viewModel.getPledgedProjects() } } } @@ -74,11 +70,13 @@ class PledgedProjectsOverviewActivity : AppCompatActivity() { val darkModeEnabled = this.isDarkModeEnabled(env = env) val lazyListState = rememberLazyListState() val snackbarHostState = remember { SnackbarHostState() } + val totalAlerts = viewModel.totalAlertsState.collectAsStateWithLifecycle().value val ppoCardPagingSource = viewModel.ppoCardsState.collectAsLazyPagingItems() - val totalAlerts = ppoUIState.totalAlerts - val isLoading = ppoUIState.isLoading || !ppoCardPagingSource.loadState.isIdle + + val isLoading = ppoUIState.isLoading || ppoCardPagingSource.loadState.append is LoadState.Loading || ppoCardPagingSource.loadState.refresh is LoadState.Loading val isErrored = ppoUIState.isErrored || ppoCardPagingSource.loadState.hasError + val showEmptyState = ppoCardPagingSource.loadState.refresh is LoadState.NotLoading && ppoCardPagingSource.itemCount == 0 KickstarterApp( useDarkTheme = @@ -105,6 +103,7 @@ class PledgedProjectsOverviewActivity : AppCompatActivity() { onSendMessageClick = { projectName -> viewModel.onMessageCreatorClicked(projectName) }, isLoading = isLoading, isErrored = isErrored, + showEmptyState = showEmptyState, onSeeAllBackedProjectsClick = { startProfileActivity() }, pullRefreshCallback = { // TODO call viewmodel.getPledgedProjects() here diff --git a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewScreen.kt b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewScreen.kt index eff3a1d8e1..97306a8ad7 100644 --- a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewScreen.kt +++ b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/ui/PledgedProjectsOverviewScreen.kt @@ -43,7 +43,6 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.kickstarter.R import com.kickstarter.features.pledgedprojectsoverview.data.PPOCard import com.kickstarter.features.pledgedprojectsoverview.data.PPOCardFactory -import com.kickstarter.libs.utils.extensions.isNullOrZero import com.kickstarter.ui.compose.designsystem.KSAlertDialog import com.kickstarter.ui.compose.designsystem.KSCircularProgressIndicator import com.kickstarter.ui.compose.designsystem.KSPrimaryGreenButton @@ -154,6 +153,7 @@ fun PledgedProjectsOverviewScreen( onSeeAllBackedProjectsClick: () -> Unit, isLoading: Boolean = false, isErrored: Boolean = false, + showEmptyState: Boolean = false, pullRefreshCallback: () -> Unit = {}, onFixPaymentClick: (projectSlug: String) -> Unit, ) { @@ -190,7 +190,7 @@ fun PledgedProjectsOverviewScreen( ) { padding -> if (isErrored) { PPOScreenErrorState() - } else if (totalAlerts == 0 || ppoCards.itemCount.isNullOrZero()) { + } else if (showEmptyState) { PPOScreenEmptyState(onSeeAllBackedProjectsClick) } else { LazyColumn( diff --git a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModel.kt b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModel.kt index 47410fc0f6..5c19c6259a 100644 --- a/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModel.kt +++ b/app/src/main/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModel.kt @@ -3,12 +3,19 @@ package com.kickstarter.features.pledgedprojectsoverview.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.PagingState import com.kickstarter.R import com.kickstarter.features.pledgedprojectsoverview.data.PPOCard import com.kickstarter.features.pledgedprojectsoverview.data.PledgedProjectsOverviewQueryData import com.kickstarter.libs.Environment import com.kickstarter.models.Project +import com.kickstarter.services.ApolloClientTypeV2 +import com.kickstarter.services.apiresponses.commentresponse.PageInfoEnvelope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -18,6 +25,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart @@ -26,23 +34,67 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.rx2.asFlow +private val PAGE_LIMIT = 25 +class PledgedProjectsPagingSource( + private val apolloClient: ApolloClientTypeV2, + private var totalAlerts: MutableStateFlow, + private val limit: Int = PAGE_LIMIT, + +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String { + return "" // - Default first page is empty string when paginating with graphQL + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + var ppoCardsList = emptyList() + var nextPageEnvelope: PageInfoEnvelope? = null + var inputData = PledgedProjectsOverviewQueryData(limit, params.key ?: "") + var result: LoadResult = LoadResult.Error(Throwable()) + + apolloClient.getPledgedProjectsOverviewPledges( + inputData = inputData + ) + .asFlow() + .catch { + result = LoadResult.Error(it) + } + .collect { envelope -> + totalAlerts.emit(envelope.totalCount ?: 0) + ppoCardsList = envelope.pledges() ?: emptyList() + nextPageEnvelope = if (envelope.pageInfoEnvelope?.hasNextPage == true) envelope.pageInfoEnvelope else null + result = LoadResult.Page( + data = ppoCardsList, + prevKey = null, + nextKey = nextPageEnvelope?.endCursor + ) + } + return result + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} + data class PledgedProjectsOverviewUIState( - var totalAlerts: Int = 0, val isLoading: Boolean = false, val isErrored: Boolean = false, ) - class PledgedProjectsOverviewViewModel(environment: Environment) : ViewModel() { private val mutablePpoCards = MutableStateFlow>(PagingData.empty()) private var mutableProjectFlow = MutableSharedFlow() private var snackbarMessage: (stringID: Int) -> Unit = {} - private var totalAlerts = 0 private val apolloClient = requireNotNull(environment.apolloClientV2()) + private val mutableTotalAlerts = MutableStateFlow(0) + val totalAlertsState = mutableTotalAlerts.asStateFlow() + private val mutablePPOUIState = MutableStateFlow(PledgedProjectsOverviewUIState()) val ppoCardsState: StateFlow> = mutablePpoCards.asStateFlow() + private var pagingSource = PledgedProjectsPagingSource(apolloClient, mutableTotalAlerts, PAGE_LIMIT) + val ppoUIState: StateFlow get() = mutablePPOUIState .asStateFlow() @@ -54,7 +106,6 @@ class PledgedProjectsOverviewViewModel(environment: Environment) : ViewModel() { fun showSnackbarAndRefreshCardsList() { snackbarMessage.invoke(R.string.address_confirmed_snackbar_text_fpo) - // TODO: MBL-1556 refresh the PPO list (i.e. requery the PPO list). } @@ -66,10 +117,34 @@ class PledgedProjectsOverviewViewModel(environment: Environment) : ViewModel() { started = SharingStarted.WhileSubscribed(), ) - class Factory(private val environment: Environment) : - ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return PledgedProjectsOverviewViewModel(environment) as T + init { + getPledgedProjects() + } + + fun getPledgedProjects() { + viewModelScope.launch(Dispatchers.IO) { + try { + Pager( + PagingConfig( + pageSize = PAGE_LIMIT, + prefetchDistance = 3, + enablePlaceholders = true, + ) + ) { + pagingSource + } + .flow + .onStart { + emitCurrentState(isLoading = true) + }.catch { + emitCurrentState(isErrored = true) + }.collectLatest { pagingData -> + mutablePpoCards.value = pagingData + emitCurrentState() + } + } catch (e: Exception) { + emitCurrentState(isErrored = true) + } } } @@ -95,32 +170,19 @@ class PledgedProjectsOverviewViewModel(environment: Environment) : ViewModel() { } } - fun getPledgedProjects(inputData: PledgedProjectsOverviewQueryData) { - viewModelScope.launch { - // TODO how we are fetching the data will be modified once the pagination piece in MBL-1473 is finished - apolloClient.getPledgedProjectsOverviewPledges( - inputData = inputData, - ) - .asFlow() - .onStart { - emitCurrentState(isLoading = true) - }.map { ppoEnvelope -> - // update paginated ppo card list here - totalAlerts = ppoEnvelope.totalCount() ?: 0 - emitCurrentState() - }.catch { - emitCurrentState(isErrored = true) - }.collect() - } - } - private suspend fun emitCurrentState(isLoading: Boolean = false, isErrored: Boolean = false) { mutablePPOUIState.emit( PledgedProjectsOverviewUIState( isLoading = isLoading, isErrored = isErrored, - totalAlerts = totalAlerts ) ) } + + class Factory(private val environment: Environment) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return PledgedProjectsOverviewViewModel(environment) as T + } + } } diff --git a/app/src/test/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModelTest.kt b/app/src/test/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModelTest.kt index ff47eaedc6..cbaa168ce4 100644 --- a/app/src/test/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/features/pledgedprojectsoverview/viewmodel/PledgedProjectsOverviewViewModelTest.kt @@ -1,5 +1,8 @@ package com.kickstarter.features.pledgedprojectsoverview.viewmodel +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.testing.TestPager import com.kickstarter.KSRobolectricTestCase import com.kickstarter.R import com.kickstarter.features.pledgedprojectsoverview.data.PPOCardFactory @@ -10,6 +13,7 @@ import com.kickstarter.mock.services.MockApolloClientV2 import com.kickstarter.models.Project import io.reactivex.Observable import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -91,96 +95,177 @@ class PledgedProjectsOverviewViewModelTest : KSRobolectricTestCase() { ) } +// TODO will add tests back after spike MBL-1638 completed +// @Test +// fun `emits_error_state_when_errored`() = +// runTest { +// val mockApolloClientV2 = object : MockApolloClientV2() { +// +// override fun getPledgedProjectsOverviewPledges(inputData: PledgedProjectsOverviewQueryData): Observable { +// return Observable.error(Throwable()) +// } +// } +// +// val environment = environment().toBuilder().apolloClientV2(mockApolloClientV2).build() +// +// viewModel = PledgedProjectsOverviewViewModel.Factory(environment = environment) +// .create(PledgedProjectsOverviewViewModel::class.java) +// +// val uiState = mutableListOf() +// +// backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { +// viewModel.ppoUIState.toList(uiState) +// } +// +// viewModel.getPledgedProjects() +// +// assertEquals( +// uiState, +// listOf( +// PledgedProjectsOverviewUIState(isLoading = false, isErrored = false), +// PledgedProjectsOverviewUIState(isLoading = true, isErrored = false), +// PledgedProjectsOverviewUIState(isLoading = false, isErrored = true) +// ) +// ) +// } +// +// @Test +// fun `emits_empty_state_when_no_pledges`() = +// runTest { +// val mockApolloClientV2 = object : MockApolloClientV2() { +// +// override fun getPledgedProjectsOverviewPledges(inputData: PledgedProjectsOverviewQueryData): Observable { +// return Observable.just(PledgedProjectsOverviewEnvelope.builder().totalCount(0).build()) +// } +// } +// +// viewModel = PledgedProjectsOverviewViewModel.Factory(environment = environment().toBuilder().apolloClientV2(mockApolloClientV2).build()) +// .create(PledgedProjectsOverviewViewModel::class.java) +// +// val uiState = mutableListOf() +// +// backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { +// viewModel.ppoUIState.toList(uiState) +// } +// +// viewModel.getPledgedProjects() +// +// assertEquals( +// uiState, +// listOf( +// PledgedProjectsOverviewUIState(isLoading = false, isErrored = false), +// PledgedProjectsOverviewUIState(isLoading = true, isErrored = false), +// PledgedProjectsOverviewUIState(isLoading = false, isErrored = false) +// ) +// ) +// } + +// @Test +// fun `emits_loading_then_success_state_when_successful`() = +// runTest { +// val mockApolloClientV2 = object : MockApolloClientV2() { +// +// override fun getPledgedProjectsOverviewPledges(inputData: PledgedProjectsOverviewQueryData): Observable { +// return Observable.just(PledgedProjectsOverviewEnvelope.builder().totalCount(10).pledges(listOf(PPOCardFactory.confirmAddressCard())).build()) +// } +// } +// +// val environment = environment().toBuilder().apolloClientV2(mockApolloClientV2).build() +// +// viewModel = PledgedProjectsOverviewViewModel.Factory(environment = environment) +// .create(PledgedProjectsOverviewViewModel::class.java) +// +// val uiState = mutableListOf() +// +// backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { +// viewModel.ppoUIState.toList(uiState) +// } +// +// viewModel.getPledgedProjects() +// +// assertEquals( +// uiState, +// listOf( +// PledgedProjectsOverviewUIState(isLoading = false, isErrored = false), +// PledgedProjectsOverviewUIState(isLoading = true, isErrored = false), +// PledgedProjectsOverviewUIState(isLoading = false, isErrored = false) +// ) +// ) +// } @Test - fun `emits error state when errored`() = + fun `pager result is errored when network response is errored`() { runTest { + val mutableTotalAlerts = MutableStateFlow(0) + val mockApolloClientV2 = object : MockApolloClientV2() { override fun getPledgedProjectsOverviewPledges(inputData: PledgedProjectsOverviewQueryData): Observable { return Observable.error(Throwable()) } } + val pagingSource = PledgedProjectsPagingSource( + mockApolloClientV2, + mutableTotalAlerts + ) - viewModel = PledgedProjectsOverviewViewModel.Factory(environment = environment().toBuilder().apolloClientV2(mockApolloClientV2).build()) - .create(PledgedProjectsOverviewViewModel::class.java) - - val uiState = mutableListOf() - - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.ppoUIState.toList(uiState) - } + val pager = TestPager( + PagingConfig( + pageSize = 3, + prefetchDistance = 3, + enablePlaceholders = true, + ), + pagingSource + ) - viewModel.getPledgedProjects(PledgedProjectsOverviewQueryData(10, null, null, null)) + val result = pager.refresh() + assertTrue(result is PagingSource.LoadResult.Error) - assertEquals( - uiState, - listOf( - PledgedProjectsOverviewUIState(isLoading = false, isErrored = false, totalAlerts = 0), - PledgedProjectsOverviewUIState(isLoading = true, isErrored = false, totalAlerts = 0), - PledgedProjectsOverviewUIState(isLoading = false, isErrored = true, totalAlerts = 0) - ) - ) + val page = pager.getLastLoadedPage() + assertNull(page) } + } @Test - fun `emits empty state when no pledges`() = + fun `pager result returns list network call is successful`() { runTest { + val mutableTotalAlerts = MutableStateFlow(0) + val totalAlertsList = mutableListOf() + val mockApolloClientV2 = object : MockApolloClientV2() { override fun getPledgedProjectsOverviewPledges(inputData: PledgedProjectsOverviewQueryData): Observable { - return Observable.just(PledgedProjectsOverviewEnvelope.builder().totalCount(0).build()) + return Observable.just(PledgedProjectsOverviewEnvelope.builder().totalCount(10).pledges(listOf(PPOCardFactory.confirmAddressCard())).build()) } } - - viewModel = PledgedProjectsOverviewViewModel.Factory(environment = environment().toBuilder().apolloClientV2(mockApolloClientV2).build()) - .create(PledgedProjectsOverviewViewModel::class.java) - - val uiState = mutableListOf() + val pagingSource = PledgedProjectsPagingSource( + mockApolloClientV2, + mutableTotalAlerts + ) backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.ppoUIState.toList(uiState) + mutableTotalAlerts.toList(totalAlertsList) } - viewModel.getPledgedProjects(PledgedProjectsOverviewQueryData(10, null, null, null)) - - assertEquals( - uiState, - listOf( - PledgedProjectsOverviewUIState(isLoading = false, isErrored = false, totalAlerts = 0), - PledgedProjectsOverviewUIState(isLoading = true, isErrored = false, totalAlerts = 0), - PledgedProjectsOverviewUIState(isLoading = false, isErrored = false, totalAlerts = 0) - ) + val pager = TestPager( + PagingConfig( + pageSize = 3, + prefetchDistance = 3, + enablePlaceholders = true, + ), + pagingSource ) - } - - @Test - fun `emits loading then success state when successful`() = - runTest { - val mockApolloClientV2 = object : MockApolloClientV2() { - - override fun getPledgedProjectsOverviewPledges(inputData: PledgedProjectsOverviewQueryData): Observable { - return Observable.just(PledgedProjectsOverviewEnvelope.builder().totalCount(10).pledges(listOf(PPOCardFactory.confirmAddressCard())).build()) - } - } - - viewModel = PledgedProjectsOverviewViewModel.Factory(environment = environment().toBuilder().apolloClientV2(mockApolloClientV2).build()) - .create(PledgedProjectsOverviewViewModel::class.java) + viewModel.getPledgedProjects() - val uiState = mutableListOf() + val result = pager.refresh() + assertTrue(result is PagingSource.LoadResult.Page) - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.ppoUIState.toList(uiState) - } - - viewModel.getPledgedProjects(PledgedProjectsOverviewQueryData(10, null, null, null)) + val page = pager.getLastLoadedPage() + assert(page?.data?.size == 1) assertEquals( - uiState, - listOf( - PledgedProjectsOverviewUIState(isLoading = false, isErrored = false, totalAlerts = 0), - PledgedProjectsOverviewUIState(isLoading = true, isErrored = false, totalAlerts = 0), - PledgedProjectsOverviewUIState(isLoading = false, isErrored = false, totalAlerts = 10) - ) + totalAlertsList, + listOf(0, 10) ) } + } }