From 084a22dd7f9423472d61de62d9b0040e95483bee Mon Sep 17 00:00:00 2001 From: Owen LeJeune Date: Sun, 30 Jul 2023 21:37:46 -0400 Subject: [PATCH] add pull to refresh to home screen --- .../tvtime/api/tmdb/api/BasePagingSource.kt | 16 ++- .../tvtime/api/tmdb/api/v3/MoviesService.kt | 6 + .../tvtime/api/tmdb/api/v3/PeopleService.kt | 4 + .../tvtime/api/tmdb/api/v3/TvService.kt | 6 + .../tvtime/ui/screens/tabs/MediaTab.kt | 106 ++++++++++++------ .../tvtime/ui/viewmodel/MainViewModel.kt | 86 ++++++++++++-- 6 files changed, 177 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/BasePagingSource.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/BasePagingSource.kt index c193722..dbbbf38 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/BasePagingSource.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/BasePagingSource.kt @@ -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 ViewModel.createPagingFlow( fetcher: suspend (Int) -> Response, - processor: (S) -> List + processor: (S) -> List, + refreshState: MutableState = mutableStateOf(false) ): Flow> { return Pager(PagingConfig(pageSize = ViewModelConstants.PAGING_SIZE)) { BasePagingSource( fetcher = fetcher, - processor = processor + processor = processor, + refreshState = refreshState ) }.flow.cachedIn(viewModelScope) } class BasePagingSource( private val fetcher: suspend (Int) -> Response, - private val processor: (S) -> List + private val processor: (S) -> List, + private val refreshState: MutableState ): PagingSource(), KoinComponent { private val context: Context by inject() @@ -43,16 +48,21 @@ class BasePagingSource( override suspend fun load(params: LoadParams): LoadResult { 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() } diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt index 20873a7..2ed24a0 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/MoviesService.kt @@ -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) }, diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt index 6034fdc..df1637b 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/PeopleService.kt @@ -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) }, diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt index 9437d0d..e613114 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt @@ -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) }, diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/tabs/MediaTab.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/tabs/MediaTab.kt index 3623164..4f323a3 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/tabs/MediaTab.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/tabs/MediaTab.kt @@ -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() 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) diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt index f7f7aee..e7f5cb4 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt @@ -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 { + 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>> { 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 { + return when (mediaType) { + MediaViewType.MOVIE -> isTrendingMoviesLoading + MediaViewType.TV -> isTrendingTvLoading + MediaViewType.PERSON -> isTrendingPeopleLoading + else -> throw ViewableMediaTypeException(mediaType) + } + } + fun produceDetailsLoadingStateFor(mediaType: MediaViewType): MutableState { return providesForType(mediaType, { movieDetailsLoadingState }, { tvDetailsLoadingState}) }