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

View File

@@ -63,6 +63,12 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
val releaseDatesLoadingState = mutableStateOf(LoadingState.INACTIVE) val releaseDatesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val accountStatesLoadingState = 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) { override suspend fun getById(id: Int, refreshing: Boolean) {
loadRemoteData( loadRemoteData(
{ movieService.getMovieById(id) }, { movieService.getMovieById(id) },

View File

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

View File

@@ -101,6 +101,12 @@ class TvService: KoinComponent, DetailService, HomePageService {
val seasonVideosLoadingState = mutableStateOf(LoadingState.INACTIVE) val seasonVideosLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonWatchProvidersLoadingState = 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) { override suspend fun getById(id: Int, refreshing: Boolean) {
loadRemoteData( loadRemoteData(
{ service.getTvShowById(id) }, { service.getTvShowById(id) },

View File

@@ -1,12 +1,18 @@
package com.owenlejeune.tvtime.ui.screens.tabs package com.owenlejeune.tvtime.ui.screens.tabs
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -63,6 +69,7 @@ fun MediaTab(
} }
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun MediaTabContent( fun MediaTabContent(
appNavController: NavHostController, appNavController: NavHostController,
@@ -71,17 +78,34 @@ fun MediaTabContent(
) { ) {
val viewModel = viewModel<MainViewModel>() val viewModel = viewModel<MainViewModel>()
val mediaListItems = viewModel.produceMediaTabFlowFor(mediaType, mediaTabItem.type).collectAsLazyPagingItems() val mediaListItems = viewModel.produceMediaTabFlowFor(mediaType, mediaTabItem.type).collectAsLazyPagingItems()
val isRefreshing = remember { viewModel.produceMediaTabLoadingFor(mediaType, mediaTabItem.type) }
PagingPosterGrid( val pullRefreshState = rememberPullRefreshState(
lazyPagingItems = mediaListItems, refreshing = isRefreshing.value,
onClick = { id -> onRefresh = { mediaListItems.refresh() }
appNavController.navigate(
AppNavItem.DetailView.withArgs(mediaType, id)
)
}
) )
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 @Composable
fun MediaTabTrendingContent( fun MediaTabTrendingContent(
appNavController: NavHostController, appNavController: NavHostController,
@@ -92,6 +116,7 @@ fun MediaTabTrendingContent(
val timeWindow = remember { mutableStateOf(TimeWindow.DAY) } val timeWindow = remember { mutableStateOf(TimeWindow.DAY) }
val flow = remember { mutableStateOf(viewModel.produceTrendingFor(mediaType, timeWindow.value)) } val flow = remember { mutableStateOf(viewModel.produceTrendingFor(mediaType, timeWindow.value)) }
val isRefreshing = remember { viewModel.produceTrendingLoadingFor(mediaType) }
LaunchedEffect(timeWindow.value) { LaunchedEffect(timeWindow.value) {
flow.value = viewModel.produceTrendingFor(mediaType, timeWindow.value) flow.value = viewModel.produceTrendingFor(mediaType, timeWindow.value)
@@ -99,33 +124,50 @@ fun MediaTabTrendingContent(
val mediaListItems = flow.value.collectAsLazyPagingItems() val mediaListItems = flow.value.collectAsLazyPagingItems()
PagingPosterGrid( val pullRefreshState = rememberPullRefreshState(
lazyPagingItems = mediaListItems, refreshing = isRefreshing.value,
headerContent = { onRefresh = {
val options = listOf(TimeWindow.DAY, TimeWindow.WEEK) mediaListItems.refresh()
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)
)
} }
) )
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) @OptIn(ExperimentalPagerApi::class)

View File

@@ -3,6 +3,7 @@ package com.owenlejeune.tvtime.ui.viewmodel
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.paging.PagingData 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.TimeWindow
import com.owenlejeune.tvtime.utils.types.ViewableMediaTypeException import com.owenlejeune.tvtime.utils.types.ViewableMediaTypeException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.cancellable
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@@ -62,28 +64,38 @@ class MainViewModel: ViewModel(), KoinComponent {
val movieReleaseDatesLoadingState = movieService.releaseDatesLoadingState val movieReleaseDatesLoadingState = movieService.releaseDatesLoadingState
val movieAccountStatesLoadingState = movieService.accountStatesLoadingState 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 { val popularMovies by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> movieService.getPopular(p) }, fetcher = { p -> movieService.getPopular(p) },
processor = { it.results } processor = { it.results },
refreshState = isPopularMoviesLoading
) )
} }
val topRatedMovies by lazy { val topRatedMovies by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> movieService.getTopRated(p) }, fetcher = { p -> movieService.getTopRated(p) },
processor = { it.results } processor = { it.results },
refreshState = isTopRatedMoviesLoading
) )
} }
val nowPlayingMovies by lazy { val nowPlayingMovies by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> movieService.getNowPlaying(p) }, fetcher = { p -> movieService.getNowPlaying(p) },
processor = { it.results } processor = { it.results },
refreshState = isNowPlayingMoviesLoading
) )
} }
val upcomingMovies by lazy { val upcomingMovies by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> movieService.getUpcoming(p) }, 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 tvSeasonVideosLoadingState = tvService.seasonVideosLoadingState
val tvSeasonWatchProvidersLoadingState = tvService.seasonWatchProvidersLoadingState 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 { val popularTv by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> tvService.getPopular(p) }, fetcher = { p -> tvService.getPopular(p) },
processor = { it.results } processor = { it.results },
isPopularTvLoading
) )
} }
val topRatedTv by lazy{ val topRatedTv by lazy{
createPagingFlow( createPagingFlow(
fetcher = { p -> tvService.getTopRated(p) }, fetcher = { p -> tvService.getTopRated(p) },
processor = { it.results } processor = { it.results },
isTopRatedTvLoading
) )
} }
val airingTodayTv by lazy { val airingTodayTv by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> tvService.getNowPlaying(p) }, fetcher = { p -> tvService.getNowPlaying(p) },
processor = { it.results } processor = { it.results },
refreshState = isAiringTodayTvLoading
) )
} }
val onTheAirTv by lazy { val onTheAirTv by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> tvService.getUpcoming(p) }, 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 peopleImagesLoadingState = peopleService.imagesLoadingState
val peopleExternalIdsLoadingState = peopleService.externalIdsLoadingState val peopleExternalIdsLoadingState = peopleService.externalIdsLoadingState
val isPopularPeopleLoading = peopleService.isPopularPeopleLoading
val isTrendingPeopleLoading = peopleService.isTrendingPeopleLoading
val popularPeople by lazy { val popularPeople by lazy {
createPagingFlow( createPagingFlow(
fetcher = { p -> peopleService.getPopular(p) }, 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>>> { fun produceKeywordsResultsFor(mediaType: MediaViewType): Map<Int, Flow<PagingData<SearchResultMedia>>> {
return providesForType(mediaType, { movieKeywordResults }, { tvKeywordResults }) return providesForType(mediaType, { movieKeywordResults }, { tvKeywordResults })
} }
@@ -258,25 +308,37 @@ class MainViewModel: ViewModel(), KoinComponent {
MediaViewType.MOVIE -> { MediaViewType.MOVIE -> {
createPagingFlow( createPagingFlow(
fetcher = { p -> movieService.getTrending(timeWindow, p) }, fetcher = { p -> movieService.getTrending(timeWindow, p) },
processor = { it.results } processor = { it.results },
refreshState = isTrendingMoviesLoading
) )
} }
MediaViewType.TV -> { MediaViewType.TV -> {
createPagingFlow( createPagingFlow(
fetcher = { p -> tvService.getTrending(timeWindow, p) }, fetcher = { p -> tvService.getTrending(timeWindow, p) },
processor = { it.results } processor = { it.results },
refreshState = isTrendingTvLoading
) )
} }
MediaViewType.PERSON -> { MediaViewType.PERSON -> {
createPagingFlow( createPagingFlow(
fetcher = { p -> peopleService.getTrending(timeWindow, p) }, fetcher = { p -> peopleService.getTrending(timeWindow, p) },
processor = { it.results } processor = { it.results },
refreshState = isTrendingPeopleLoading
) )
} }
else -> throw ViewableMediaTypeException(mediaType) 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> { fun produceDetailsLoadingStateFor(mediaType: MediaViewType): MutableState<LoadingState> {
return providesForType(mediaType, { movieDetailsLoadingState }, { tvDetailsLoadingState}) return providesForType(mediaType, { movieDetailsLoadingState }, { tvDetailsLoadingState})
} }