add pull to refresh to home screen

This commit is contained in:
Owen LeJeune
2023-07-30 21:37:46 -04:00
parent 97c108f44c
commit 084a22dd7f
6 changed files with 177 additions and 47 deletions

View File

@@ -2,6 +2,8 @@ package com.owenlejeune.tvtime.api.tmdb.api
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
@@ -19,19 +21,22 @@ import retrofit2.Response
fun <T: Any, S> ViewModel.createPagingFlow(
fetcher: suspend (Int) -> Response<S>,
processor: (S) -> List<T>
processor: (S) -> List<T>,
refreshState: MutableState<Boolean> = mutableStateOf(false)
): Flow<PagingData<T>> {
return Pager(PagingConfig(pageSize = ViewModelConstants.PAGING_SIZE)) {
BasePagingSource(
fetcher = fetcher,
processor = processor
processor = processor,
refreshState = refreshState
)
}.flow.cachedIn(viewModelScope)
}
class BasePagingSource<T: Any, S>(
private val fetcher: suspend (Int) -> Response<S>,
private val processor: (S) -> List<T>
private val processor: (S) -> List<T>,
private val refreshState: MutableState<Boolean>
): PagingSource<Int, T>(), KoinComponent {
private val context: Context by inject()
@@ -43,16 +48,21 @@ class BasePagingSource<T: Any, S>(
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
return try {
val page = params.key ?: 1
if (page == 1) {
refreshState.value = true
}
val response = fetcher(page)
if (response.isSuccessful) {
val responseBody = response.body()
val results = responseBody?.let(processor) ?: emptyList()
refreshState.value = false
LoadResult.Page(
data = results,
prevKey = if (page == 1) { null } else { page - 1},
nextKey = if (results.isEmpty()) { null } else { page + 1}
)
} else {
refreshState.value = false
// Toast.makeText(context, context.getString(R.string.no_result_found), Toast.LENGTH_SHORT).show()
LoadResult.Invalid()
}

View File

@@ -63,6 +63,12 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
val releaseDatesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val accountStatesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val isPopularMoviesLoading = mutableStateOf(false)
val isTopRatedMoviesLoading = mutableStateOf(false)
val isNowPlayingMoviesLoading = mutableStateOf(false)
val isUpcomingMoviesLoading = mutableStateOf(false)
val isTrendingMoviesLoading = mutableStateOf(false)
override suspend fun getById(id: Int, refreshing: Boolean) {
loadRemoteData(
{ movieService.getMovieById(id) },

View File

@@ -35,6 +35,10 @@ class PeopleService: KoinComponent {
val imagesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val externalIdsLoadingState = mutableStateOf(LoadingState.INACTIVE)
val isPopularPeopleLoading = mutableStateOf(false)
val isTrendingPeopleLoading = mutableStateOf(false
)
suspend fun getPerson(id: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getPerson(id) },

View File

@@ -101,6 +101,12 @@ class TvService: KoinComponent, DetailService, HomePageService {
val seasonVideosLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonWatchProvidersLoadingState = mutableStateOf(LoadingState.INACTIVE)
val isPopularTvLoading = mutableStateOf(false)
val isTopRatedTvLoading = mutableStateOf(false)
val isAiringTodayTvLoading = mutableStateOf(false)
val isOnTheAirTvLoading = mutableStateOf(false)
val isTrendingTvLoading = mutableStateOf(false)
override suspend fun getById(id: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getTvShowById(id) },

View File

@@ -1,12 +1,18 @@
package com.owenlejeune.tvtime.ui.screens.tabs
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -63,6 +69,7 @@ fun MediaTab(
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MediaTabContent(
appNavController: NavHostController,
@@ -71,17 +78,34 @@ fun MediaTabContent(
) {
val viewModel = viewModel<MainViewModel>()
val mediaListItems = viewModel.produceMediaTabFlowFor(mediaType, mediaTabItem.type).collectAsLazyPagingItems()
val isRefreshing = remember { viewModel.produceMediaTabLoadingFor(mediaType, mediaTabItem.type) }
PagingPosterGrid(
lazyPagingItems = mediaListItems,
onClick = { id ->
appNavController.navigate(
AppNavItem.DetailView.withArgs(mediaType, id)
)
}
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing.value,
onRefresh = { mediaListItems.refresh() }
)
Box(
modifier = Modifier.pullRefresh(state = pullRefreshState)
) {
PagingPosterGrid(
lazyPagingItems = mediaListItems,
onClick = { id ->
appNavController.navigate(
AppNavItem.DetailView.withArgs(mediaType, id)
)
}
)
PullRefreshIndicator(
refreshing = isRefreshing.value,
state = pullRefreshState,
modifier = Modifier.align(alignment = Alignment.TopCenter)
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MediaTabTrendingContent(
appNavController: NavHostController,
@@ -92,6 +116,7 @@ fun MediaTabTrendingContent(
val timeWindow = remember { mutableStateOf(TimeWindow.DAY) }
val flow = remember { mutableStateOf(viewModel.produceTrendingFor(mediaType, timeWindow.value)) }
val isRefreshing = remember { viewModel.produceTrendingLoadingFor(mediaType) }
LaunchedEffect(timeWindow.value) {
flow.value = viewModel.produceTrendingFor(mediaType, timeWindow.value)
@@ -99,33 +124,50 @@ fun MediaTabTrendingContent(
val mediaListItems = flow.value.collectAsLazyPagingItems()
PagingPosterGrid(
lazyPagingItems = mediaListItems,
headerContent = {
val options = listOf(TimeWindow.DAY, TimeWindow.WEEK)
val context = LocalContext.current
PillSegmentedControl(
items = options,
itemLabel = { _, i ->
when (i) {
TimeWindow.DAY -> context.getString(R.string.time_window_day)
TimeWindow.WEEK -> context.getString(R.string.time_window_week)
}
},
onItemSelected = { _, i -> timeWindow.value = i },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.padding(bottom = 4.dp)
)
},
onClick = { id ->
appNavController.navigate(
AppNavItem.DetailView.withArgs(mediaType, id)
)
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing.value,
onRefresh = {
mediaListItems.refresh()
}
)
Box(
modifier = Modifier.pullRefresh(pullRefreshState)
) {
PagingPosterGrid(
lazyPagingItems = mediaListItems,
headerContent = {
val options = listOf(TimeWindow.DAY, TimeWindow.WEEK)
val context = LocalContext.current
PillSegmentedControl(
items = options,
itemLabel = { _, i ->
when (i) {
TimeWindow.DAY -> context.getString(R.string.time_window_day)
TimeWindow.WEEK -> context.getString(R.string.time_window_week)
}
},
onItemSelected = { _, i -> timeWindow.value = i },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.padding(bottom = 4.dp)
)
},
onClick = { id ->
appNavController.navigate(
AppNavItem.DetailView.withArgs(mediaType, id)
)
}
)
PullRefreshIndicator(
refreshing = isRefreshing.value,
state = pullRefreshState,
modifier = Modifier.align(alignment = Alignment.TopCenter)
)
}
}
@OptIn(ExperimentalPagerApi::class)

View File

@@ -3,6 +3,7 @@ package com.owenlejeune.tvtime.ui.viewmodel
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.paging.PagingData
@@ -28,6 +29,7 @@ import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.utils.types.TimeWindow
import com.owenlejeune.tvtime.utils.types.ViewableMediaTypeException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.cancellable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -62,28 +64,38 @@ class MainViewModel: ViewModel(), KoinComponent {
val movieReleaseDatesLoadingState = movieService.releaseDatesLoadingState
val movieAccountStatesLoadingState = movieService.accountStatesLoadingState
val isPopularMoviesLoading = movieService.isPopularMoviesLoading
val isTopRatedMoviesLoading = movieService.isTopRatedMoviesLoading
val isNowPlayingMoviesLoading = movieService.isNowPlayingMoviesLoading
val isUpcomingMoviesLoading = movieService.isUpcomingMoviesLoading
val isTrendingMoviesLoading = movieService.isTrendingMoviesLoading
val popularMovies by lazy {
createPagingFlow(
fetcher = { p -> movieService.getPopular(p) },
processor = { it.results }
processor = { it.results },
refreshState = isPopularMoviesLoading
)
}
val topRatedMovies by lazy {
createPagingFlow(
fetcher = { p -> movieService.getTopRated(p) },
processor = { it.results }
processor = { it.results },
refreshState = isTopRatedMoviesLoading
)
}
val nowPlayingMovies by lazy {
createPagingFlow(
fetcher = { p -> movieService.getNowPlaying(p) },
processor = { it.results }
processor = { it.results },
refreshState = isNowPlayingMoviesLoading
)
}
val upcomingMovies by lazy {
createPagingFlow(
fetcher = { p -> movieService.getUpcoming(p) },
processor = { it.results }
processor = { it.results },
refreshState = isUpcomingMoviesLoading
)
}
@@ -125,28 +137,38 @@ class MainViewModel: ViewModel(), KoinComponent {
val tvSeasonVideosLoadingState = tvService.seasonVideosLoadingState
val tvSeasonWatchProvidersLoadingState = tvService.seasonWatchProvidersLoadingState
val isPopularTvLoading = tvService.isPopularTvLoading
val isTopRatedTvLoading = tvService.isTopRatedTvLoading
val isAiringTodayTvLoading = tvService.isAiringTodayTvLoading
val isOnTheAirTvLoading = tvService.isOnTheAirTvLoading
val isTrendingTvLoading = tvService.isTrendingTvLoading
val popularTv by lazy {
createPagingFlow(
fetcher = { p -> tvService.getPopular(p) },
processor = { it.results }
processor = { it.results },
isPopularTvLoading
)
}
val topRatedTv by lazy{
createPagingFlow(
fetcher = { p -> tvService.getTopRated(p) },
processor = { it.results }
processor = { it.results },
isTopRatedTvLoading
)
}
val airingTodayTv by lazy {
createPagingFlow(
fetcher = { p -> tvService.getNowPlaying(p) },
processor = { it.results }
processor = { it.results },
refreshState = isAiringTodayTvLoading
)
}
val onTheAirTv by lazy {
createPagingFlow(
fetcher = { p -> tvService.getUpcoming(p) },
processor = { it.results }
processor = { it.results },
refreshState = isOnTheAirTvLoading
)
}
@@ -161,10 +183,14 @@ class MainViewModel: ViewModel(), KoinComponent {
val peopleImagesLoadingState = peopleService.imagesLoadingState
val peopleExternalIdsLoadingState = peopleService.externalIdsLoadingState
val isPopularPeopleLoading = peopleService.isPopularPeopleLoading
val isTrendingPeopleLoading = peopleService.isTrendingPeopleLoading
val popularPeople by lazy {
createPagingFlow(
fetcher = { p -> peopleService.getPopular(p) },
processor = { it.results }
processor = { it.results },
refreshState = isPopularPeopleLoading
)
}
@@ -249,6 +275,30 @@ class MainViewModel: ViewModel(), KoinComponent {
)
}
fun produceMediaTabLoadingFor(mediaType: MediaViewType, contentType: MediaTabNavItem.Type): MutableState<Boolean> {
return providesForType(
mediaType,
{
when (contentType) {
MediaTabNavItem.Type.UPCOMING -> isUpcomingMoviesLoading
MediaTabNavItem.Type.TOP_RATED -> isTopRatedMoviesLoading
MediaTabNavItem.Type.NOW_PLAYING -> isNowPlayingMoviesLoading
MediaTabNavItem.Type.POPULAR -> isPopularMoviesLoading
else -> throw Exception("Can't produce media flow for $mediaType")
}
},
{
when (contentType) {
MediaTabNavItem.Type.UPCOMING -> isOnTheAirTvLoading
MediaTabNavItem.Type.TOP_RATED -> isTopRatedTvLoading
MediaTabNavItem.Type.NOW_PLAYING -> isAiringTodayTvLoading
MediaTabNavItem.Type.POPULAR -> isPopularTvLoading
else -> throw Exception("Can't produce media flow for $mediaType")
}
}
)
}
fun produceKeywordsResultsFor(mediaType: MediaViewType): Map<Int, Flow<PagingData<SearchResultMedia>>> {
return providesForType(mediaType, { movieKeywordResults }, { tvKeywordResults })
}
@@ -258,25 +308,37 @@ class MainViewModel: ViewModel(), KoinComponent {
MediaViewType.MOVIE -> {
createPagingFlow(
fetcher = { p -> movieService.getTrending(timeWindow, p) },
processor = { it.results }
processor = { it.results },
refreshState = isTrendingMoviesLoading
)
}
MediaViewType.TV -> {
createPagingFlow(
fetcher = { p -> tvService.getTrending(timeWindow, p) },
processor = { it.results }
processor = { it.results },
refreshState = isTrendingTvLoading
)
}
MediaViewType.PERSON -> {
createPagingFlow(
fetcher = { p -> peopleService.getTrending(timeWindow, p) },
processor = { it.results }
processor = { it.results },
refreshState = isTrendingPeopleLoading
)
}
else -> throw ViewableMediaTypeException(mediaType)
}
}
fun produceTrendingLoadingFor(mediaType: MediaViewType): MutableState<Boolean> {
return when (mediaType) {
MediaViewType.MOVIE -> isTrendingMoviesLoading
MediaViewType.TV -> isTrendingTvLoading
MediaViewType.PERSON -> isTrendingPeopleLoading
else -> throw ViewableMediaTypeException(mediaType)
}
}
fun produceDetailsLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieDetailsLoadingState }, { tvDetailsLoadingState})
}