add trending content to home pages

This commit is contained in:
Owen LeJeune
2023-06-24 00:01:47 -04:00
parent 6fa7a27f1c
commit ef11a88a90
26 changed files with 375 additions and 97 deletions

View File

@@ -53,7 +53,7 @@ class BasePagingSource<T: Any, S>(
nextKey = if (results.isEmpty()) { null } else { page + 1} nextKey = if (results.isEmpty()) { null } else { page + 1}
) )
} else { } else {
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()
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -1,6 +1,7 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3 package com.owenlejeune.tvtime.api.tmdb.api.v3
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* import com.owenlejeune.tvtime.api.tmdb.api.v3.model.*
import com.owenlejeune.tvtime.utils.types.TimeWindow
import retrofit2.Response import retrofit2.Response
import retrofit2.http.* import retrofit2.http.*
@@ -67,4 +68,7 @@ interface MoviesApi {
@GET("discover/movie") @GET("discover/movie")
suspend fun discover(@Query("with_keywords") keywords: String? = null, @Query("page") page: Int): Response<SearchResult<SearchResultMovie>> suspend fun discover(@Query("with_keywords") keywords: String? = null, @Query("page") page: Int): Response<SearchResult<SearchResultMovie>>
@GET("trending/movie/{time_window}")
suspend fun trending(@Path("time_window") timeWindow: String, @Query("page") page: Int): Response<SearchResult<SearchResultMovie>>
} }

View File

@@ -28,6 +28,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.types.TimeWindow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@@ -137,6 +138,10 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
} }
} }
suspend fun getTrending(timeWindow: TimeWindow, page: Int): Response<out SearchResult<out SearchResultMedia>> {
return movieService.trending(timeWindow.name.lowercase(), page)
}
override suspend fun discover(keywords: String?, page: Int): Response<out SearchResult<out SearchResultMedia>> { override suspend fun discover(keywords: String?, page: Int): Response<out SearchResult<out SearchResultMedia>> {
return movieService.discover(keywords, page) return movieService.discover(keywords, page)
} }

View File

@@ -5,6 +5,9 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePeopleResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePeopleResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonCreditsResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonCreditsResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImageCollection import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImageCollection
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultPerson
import com.owenlejeune.tvtime.utils.types.TimeWindow
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
@@ -36,4 +39,7 @@ interface PeopleApi {
@GET("person/{id}/external_ids") @GET("person/{id}/external_ids")
suspend fun getExternalIds(@Path("id") id: Int): Response<ExternalIds> suspend fun getExternalIds(@Path("id") id: Int): Response<ExternalIds>
@GET("trending/person/{time_window}")
suspend fun trending(@Path("time_window") timeWindow: String, @Query("page") page: Int): Response<SearchResult<SearchResultPerson>>
} }

View File

@@ -12,6 +12,10 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePeopleResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonCreditsResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonCreditsResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImage import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImage
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImageCollection import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImageCollection
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMedia
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultPerson
import com.owenlejeune.tvtime.utils.types.TimeWindow
import okhttp3.internal.notify import okhttp3.internal.notify
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import retrofit2.Response import retrofit2.Response
@@ -90,4 +94,8 @@ class PeopleService: KoinComponent {
return service.getPopular(page) return service.getPopular(page)
} }
suspend fun getTrending(timeWindow: TimeWindow, page: Int): Response<SearchResult<SearchResultPerson>> {
return service.trending(timeWindow.name.lowercase(), page)
}
} }

View File

@@ -1,6 +1,7 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3 package com.owenlejeune.tvtime.api.tmdb.api.v3
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* import com.owenlejeune.tvtime.api.tmdb.api.v3.model.*
import com.owenlejeune.tvtime.utils.types.TimeWindow
import retrofit2.Response import retrofit2.Response
import retrofit2.http.* import retrofit2.http.*
@@ -69,4 +70,7 @@ interface TvApi {
@GET("discover/tv") @GET("discover/tv")
suspend fun discover(@Query("page") page: Int, @Query("with_keywords") keywords: String? = null): Response<SearchResult<SearchResultTv>> suspend fun discover(@Query("page") page: Int, @Query("with_keywords") keywords: String? = null): Response<SearchResult<SearchResultTv>>
@GET("trending/tv/{time_window}")
suspend fun trending(@Path("time_window") timeWindow: String, @Query("page") page: Int): Response<SearchResult<SearchResultTv>>
} }

View File

@@ -32,6 +32,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.VideoResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.types.TimeWindow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@@ -140,6 +141,10 @@ class TvService: KoinComponent, DetailService, HomePageService {
} }
} }
suspend fun getTrending(timeWindow: TimeWindow, page: Int): Response<out SearchResult<out SearchResultMedia>> {
return service.trending(timeWindow.name.lowercase(), page)
}
override suspend fun discover(keywords: String?, page: Int): Response<out SearchResult<out SearchResultMedia>> { override suspend fun discover(keywords: String?, page: Int): Response<out SearchResult<out SearchResultMedia>> {
return service.discover(page, keywords) return service.discover(page, keywords)
} }

View File

@@ -12,9 +12,9 @@ abstract class SearchResultMedia(
@SerializedName("genre_ids") val genreIds: List<Int>, @SerializedName("genre_ids") val genreIds: List<Int>,
@SerializedName("original_language") val originalLanguage: String, @SerializedName("original_language") val originalLanguage: String,
@SerializedName("original_name", alternate = ["original_title"]) val originalName: String, @SerializedName("original_name", alternate = ["original_title"]) val originalName: String,
@SerializedName("poster_path") val posterPath: String?, posterPath: String?,
type: MediaViewType, type: MediaViewType,
id: Int, id: Int,
name: String, name: String,
popularity: Float popularity: Float
): SortableSearchResult(type, popularity, id, name) ): SortableSearchResult(type, popularity, id, name, posterPath)

View File

@@ -4,10 +4,10 @@ import com.google.gson.annotations.SerializedName
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
class SearchResultPerson( class SearchResultPerson(
@SerializedName("profile_path") val profilePath: String,
@SerializedName("adult") val isAdult: Boolean, @SerializedName("adult") val isAdult: Boolean,
@SerializedName("known_for") val knownFor: List<KnownFor>, @SerializedName("known_for") val knownFor: List<KnownFor>,
profilePath: String,
id: Int, id: Int,
name: String, name: String,
popularity: Float popularity: Float
): SortableSearchResult(MediaViewType.PERSON, popularity, id, name) ): SortableSearchResult(MediaViewType.PERSON, popularity, id, name, profilePath)

View File

@@ -6,6 +6,7 @@ import com.owenlejeune.tvtime.utils.types.MediaViewType
abstract class SortableSearchResult( abstract class SortableSearchResult(
@SerializedName("media_type") val mediaType: MediaViewType, @SerializedName("media_type") val mediaType: MediaViewType,
@SerializedName("popularity") val popularity: Float, @SerializedName("popularity") val popularity: Float,
@SerializedName("id") val id: Int, id: Int,
@SerializedName("name", alternate = ["title"]) val name: String name: String,
): Searchable posterPath: String?
): TmdbItem(id, posterPath, name), Searchable

View File

@@ -4,6 +4,6 @@ import com.google.gson.annotations.SerializedName
abstract class TmdbItem( abstract class TmdbItem(
@SerializedName("id") val id: Int, @SerializedName("id") val id: Int,
@SerializedName("poster_path") val posterPath: String?, @SerializedName("poster_path", alternate = ["profile_path"]) val posterPath: String?,
@SerializedName("name", alternate = ["title"]) val title: String @SerializedName("name", alternate = ["title"]) val title: String
) )

View File

@@ -52,11 +52,13 @@ private val POSTER_HEIGHT = 180.dp
@Composable @Composable
fun PagingPosterGrid( fun PagingPosterGrid(
modifier: Modifier = Modifier,
lazyPagingItems: LazyPagingItems<TmdbItem>?, lazyPagingItems: LazyPagingItems<TmdbItem>?,
onClick: (id: Int) -> Unit = {} onClick: (id: Int) -> Unit = {}
) { ) {
lazyPagingItems?.let { lazyPagingItems?.let {
LazyVerticalGrid( LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Adaptive(minSize = POSTER_WIDTH), columns = GridCells.Adaptive(minSize = POSTER_WIDTH),
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween

View File

@@ -1117,15 +1117,18 @@ fun SelectableTextChip(
selected: Boolean, selected: Boolean,
onSelected: () -> Unit, onSelected: () -> Unit,
text: String, text: String,
modifier: Modifier = Modifier,
selectedColor: Color = MaterialTheme.colorScheme.secondary, selectedColor: Color = MaterialTheme.colorScheme.secondary,
unselectedColor: Color = MaterialTheme.colorScheme.surfaceVariant unselectedColor: Color = MaterialTheme.colorScheme.surfaceVariant
) { ) {
Box( Box(
modifier = Modifier modifier = modifier.then(
.clip(RoundedCornerShape(percent = 25)) Modifier
.border(width = 1.dp, color = selectedColor, shape = RoundedCornerShape(percent = 25)) .clip(RoundedCornerShape(percent = 25))
.background(color = if(selected) selectedColor else unselectedColor) .border(width = 1.dp, color = selectedColor, shape = RoundedCornerShape(percent = 25))
.clickable(onClick = onSelected) .background(color = if(selected) selectedColor else unselectedColor)
.clickable(onClick = onSelected)
)
) { ) {
Text( Text(
text = text, text = text,

View File

@@ -40,8 +40,8 @@ import com.owenlejeune.tvtime.ui.components.AccountIcon
import com.owenlejeune.tvtime.ui.components.MediaResultCard import com.owenlejeune.tvtime.ui.components.MediaResultCard
import com.owenlejeune.tvtime.ui.components.PagingPosterGrid import com.owenlejeune.tvtime.ui.components.PagingPosterGrid
import com.owenlejeune.tvtime.ui.components.ScrollableTabs import com.owenlejeune.tvtime.ui.components.ScrollableTabs
import com.owenlejeune.tvtime.ui.navigation.AccountTabNavItem
import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.screens.tabs.AccountTabNavItem
import com.owenlejeune.tvtime.ui.viewmodel.AccountViewModel import com.owenlejeune.tvtime.ui.viewmodel.AccountViewModel
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.TmdbUtils

View File

@@ -79,7 +79,7 @@ fun KeywordResultsScreen(
id = result.id, id = result.id,
backdropPath = TmdbUtils.getFullBackdropPath(result.backdropPath), backdropPath = TmdbUtils.getFullBackdropPath(result.backdropPath),
posterPath = TmdbUtils.getFullPosterPath(result.posterPath), posterPath = TmdbUtils.getFullPosterPath(result.posterPath),
title = result.name, title = result.title,
additionalDetails = listOf(result.overview) additionalDetails = listOf(result.overview)
) )
} }

View File

@@ -204,17 +204,19 @@ fun PersonDetailScreen(
@Composable @Composable
private fun BiographyCard(person: DetailPerson?) { private fun BiographyCard(person: DetailPerson?) {
ExpandableContentCard { isExpanded -> if (person != null && person.biography.isNotEmpty()) {
Text( ExpandableContentCard { isExpanded ->
modifier = Modifier Text(
.fillMaxWidth() modifier = Modifier
.wrapContentHeight() .fillMaxWidth()
.padding(top = 12.dp, start = 16.dp, end = 16.dp), .wrapContentHeight()
text = person?.biography ?: "", .padding(top = 12.dp, start = 16.dp, end = 16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, text = person.biography,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (isExpanded) Int.MAX_VALUE else 3, style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis maxLines = if (isExpanded) Int.MAX_VALUE else 3,
) overflow = TextOverflow.Ellipsis
)
}
} }
} }

View File

@@ -112,22 +112,22 @@ fun SearchScreen(
SelectableTextChip( SelectableTextChip(
selected = viewType.value == MediaViewType.MOVIE, selected = viewType.value == MediaViewType.MOVIE,
onSelected = { viewType.value = MediaViewType.MOVIE }, onSelected = { viewType.value = MediaViewType.MOVIE },
text = "Movies" text = stringResource(id = R.string.nav_movies_title)
) )
SelectableTextChip( SelectableTextChip(
selected = viewType.value == MediaViewType.TV, selected = viewType.value == MediaViewType.TV,
onSelected = { viewType.value = MediaViewType.TV }, onSelected = { viewType.value = MediaViewType.TV },
text = "TV" text = stringResource(id = R.string.nav_tv_title)
) )
SelectableTextChip( SelectableTextChip(
selected = viewType.value == MediaViewType.PERSON, selected = viewType.value == MediaViewType.PERSON,
onSelected = { viewType.value = MediaViewType.PERSON }, onSelected = { viewType.value = MediaViewType.PERSON },
text = "People" text = stringResource(id = R.string.nav_people_title)
) )
SelectableTextChip( SelectableTextChip(
selected = viewType.value == MediaViewType.MIXED, selected = viewType.value == MediaViewType.MIXED,
onSelected = { viewType.value = MediaViewType.MIXED }, onSelected = { viewType.value = MediaViewType.MIXED },
text = "Multi" text = stringResource(id = R.string.search_multi_title)
) )
} }
@@ -374,7 +374,7 @@ private fun <T: SortableSearchResult> SearchResultItemView(
id = searchResult.id, id = searchResult.id,
backdropPath = backdropModel(searchResult), backdropPath = backdropModel(searchResult),
posterPath = posterModel(searchResult), posterPath = posterModel(searchResult),
title = searchResult.name, title = searchResult.title,
additionalDetails = additionalDetails(searchResult) additionalDetails = additionalDetails(searchResult)
) )
} }
@@ -453,7 +453,7 @@ private fun PeopleSearchResultView(
appNavController = appNavController, appNavController = appNavController,
mediaViewType = MediaViewType.PERSON, mediaViewType = MediaViewType.PERSON,
searchResult = result, searchResult = result,
posterModel = { TmdbUtils.getFullPersonImagePath(result.profilePath) }, posterModel = { TmdbUtils.getFullPersonImagePath(result.posterPath) },
backdropModel = { TmdbUtils.getFullBackdropPath(mostKnownFor?.backdropPath) }, backdropModel = { TmdbUtils.getFullBackdropPath(mostKnownFor?.backdropPath) },
additionalDetails = { additional } additionalDetails = { additional }
) )

View File

@@ -1,4 +1,4 @@
package com.owenlejeune.tvtime.ui.navigation package com.owenlejeune.tvtime.ui.screens.tabs
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -84,7 +84,7 @@ sealed class AccountTabNavItem(
R.string.no_favorite_movies, R.string.no_favorite_movies,
MediaViewType.MOVIE, MediaViewType.MOVIE,
screenContent, screenContent,
AccountTabType.FAVORITE, AccountTabType.FAVORITE,
FavoriteMovie::class, FavoriteMovie::class,
3 3
) )

View File

@@ -1,8 +1,16 @@
package com.owenlejeune.tvtime.ui.screens.tabs package com.owenlejeune.tvtime.ui.screens.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -13,13 +21,14 @@ import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.ui.components.PagingPosterGrid import com.owenlejeune.tvtime.ui.components.PagingPosterGrid
import com.owenlejeune.tvtime.ui.components.ScrollableTabs
import com.owenlejeune.tvtime.ui.components.SearchView import com.owenlejeune.tvtime.ui.components.SearchView
import com.owenlejeune.tvtime.ui.components.Tabs import com.owenlejeune.tvtime.ui.components.SelectableTextChip
import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.navigation.MediaTabNavItem
import com.owenlejeune.tvtime.ui.viewmodel.HomeScreenViewModel import com.owenlejeune.tvtime.ui.viewmodel.HomeScreenViewModel
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.utils.types.TimeWindow
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalPagerApi::class)
@Composable @Composable
@@ -44,7 +53,7 @@ fun MediaTab(
val tabs = MediaTabNavItem.itemsForType(type = mediaType) val tabs = MediaTabNavItem.itemsForType(type = mediaType)
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
Tabs(tabs = tabs, pagerState = pagerState) ScrollableTabs(tabs = tabs, pagerState = pagerState)
MediaTabs( MediaTabs(
tabs = tabs, tabs = tabs,
pagerState = pagerState, pagerState = pagerState,
@@ -73,6 +82,52 @@ fun MediaTabContent(
) )
} }
@Composable
fun MediaTabTrendingContent(
appNavController: NavHostController,
mediaType: MediaViewType,
mediaTabItem: MediaTabNavItem
) {
val viewModel = viewModel<MainViewModel>()
val timeWindow = remember { mutableStateOf(TimeWindow.DAY) }
val flow = remember { mutableStateOf(viewModel.produceTrendingFor(mediaType, timeWindow.value)) }
LaunchedEffect(timeWindow.value) {
flow.value = viewModel.produceTrendingFor(mediaType, timeWindow.value)
}
val mediaListItems = flow.value.collectAsLazyPagingItems()
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(all = 12.dp)
) {
SelectableTextChip(
selected = timeWindow.value == TimeWindow.DAY,
onSelected = { timeWindow.value = TimeWindow.DAY },
text = stringResource(id = R.string.time_window_day),
modifier = Modifier.weight(1f)
)
SelectableTextChip(
selected = timeWindow.value == TimeWindow.WEEK,
onSelected = { timeWindow.value = TimeWindow.WEEK },
text = stringResource(id = R.string.time_window_week),
modifier = Modifier.weight(1f)
)
}
PagingPosterGrid(
lazyPagingItems = mediaListItems,
onClick = { id ->
appNavController.navigate(
AppNavItem.DetailView.withArgs(mediaType, id)
)
}
)
}
}
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalPagerApi::class)
@Composable @Composable
fun MediaTabs( fun MediaTabs(

View File

@@ -1,4 +1,4 @@
package com.owenlejeune.tvtime.ui.navigation package com.owenlejeune.tvtime.ui.screens.tabs
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -6,6 +6,7 @@ import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.HomePageService import com.owenlejeune.tvtime.api.tmdb.api.v3.HomePageService
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePageResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePageResponse
import com.owenlejeune.tvtime.ui.screens.tabs.MediaTabContent import com.owenlejeune.tvtime.ui.screens.tabs.MediaTabContent
import com.owenlejeune.tvtime.ui.screens.tabs.MediaTabTrendingContent
import com.owenlejeune.tvtime.utils.ResourceUtils import com.owenlejeune.tvtime.utils.ResourceUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.utils.types.TabNavItem import com.owenlejeune.tvtime.utils.types.TabNavItem
@@ -27,12 +28,13 @@ sealed class MediaTabNavItem(
POPULAR, POPULAR,
NOW_PLAYING, NOW_PLAYING,
UPCOMING, UPCOMING,
TOP_RATED TOP_RATED,
TRENDING
} }
companion object { companion object {
val MovieItems = listOf(NowPlaying, Popular, Upcoming, TopRated) private val MovieItems = listOf(NowPlaying, Popular, Trending, Upcoming, TopRated)
val TvItems = listOf(OnTheAir, Popular, AiringToday, TopRated) private val TvItems = listOf(OnTheAir, Popular, Trending, AiringToday, TopRated)
fun itemsForType(type: MediaViewType): List<MediaTabNavItem> { fun itemsForType(type: MediaViewType): List<MediaTabNavItem> {
return when (type) { return when (type) {
@@ -41,12 +43,6 @@ sealed class MediaTabNavItem(
else -> throw ViewableMediaTypeException(type) else -> throw ViewableMediaTypeException(type)
} }
} }
private val Items = listOf(NowPlaying, Popular, TopRated, Upcoming, AiringToday, OnTheAir)
fun getByRoute(route: String?): MediaTabNavItem? {
return Items.firstOrNull { it.route == route }
}
} }
object Popular: MediaTabNavItem( object Popular: MediaTabNavItem(
@@ -85,12 +81,23 @@ sealed class MediaTabNavItem(
screen = screenContent, screen = screenContent,
type = Type.UPCOMING type = Type.UPCOMING
) )
object Trending: MediaTabNavItem(
stringRes = R.string.nav_trending_title,
route = "trending_route",
screen = trendingScreenContent,
type = Type.TRENDING
)
} }
private val screenContent: MediaNavComposableFun = { appNavController, mediaViewType, mediaTabItem -> private val screenContent: MediaNavComposableFun = { appNavController, mediaViewType, mediaTabItem ->
MediaTabContent(appNavController = appNavController, mediaType = mediaViewType, mediaTabItem = mediaTabItem) MediaTabContent(appNavController = appNavController, mediaType = mediaViewType, mediaTabItem = mediaTabItem)
} }
private val trendingScreenContent: MediaNavComposableFun = { appNavController, mediaViewType, mediaTabItem ->
MediaTabTrendingContent(appNavController = appNavController, mediaType = mediaViewType, mediaTabItem = mediaTabItem)
}
typealias MediaNavComposableFun = @Composable (NavHostController, MediaViewType, MediaTabNavItem) -> Unit typealias MediaNavComposableFun = @Composable (NavHostController, MediaViewType, MediaTabNavItem) -> Unit
typealias MediaFetchFun = suspend (service: HomePageService, page: Int) -> Response<out HomePageResponse> typealias MediaFetchFun = suspend (service: HomePageService, page: Int) -> Response<out HomePageResponse>

View File

@@ -1,19 +1,38 @@
package com.owenlejeune.tvtime.ui.screens.tabs package com.owenlejeune.tvtime.ui.screens.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.ui.components.PagingPeoplePosterGrid import com.owenlejeune.tvtime.ui.components.PagingPeoplePosterGrid
import com.owenlejeune.tvtime.ui.components.PagingPosterGrid
import com.owenlejeune.tvtime.ui.components.SearchView import com.owenlejeune.tvtime.ui.components.SearchView
import com.owenlejeune.tvtime.ui.components.SelectableTextChip
import com.owenlejeune.tvtime.ui.components.Tabs
import com.owenlejeune.tvtime.ui.navigation.AppNavItem import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.viewmodel.HomeScreenViewModel import com.owenlejeune.tvtime.ui.viewmodel.HomeScreenViewModel
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.utils.types.TimeWindow
@OptIn(ExperimentalPagerApi::class)
@Composable @Composable
fun PeopleTab( fun PeopleTab(
appNavController: NavHostController, appNavController: NavHostController,
@@ -28,17 +47,69 @@ fun PeopleTab(
mediaType = MediaViewType.PERSON mediaType = MediaViewType.PERSON
) )
val mainViewModel = viewModel<MainViewModel>() val tabs = PeopleTabNavItem.Items
val peopleList = mainViewModel.popularPeople.collectAsLazyPagingItems() val pagerState = rememberPagerState()
Tabs(tabs = tabs, pagerState = pagerState)
PeopleTabs(
tabs = tabs,
pagerState = pagerState,
appNavController = appNavController
)
}
}
PagingPeoplePosterGrid( @Composable
lazyPagingItems = peopleList, fun PopularPeopleContent(
header = { appNavController: NavController
// Text( ) {
// text = stringResource(R.string.popular_today_header), val mainViewModel = viewModel<MainViewModel>()
// modifier = Modifier.padding(start = 8.dp) val peopleList = mainViewModel.popularPeople.collectAsLazyPagingItems()
// )
}, PagingPeoplePosterGrid(
lazyPagingItems = peopleList,
onClick = { id ->
appNavController.navigate(
AppNavItem.DetailView.withArgs(MediaViewType.PERSON, id)
)
}
)
}
@Composable
fun TrendingPeopleContent(
appNavController: NavController
) {
val viewModel = viewModel<MainViewModel>()
val timeWindow = remember { mutableStateOf(TimeWindow.DAY) }
val flow = remember { mutableStateOf(viewModel.produceTrendingFor(MediaViewType.PERSON, timeWindow.value)) }
LaunchedEffect(timeWindow.value) {
flow.value = viewModel.produceTrendingFor(MediaViewType.PERSON, timeWindow.value)
}
val mediaListItems = flow.value.collectAsLazyPagingItems()
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(all = 12.dp)
) {
SelectableTextChip(
selected = timeWindow.value == TimeWindow.DAY,
onSelected = { timeWindow.value = TimeWindow.DAY },
text = stringResource(id = R.string.time_window_day),
modifier = Modifier.weight(1f)
)
SelectableTextChip(
selected = timeWindow.value == TimeWindow.WEEK,
onSelected = { timeWindow.value = TimeWindow.WEEK },
text = stringResource(id = R.string.time_window_week),
modifier = Modifier.weight(1f)
)
}
PagingPosterGrid(
lazyPagingItems = mediaListItems,
onClick = { id -> onClick = { id ->
appNavController.navigate( appNavController.navigate(
AppNavItem.DetailView.withArgs(MediaViewType.PERSON, id) AppNavItem.DetailView.withArgs(MediaViewType.PERSON, id)
@@ -46,4 +117,16 @@ fun PeopleTab(
} }
) )
} }
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun PeopleTabs(
tabs: List<PeopleTabNavItem>,
pagerState: PagerState,
appNavController: NavHostController = rememberNavController()
) {
HorizontalPager(count = tabs.size, state = pagerState) { page ->
tabs[page].screen(appNavController)
}
} }

View File

@@ -0,0 +1,34 @@
package com.owenlejeune.tvtime.ui.screens.tabs
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.utils.ResourceUtils
import com.owenlejeune.tvtime.utils.types.TabNavItem
import org.koin.core.component.inject
sealed class PeopleTabNavItem(
stringRes: Int,
route: String,
val screen: @Composable (NavController) -> Unit
): TabNavItem(route) {
private val resourceUtils: ResourceUtils by inject()
override val name: String = resourceUtils.getString(stringRes)
companion object {
val Items by lazy { listOf(Popular, Trending) }
}
object Popular: PeopleTabNavItem(
stringRes = R.string.popular_today_header,
route = "people_popular_route",
screen = { PopularPeopleContent(appNavController = it) }
)
object Trending: PeopleTabNavItem(
stringRes = R.string.nav_trending_title,
route = "people_trending_route",
screen = { TrendingPeopleContent(appNavController = it) }
)
}

View File

@@ -11,7 +11,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v4.ListV4Service
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.DeleteListItemsBody import com.owenlejeune.tvtime.api.tmdb.api.v4.model.DeleteListItemsBody
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.DeleteListItemsItem import com.owenlejeune.tvtime.api.tmdb.api.v4.model.DeleteListItemsItem
import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListUpdateBody import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListUpdateBody
import com.owenlejeune.tvtime.ui.navigation.AccountTabNavItem import com.owenlejeune.tvtime.ui.screens.tabs.AccountTabNavItem
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
import com.owenlejeune.tvtime.utils.types.ViewableMediaTypeException import com.owenlejeune.tvtime.utils.types.ViewableMediaTypeException

View File

@@ -19,8 +19,9 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMedia
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders
import com.owenlejeune.tvtime.ui.navigation.MediaTabNavItem import com.owenlejeune.tvtime.ui.screens.tabs.MediaTabNavItem
import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.MediaViewType
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 org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@@ -46,22 +47,30 @@ class MainViewModel: ViewModel(), KoinComponent {
val movieAccountStates = movieService.accountStates val movieAccountStates = movieService.accountStates
val movieKeywordResults = movieService.keywordResults val movieKeywordResults = movieService.keywordResults
val popularMovies = createPagingFlow( val popularMovies by lazy {
fetcher = { p -> movieService.getPopular(p) }, createPagingFlow(
processor = { it.results } fetcher = { p -> movieService.getPopular(p) },
) processor = { it.results }
val topRatedMovies = createPagingFlow( )
fetcher = { p -> movieService.getTopRated(p) }, }
processor = { it.results } val topRatedMovies by lazy {
) createPagingFlow(
val nowPlayingMovies = createPagingFlow( fetcher = { p -> movieService.getTopRated(p) },
fetcher = { p -> movieService.getNowPlaying(p) }, processor = { it.results }
processor = { it.results } )
) }
val upcomingMovies = createPagingFlow( val nowPlayingMovies by lazy {
fetcher = { p -> movieService.getUpcoming(p) }, createPagingFlow(
processor = { it.results } fetcher = { p -> movieService.getNowPlaying(p) },
) processor = { it.results }
)
}
val upcomingMovies by lazy {
createPagingFlow(
fetcher = { p -> movieService.getUpcoming(p) },
processor = { it.results }
)
}
val detailedTv = tvService.detailTv val detailedTv = tvService.detailTv
val tvImages = tvService.images val tvImages = tvService.images
@@ -78,22 +87,30 @@ class MainViewModel: ViewModel(), KoinComponent {
val tvAccountStates = tvService.accountStates val tvAccountStates = tvService.accountStates
val tvKeywordResults = tvService.keywordResults val tvKeywordResults = tvService.keywordResults
val popularTv = createPagingFlow( val popularTv by lazy {
fetcher = { p -> tvService.getPopular(p) }, createPagingFlow(
processor = { it.results } fetcher = { p -> tvService.getPopular(p) },
) processor = { it.results }
val topRatedTv = createPagingFlow( )
fetcher = { p -> tvService.getTopRated(p) }, }
processor = { it.results } val topRatedTv by lazy{
) createPagingFlow(
val airingTodayTv = createPagingFlow( fetcher = { p -> tvService.getTopRated(p) },
fetcher = { p -> tvService.getNowPlaying(p) }, processor = { it.results }
processor = { it.results } )
) }
val onTheAirTv = createPagingFlow( val airingTodayTv by lazy {
fetcher = { p -> tvService.getUpcoming(p) }, createPagingFlow(
processor = { it.results } fetcher = { p -> tvService.getNowPlaying(p) },
) processor = { it.results }
)
}
val onTheAirTv by lazy {
createPagingFlow(
fetcher = { p -> tvService.getUpcoming(p) },
processor = { it.results }
)
}
val peopleMap = peopleService.peopleMap val peopleMap = peopleService.peopleMap
val peopleCastMap = peopleService.castMap val peopleCastMap = peopleService.castMap
@@ -101,10 +118,12 @@ class MainViewModel: ViewModel(), KoinComponent {
val peopleImagesMap = peopleService.imagesMap val peopleImagesMap = peopleService.imagesMap
val peopleExternalIdsMap = peopleService.externalIdsMap val peopleExternalIdsMap = peopleService.externalIdsMap
val popularPeople = createPagingFlow( val popularPeople by lazy {
fetcher = { p -> peopleService.getPopular(p) }, createPagingFlow(
processor = { it.results } fetcher = { p -> peopleService.getPopular(p) },
) processor = { it.results }
)
}
private fun <T> providesForType(type: MediaViewType, movies: () -> T, tv: () -> T): T { private fun <T> providesForType(type: MediaViewType, movies: () -> T, tv: () -> T): T {
return when (type) { return when (type) {
@@ -167,6 +186,7 @@ class MainViewModel: ViewModel(), KoinComponent {
MediaTabNavItem.Type.TOP_RATED -> topRatedMovies MediaTabNavItem.Type.TOP_RATED -> topRatedMovies
MediaTabNavItem.Type.NOW_PLAYING -> nowPlayingMovies MediaTabNavItem.Type.NOW_PLAYING -> nowPlayingMovies
MediaTabNavItem.Type.POPULAR -> popularMovies MediaTabNavItem.Type.POPULAR -> popularMovies
else -> throw Exception("Can't produce media flow for $mediaType")
} }
}, },
{ {
@@ -175,6 +195,7 @@ class MainViewModel: ViewModel(), KoinComponent {
MediaTabNavItem.Type.TOP_RATED -> topRatedTv MediaTabNavItem.Type.TOP_RATED -> topRatedTv
MediaTabNavItem.Type.NOW_PLAYING -> airingTodayTv MediaTabNavItem.Type.NOW_PLAYING -> airingTodayTv
MediaTabNavItem.Type.POPULAR -> popularTv MediaTabNavItem.Type.POPULAR -> popularTv
else -> throw Exception("Can't produce media flow for $mediaType")
} }
} }
) )
@@ -184,6 +205,30 @@ class MainViewModel: ViewModel(), KoinComponent {
return providesForType(mediaType, { movieKeywordResults }, { tvKeywordResults }) return providesForType(mediaType, { movieKeywordResults }, { tvKeywordResults })
} }
fun produceTrendingFor(mediaType: MediaViewType, timeWindow: TimeWindow): Flow<PagingData<TmdbItem>> {
return when (mediaType) {
MediaViewType.MOVIE -> {
createPagingFlow(
fetcher = { p -> movieService.getTrending(timeWindow, p) },
processor = { it.results }
)
}
MediaViewType.TV -> {
createPagingFlow(
fetcher = { p -> tvService.getTrending(timeWindow, p) },
processor = { it.results }
)
}
MediaViewType.PERSON -> {
createPagingFlow(
fetcher = { p -> peopleService.getTrending(timeWindow, p) },
processor = { it.results }
)
}
else -> throw ViewableMediaTypeException(mediaType)
}
}
suspend fun getById(id: Int, type: MediaViewType) { suspend fun getById(id: Int, type: MediaViewType) {
when (type) { when (type) {
MediaViewType.MOVIE -> movieService.getById(id) MediaViewType.MOVIE -> movieService.getById(id)

View File

@@ -0,0 +1,10 @@
package com.owenlejeune.tvtime.utils.types
import com.google.gson.annotations.SerializedName
enum class TimeWindow {
@SerializedName("day")
DAY,
@SerializedName("week")
WEEK
}

View File

@@ -12,6 +12,7 @@
<string name="nav_upcoming_title">Upcoming</string> <string name="nav_upcoming_title">Upcoming</string>
<string name="nav_tv_airing_today_title">Airing Today</string> <string name="nav_tv_airing_today_title">Airing Today</string>
<string name="nav_tv_on_the_air">On The Air</string> <string name="nav_tv_on_the_air">On The Air</string>
<string name="nav_trending_title">Trending</string>
<string name="nav_account_title">Account</string> <string name="nav_account_title">Account</string>
<string name="nav_rated_movies_title">Rated Movies</string> <string name="nav_rated_movies_title">Rated Movies</string>
<string name="nav_rated_shows_title">Rated TV Shows</string> <string name="nav_rated_shows_title">Rated TV Shows</string>
@@ -216,6 +217,9 @@
<string name="recommended_tv_title">Recommended TV</string> <string name="recommended_tv_title">Recommended TV</string>
<string name="no_recommended_tv">No Recommended TV</string> <string name="no_recommended_tv">No Recommended TV</string>
<string name="no_result_found">No more results found</string> <string name="no_result_found">No more results found</string>
<string name="search_multi_title">Multi</string>
<string name="time_window_day">Day</string>
<string name="time_window_week">Week</string>
<string name="attribution_text">This product uses the TMDB API but is not endorsed or certified by TMDB.</string> <string name="attribution_text">This product uses the TMDB API but is not endorsed or certified by TMDB.</string>
<string name="tmdb_home_page">"https://www.themoviedb.org"</string> <string name="tmdb_home_page">"https://www.themoviedb.org"</string>