add ability to rate as guest and see rated content

This commit is contained in:
Owen LeJeune
2022-02-28 19:19:26 -05:00
parent 11a1b5c68d
commit be23d58b1d
22 changed files with 408 additions and 172 deletions

View File

@@ -17,4 +17,8 @@ interface DetailService {
suspend fun getReviews(id: Int): Response<ReviewResponse> suspend fun getReviews(id: Int): Response<ReviewResponse>
suspend fun postRating(id: Int, ratingBody: RatingBody): Response<RatingResponse>
suspend fun deleteRating(id: Int): Response<RatingResponse>
} }

View File

@@ -1,18 +1,13 @@
package com.owenlejeune.tvtime.api.tmdb package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.* import com.owenlejeune.tvtime.api.tmdb.model.*
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import retrofit2.Response import retrofit2.Response
class MoviesService: KoinComponent, DetailService, HomePageService { class MoviesService: KoinComponent, DetailService, HomePageService {
private val preferences: AppPreferences by inject()
private val movieService by lazy { TmdbClient().createMovieService() } private val movieService by lazy { TmdbClient().createMovieService() }
private val authService by lazy { TmdbClient().createAuthenticationService() }
override suspend fun getPopular(page: Int): Response<out HomePageResponse> { override suspend fun getPopular(page: Int): Response<out HomePageResponse> {
return movieService.getPopularMovies(page) return movieService.getPopularMovies(page)
@@ -58,7 +53,7 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
return movieService.getReviews(id) return movieService.getReviews(id)
} }
suspend fun postRating(id: Int, rating: RatingBody): Response<RatingResponse> { override suspend fun postRating(id: Int, rating: RatingBody): Response<RatingResponse> {
val session = SessionManager.currentSession val session = SessionManager.currentSession
return if (session.isGuest) { return if (session.isGuest) {
movieService.postMovieRatingAsGuest(id, session.sessionId, rating) movieService.postMovieRatingAsGuest(id, session.sessionId, rating)
@@ -67,7 +62,7 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
} }
} }
suspend fun deleteRating(id: Int, rating: RatingBody): Response<RatingResponse> { override suspend fun deleteRating(id: Int): Response<RatingResponse> {
val session = SessionManager.currentSession val session = SessionManager.currentSession
return if (session.isGuest) { return if (session.isGuest) {
movieService.deleteMovieReviewAsGuest(id, session.sessionId) movieService.deleteMovieReviewAsGuest(id, session.sessionId)

View File

@@ -15,8 +15,6 @@ class TmdbClient: KoinComponent {
companion object { companion object {
const val BASE_URL = "https://api.themoviedb.org/3/" const val BASE_URL = "https://api.themoviedb.org/3/"
private val SUPPORTED_LANGUAGES = listOf("en", "fr")
} }
private val client: Client by inject { parametersOf(BASE_URL) } private val client: Client by inject { parametersOf(BASE_URL) }
@@ -50,12 +48,8 @@ class TmdbClient: KoinComponent {
val apiParam = QueryParam("api_key", BuildConfig.TMDB_ApiKey) val apiParam = QueryParam("api_key", BuildConfig.TMDB_ApiKey)
val locale = Locale.current val locale = Locale.current
val languageParam = if (SUPPORTED_LANGUAGES.contains(locale.language)) {
val languageCode = "${locale.language}-${locale.region}" val languageCode = "${locale.language}-${locale.region}"
QueryParam("language", languageCode) val languageParam = QueryParam("language", languageCode)
} else {
null
}
val request = chain.addQueryParams(apiParam, languageParam) val request = chain.addQueryParams(apiParam, languageParam)

View File

@@ -2,9 +2,7 @@ package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.* import com.owenlejeune.tvtime.api.tmdb.model.*
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.*
import retrofit2.http.Path
import retrofit2.http.Query
interface TvApi { interface TvApi {
@@ -38,7 +36,33 @@ interface TvApi {
@GET("tv/{id}/videos") @GET("tv/{id}/videos")
suspend fun getVideos(@Path("id") id: Int): Response<VideoResponse> suspend fun getVideos(@Path("id") id: Int): Response<VideoResponse>
@GET("movie/{id}/reviews") @GET("tv/{id}/reviews")
suspend fun getReviews(@Path("id") id: Int): Response<ReviewResponse> suspend fun getReviews(@Path("id") id: Int): Response<ReviewResponse>
@POST("tv/{id}/rating")
suspend fun postTvRatingAsGuest(
@Path("id") id: Int,
@Query("guest_session_id") guestSessionId: String,
@Body ratingBody: RatingBody
): Response<RatingResponse>
@POST("tv/{id}/rating")
suspend fun postTvRatingAsUser(
@Path("id") id: Int,
@Query("session_id") sessionId: String,
@Body ratingBody: RatingBody
): Response<RatingResponse>
@DELETE("tv/{id}/rating")
suspend fun deleteTvReviewAsGuest(
@Path("id") id: Int,
@Query("guest_session_id") guestSessionId: String
): Response<RatingResponse>
@DELETE("tv/{id}/rating")
suspend fun deleteTvReviewAsUser(
@Path("id") id: Int,
@Query("session_id") sessionId: String
): Response<RatingResponse>
} }

View File

@@ -1,6 +1,7 @@
package com.owenlejeune.tvtime.api.tmdb package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.* import com.owenlejeune.tvtime.api.tmdb.model.*
import com.owenlejeune.tvtime.utils.SessionManager
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import retrofit2.Response import retrofit2.Response
@@ -52,4 +53,22 @@ class TvService: KoinComponent, DetailService, HomePageService {
return service.getReviews(id) return service.getReviews(id)
} }
override suspend fun postRating(id: Int, rating: RatingBody): Response<RatingResponse> {
val session = SessionManager.currentSession
return if (session.isGuest) {
service.postTvRatingAsGuest(id, session.sessionId, rating)
} else {
service.postTvRatingAsUser(id, session.sessionId, rating)
}
}
override suspend fun deleteRating(id: Int): Response<RatingResponse> {
val session = SessionManager.currentSession
return if (session.isGuest) {
service.deleteTvReviewAsGuest(id, session.sessionId)
} else {
service.deleteTvReviewAsUser(id, session.sessionId)
}
}
} }

View File

@@ -3,7 +3,25 @@ package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
class RatedMedia( class RatedMedia(
@SerializedName("name", alternate = ["title"]) val title: String,
@SerializedName("id") val id: Int, @SerializedName("id") val id: Int,
@SerializedName("poster_path") val posterPath: String?,
@SerializedName("backdrop_path") val backdropPath: String?,
@SerializedName("release_date", alternate = ["first_air_date", "air_date"]) val releaseDate: String,
@SerializedName("rating") val rating: Float,
@SerializedName("genre_ids") val genreIds: List<Int>,
@SerializedName("original_language") val originalLanguage: String,
@SerializedName("original_title") val originalTitle: String,
@SerializedName("overview") val overview: String,
@SerializedName("popularity") val popularity: Float,
@SerializedName("vote_average") val voteAverage: Float,
@SerializedName("vote_count") val voteCount: Int,
// only for movies
@SerializedName("adult") val isAdult: Boolean?,
@SerializedName("video") val isVideo: Boolean?,
// only for tv
@SerializedName("origin_country") val originCountries: List<String>?,
var type: Type var type: Type
) { ) {

View File

@@ -3,5 +3,8 @@ package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
class RatedMediaResponse( class RatedMediaResponse(
@SerializedName("results") val results: List<RatedMedia> @SerializedName("page") val page: Int,
@SerializedName("results") val results: List<RatedMedia>,
@SerializedName("total_pages") val totalPages: Int,
@SerializedName("total_results") val totalResults: Int
) )

View File

@@ -19,8 +19,8 @@ fun SliderWithLabel(
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
onValueChanged: (Float) -> Unit, onValueChanged: (Float) -> Unit,
sliderLabel: String, sliderLabel: String,
step: Int = 0, steps: Int = 0,
labelMinWidth: Dp = 24.dp labelMinWidth: Dp = 36.dp
) { ) {
Column { Column {
BoxWithConstraints( BoxWithConstraints(
@@ -32,15 +32,13 @@ fun SliderWithLabel(
value = value, value = value,
valueRange = valueRange, valueRange = valueRange,
boxWidth = maxWidth, boxWidth = maxWidth,
labelWidth = labelMinWidth + 8.dp // Since we use a padding of 4.dp on either sides of the SliderLabel, we need to account for this in our calculation labelWidth = labelMinWidth + 8.dp
) )
// if (value > valueRange.start) {
SliderLabel( SliderLabel(
label = sliderLabel, minWidth = labelMinWidth, modifier = Modifier label = sliderLabel, minWidth = labelMinWidth, modifier = Modifier
.padding(start = offset) .padding(start = offset)
) )
// }
} }
Slider( Slider(
@@ -48,7 +46,7 @@ fun SliderWithLabel(
onValueChange = onValueChanged, onValueChange = onValueChanged,
valueRange = valueRange, valueRange = valueRange,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
steps = step, steps = steps,
colors = SliderDefaults.colors( colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary, thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary activeTrackColor = MaterialTheme.colorScheme.primary

View File

@@ -597,7 +597,7 @@ fun AvatarImage(
) { ) {
Text( Text(
modifier = Modifier.fillMaxSize().padding(top = size/5), modifier = Modifier.fillMaxSize().padding(top = size/5),
text = author.name[0].uppercase(), text = if (author.name.isNotEmpty()) author.name[0].uppercase() else author.username[0].toString(),
color = MaterialTheme.colorScheme.onTertiary, color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge

View File

@@ -0,0 +1,34 @@
package com.owenlejeune.tvtime.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHost
import androidx.navigation.NavHostController
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.model.RatedMedia
import com.owenlejeune.tvtime.ui.screens.MediaViewType
import com.owenlejeune.tvtime.ui.screens.tabs.bottom.AccountTabContent
import com.owenlejeune.tvtime.utils.ResourceUtils
import com.owenlejeune.tvtime.utils.SessionManager
import org.koin.core.component.inject
sealed class AccountTabNavItem(stringRes: Int, route: String, val mediaType: MediaViewType, val screen: AccountNavComposableFun, val listFetchFun: ListFetchFun): TabNavItem(route) {
private val resourceUtils: ResourceUtils by inject()
override val name = resourceUtils.getString(stringRes)
companion object {
val GuestItems = listOf(RatedMovies, RatedTvShows)//, RatedTvEpisodes)
}
object RatedMovies: AccountTabNavItem(R.string.nav_rated_movies_title, "rated_movies_route", MediaViewType.MOVIE, screenContent, { SessionManager.currentSession.ratedMovies } )
object RatedTvShows: AccountTabNavItem(R.string.nav_rated_shows_title, "rated_shows_route", MediaViewType.TV, screenContent, { SessionManager.currentSession.ratedTvShows } )
object RatedTvEpisodes: AccountTabNavItem(R.string.nav_rated_episodes_title, "rated_episodes_route", MediaViewType.EPISODE, screenContent, { SessionManager.currentSession.ratedTvEpisodes } )
}
private val screenContent: AccountNavComposableFun = { appNavController, mediaViewType, listFetchFun ->
AccountTabContent(appNavController = appNavController, mediaViewType = mediaViewType, listFetchFun = listFetchFun)
}
typealias ListFetchFun = () -> List<RatedMedia>
typealias AccountNavComposableFun = @Composable (NavHostController, MediaViewType, ListFetchFun) -> Unit

View File

@@ -1,51 +0,0 @@
package com.owenlejeune.tvtime.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.HomePageService
import com.owenlejeune.tvtime.api.tmdb.model.HomePageResponse
import com.owenlejeune.tvtime.ui.screens.MediaViewType
import com.owenlejeune.tvtime.ui.screens.tabs.bottom.MediaTabContent
import com.owenlejeune.tvtime.utils.ResourceUtils
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import retrofit2.Response
typealias NavComposableFun = @Composable (NavHostController, MediaViewType, MediaFetchFun) -> Unit
private val screenContent: NavComposableFun = { appNavController, mediaViewType, mediaFetchFun ->
MediaTabContent(appNavController = appNavController, mediaType = mediaViewType, mediaFetchFun = mediaFetchFun)
}
typealias MediaFetchFun = suspend (service: HomePageService, page: Int) -> Response<out HomePageResponse>
abstract class TabNavItem(val route: String, val screen: NavComposableFun, val mediaFetchFun: MediaFetchFun): KoinComponent {
abstract val name: String
}
sealed class MainTabNavItem(stringRes: Int, route: String, screen: NavComposableFun, mediaFetchFun: MediaFetchFun)
: TabNavItem(route, screen, mediaFetchFun)
{
private val resourceUtils: ResourceUtils by inject()
override val name = resourceUtils.getString(stringRes)
companion object {
val MovieItems = listOf(Popular, TopRated, NowPlaying, Upcoming)
val TvItems = listOf(Popular, TopRated, AiringToday, OnTheAir)
private val Items = listOf(NowPlaying, Popular, TopRated, Upcoming, AiringToday, OnTheAir)
fun getByRoute(route: String?): MainTabNavItem? {
return Items.firstOrNull { it.route == route }
}
}
object Popular: MainTabNavItem(R.string.nav_popular_title, "popular_route", screenContent, { s, p -> s.getPopular(p) } )
object TopRated: MainTabNavItem(R.string.nav_top_rated_title, "top_rated_route", screenContent, { s, p -> s.getTopRated(p) } )
object NowPlaying: MainTabNavItem(R.string.nav_now_playing_title, "now_playing_route", screenContent, { s, p -> s.getNowPlaying(p) } )
object Upcoming: MainTabNavItem(R.string.nav_upcoming_title, "upcoming_route", screenContent, { s, p -> s.getUpcoming(p) } )
object AiringToday: MainTabNavItem(R.string.nav_tv_airing_today_title, "airing_today_route", screenContent, { s, p -> s.getNowPlaying(p) } )
object OnTheAir: MainTabNavItem(R.string.nav_tv_on_the_air, "on_the_air_route", screenContent, { s, p -> s.getUpcoming(p) } )
}

View File

@@ -0,0 +1,44 @@
package com.owenlejeune.tvtime.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.HomePageService
import com.owenlejeune.tvtime.api.tmdb.model.HomePageResponse
import com.owenlejeune.tvtime.ui.screens.MediaViewType
import com.owenlejeune.tvtime.ui.screens.tabs.bottom.MediaTabContent
import com.owenlejeune.tvtime.utils.ResourceUtils
import org.koin.core.component.inject
import retrofit2.Response
sealed class MediaTabNavItem(stringRes: Int, route: String, val screen: MediaNavComposableFun, val mediaFetchFun: MediaFetchFun): TabNavItem(route) {
private val resourceUtils: ResourceUtils by inject()
override val name = resourceUtils.getString(stringRes)
companion object {
val MovieItems = listOf(Popular, TopRated, NowPlaying, Upcoming)
val TvItems = listOf(Popular, TopRated, AiringToday, OnTheAir)
private val Items = listOf(NowPlaying, Popular, TopRated, Upcoming, AiringToday, OnTheAir)
fun getByRoute(route: String?): MediaTabNavItem? {
return Items.firstOrNull { it.route == route }
}
}
object Popular: MediaTabNavItem(R.string.nav_popular_title, "popular_route", screenContent, { s, p -> s.getPopular(p) } )
object TopRated: MediaTabNavItem(R.string.nav_top_rated_title, "top_rated_route", screenContent, { s, p -> s.getTopRated(p) } )
object NowPlaying: MediaTabNavItem(R.string.nav_now_playing_title, "now_playing_route", screenContent, { s, p -> s.getNowPlaying(p) } )
object Upcoming: MediaTabNavItem(R.string.nav_upcoming_title, "upcoming_route", screenContent, { s, p -> s.getUpcoming(p) } )
object AiringToday: MediaTabNavItem(R.string.nav_tv_airing_today_title, "airing_today_route", screenContent, { s, p -> s.getNowPlaying(p) } )
object OnTheAir: MediaTabNavItem(R.string.nav_tv_on_the_air, "on_the_air_route", screenContent, { s, p -> s.getUpcoming(p) } )
}
private val screenContent: MediaNavComposableFun = { appNavController, mediaViewType, mediaFetchFun ->
MediaTabContent(appNavController = appNavController, mediaType = mediaViewType, mediaFetchFun = mediaFetchFun)
}
typealias MediaNavComposableFun = @Composable (NavHostController, MediaViewType, MediaFetchFun) -> Unit
typealias MediaFetchFun = suspend (service: HomePageService, page: Int) -> Response<out HomePageResponse>

View File

@@ -58,7 +58,8 @@ fun MainNavigationRoutes(navController: NavHostController, displayUnderStatusBar
@Composable @Composable
fun BottomNavigationRoutes( fun BottomNavigationRoutes(
appNavController: NavHostController, appNavController: NavHostController,
navController: NavHostController navController: NavHostController,
appBarTitle: MutableState<String>
) { ) {
NavHost(navController = navController, startDestination = BottomNavItem.Movies.route) { NavHost(navController = navController, startDestination = BottomNavItem.Movies.route) {
composable(BottomNavItem.Movies.route) { composable(BottomNavItem.Movies.route) {
@@ -68,7 +69,7 @@ fun BottomNavigationRoutes(
MediaTab(appNavController = appNavController, mediaType = MediaViewType.TV) MediaTab(appNavController = appNavController, mediaType = MediaViewType.TV)
} }
composable(BottomNavItem.Account.route) { composable(BottomNavItem.Account.route) {
AccountTab() AccountTab(appBarTitle = appBarTitle, appNavController = appNavController)
} }
composable(BottomNavItem.Favourites.route) { composable(BottomNavItem.Favourites.route) {
FavouritesTab() FavouritesTab()

View File

@@ -0,0 +1,7 @@
package com.owenlejeune.tvtime.ui.navigation
import org.koin.core.component.KoinComponent
abstract class TabNavItem(val route: String): KoinComponent {
abstract val name: String
}

View File

@@ -42,6 +42,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.DecimalFormat import java.text.DecimalFormat
@Composable @Composable
@@ -400,7 +401,7 @@ private fun ContentColumn(
MiscTvDetails(mediaItem = mediaItem, service as TvService) MiscTvDetails(mediaItem = mediaItem, service as TvService)
} }
ActionsView(itemId = itemId, type = mediaType) ActionsView(itemId = itemId, type = mediaType, service = service)
if (mediaItem.value?.overview?.isNotEmpty() == true) { if (mediaItem.value?.overview?.isNotEmpty() == true) {
OverviewCard(mediaItem = mediaItem) OverviewCard(mediaItem = mediaItem)
@@ -500,9 +501,9 @@ private fun MiscDetails(
private fun ActionsView( private fun ActionsView(
itemId: Int?, itemId: Int?,
type: MediaViewType, type: MediaViewType,
service: DetailService,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current
itemId?.let { itemId?.let {
val session = SessionManager.currentSession val session = SessionManager.currentSession
Row( Row(
@@ -510,23 +511,13 @@ private fun ActionsView(
.fillMaxWidth(), .fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
val itemIsRated = if (type == MediaViewType.MOVIE) { RateButton(
session.hasRatedMovie(itemId)
} else {
session.hasRatedTvShow(itemId)
}
val showRatingDialog = remember { mutableStateOf(false) }
ActionButton(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
text = if (itemIsRated) stringResource(R.string.delete_rating_action_label) else stringResource(R.string.rate_action_label), itemId = itemId,
onClick = { type = type,
showRatingDialog.value = true service = service
}
) )
RatingDialog(showDialog = showRatingDialog, onValueConfirmed = { rating ->
// todo post rating
Toast.makeText(context, "Rating :${rating}", Toast.LENGTH_SHORT).show()
})
if (!session.isGuest) { if (!session.isGuest) {
ActionButton( ActionButton(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -555,11 +546,69 @@ private fun ActionButton(modifier: Modifier, text: String, onClick: () -> Unit)
} }
} }
@Composable
private fun RateButton(
itemId: Int,
type: MediaViewType,
service: DetailService,
modifier: Modifier = Modifier
) {
val session = SessionManager.currentSession
val context = LocalContext.current
var itemIsRated by remember {
mutableStateOf(
if (type == MediaViewType.MOVIE) {
session.hasRatedMovie(itemId)
} else {
session.hasRatedTvShow(itemId)
}
)
}
val showRatingDialog = remember { mutableStateOf(false) }
ActionButton(
modifier = modifier,
text = if (itemIsRated) stringResource(R.string.delete_rating_action_label) else stringResource(R.string.rate_action_label),
onClick = {
if (!itemIsRated) {
showRatingDialog.value = true
} else {
CoroutineScope(Dispatchers.IO).launch {
val response = service.deleteRating(itemId)
if (response.isSuccessful) {
withContext(Dispatchers.Main) {
itemIsRated = false
}
}
SessionManager.currentSession.refresh()
}
}
}
)
RatingDialog(showDialog = showRatingDialog, onValueConfirmed = { rating ->
CoroutineScope(Dispatchers.IO).launch {
val response = service.postRating(itemId, RatingBody(rating = rating))
if (response.isSuccessful) {
SessionManager.currentSession.refresh()
withContext(Dispatchers.Main) {
itemIsRated = true
}
} else {
withContext(Dispatchers.Main) {
val errorObj = JSONObject(response.errorBody().toString())
Toast.makeText(context, "Error: ${errorObj.getString("status_message")}", Toast.LENGTH_SHORT).show()
}
}
}
})
}
@Composable @Composable
private fun RatingDialog(showDialog: MutableState<Boolean>, onValueConfirmed: (Float) -> Unit) { private fun RatingDialog(showDialog: MutableState<Boolean>, onValueConfirmed: (Float) -> Unit) {
fun formatPosition(position: Float): String { fun formatPosition(position: Float): String {
return DecimalFormat("#.#").format(position/10f) return DecimalFormat("#.#").format(position.toInt()*5/10f)
} }
if (showDialog.value) { if (showDialog.value) {
@@ -592,9 +641,11 @@ private fun RatingDialog(showDialog: MutableState<Boolean>, onValueConfirmed: (F
text = { text = {
SliderWithLabel( SliderWithLabel(
value = sliderPosition, value = sliderPosition,
valueRange = 0f..100f, valueRange = 0f..20f,
onValueChanged = { sliderPosition = it }, onValueChanged = {
sliderLabel = formatPosition(sliderPosition) sliderPosition = it
},
sliderLabel = "${sliderPosition.toInt() * 5}%",
) )
} }
) )

View File

@@ -86,7 +86,7 @@ fun MainAppView(appNavController: NavHostController, preferences: AppPreferences
} }
) { innerPadding -> ) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) { Box(modifier = Modifier.padding(innerPadding)) {
BottomNavigationRoutes(appNavController = appNavController, navController = navController) BottomNavigationRoutes(appNavController = appNavController, navController = navController, appBarTitle = appBarTitle)
} }
} }
} }
@@ -173,9 +173,7 @@ private fun BottomNavBar(navController: NavController, appBarTitle: MutableState
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
NavigationBar( NavigationBar {
containerColor = MaterialTheme.colorScheme.primaryContainer
) {
BottomNavItem.Items.forEach { item -> BottomNavItem.Items.forEach { item ->
NavigationBarItem( NavigationBarItem(
icon = { Icon(painter = painterResource(id = item.icon), contentDescription = null) }, icon = { Icon(painter = painterResource(id = item.icon), contentDescription = null) },
@@ -187,17 +185,10 @@ private fun BottomNavBar(navController: NavController, appBarTitle: MutableState
appBarTitle = appBarTitle, appBarTitle = appBarTitle,
item = item item = item
) )
}, }
colors = NavigationBarItemDefaults
.colors(
selectedIconColor = MaterialTheme.colorScheme.secondary,
indicatorColor = MaterialTheme.colorScheme.onSecondary
)
) )
} }
} }
appBarTitle.value = BottomNavItem.getByRoute(currentRoute)?.name ?: ""
} }
private fun onBottomAppBarItemClicked( private fun onBottomAppBarItemClicked(

View File

@@ -7,5 +7,6 @@ enum class MediaViewType {
MOVIE, MOVIE,
@SerializedName("tv") @SerializedName("tv")
TV, TV,
PERSON PERSON,
EPISODE
} }

View File

@@ -1,24 +1,109 @@
package com.owenlejeune.tvtime.ui.screens.tabs.bottom package com.owenlejeune.tvtime.ui.screens.tabs.bottom
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import coil.compose.rememberImagePainter
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.ui.navigation.AccountTabNavItem
import com.owenlejeune.tvtime.ui.navigation.ListFetchFun
import com.owenlejeune.tvtime.ui.navigation.MainNavItem
import com.owenlejeune.tvtime.ui.screens.MediaViewType
import com.owenlejeune.tvtime.ui.screens.tabs.top.Tabs
import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils
@OptIn(ExperimentalPagerApi::class)
@Composable @Composable
fun AccountTab() { fun AccountTab(appNavController: NavHostController, appBarTitle: MutableState<String>) {
Column( if (SessionManager.currentSession.isGuest) {
modifier = Modifier appBarTitle.value = "Hello, Guest"
.fillMaxSize() }
.wrapContentSize(Alignment.Center)
) { val tabs = if (SessionManager.currentSession.isGuest) {
Text( AccountTabNavItem.GuestItems
text = "Account", } else {
color = MaterialTheme.colorScheme.onBackground AccountTabNavItem.GuestItems
}
Column {
val pagerState = rememberPagerState()
Tabs(tabs = tabs, pagerState = pagerState)
AccountTabs(
appNavController = appNavController,
tabs = tabs,
pagerState = pagerState
) )
} }
} }
@Composable
fun AccountTabContent(
appNavController: NavHostController,
mediaViewType: MediaViewType,
listFetchFun: ListFetchFun
) {
val contentItems = listFetchFun()
LazyColumn(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
items(contentItems.size) { i ->
val ratedItem = contentItems[i]
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.clickable(
onClick = {
appNavController.navigate(
"${MainNavItem.DetailView.route}/${mediaViewType}/${ratedItem.id}"
)
}
)
) {
Image(
modifier = Modifier
.width(60.dp)
.height(80.dp),
painter = rememberImagePainter(
data = TmdbUtils.getFullPosterPath(ratedItem.posterPath)
),
contentDescription = ""
)
Column(
modifier = Modifier.height(80.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Text(text = ratedItem.title, color = MaterialTheme.colorScheme.onBackground, fontSize = 18.sp)
Text(text = ratedItem.releaseDate, color = MaterialTheme.colorScheme.onBackground)
Text(text = "Rating: ${(ratedItem.rating*10).toInt()}%", color = MaterialTheme.colorScheme.onBackground)
}
}
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun AccountTabs(
tabs: List<AccountTabNavItem>,
pagerState: PagerState,
appNavController: NavHostController
) {
HorizontalPager(count = tabs.size, state = pagerState) { page ->
tabs[page].screen(appNavController, tabs[page].mediaType, tabs[page].listFetchFun)
}
}

View File

@@ -2,19 +2,22 @@ package com.owenlejeune.tvtime.ui.screens.tabs.bottom
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.pager.ExperimentalPagerApi 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.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.api.tmdb.HomePageService import com.owenlejeune.tvtime.api.tmdb.HomePageService
import com.owenlejeune.tvtime.api.tmdb.MoviesService import com.owenlejeune.tvtime.api.tmdb.MoviesService
import com.owenlejeune.tvtime.api.tmdb.TvService import com.owenlejeune.tvtime.api.tmdb.TvService
import com.owenlejeune.tvtime.ui.components.PosterGrid import com.owenlejeune.tvtime.ui.components.PosterGrid
import com.owenlejeune.tvtime.ui.navigation.MainNavItem import com.owenlejeune.tvtime.ui.navigation.MainNavItem
import com.owenlejeune.tvtime.ui.navigation.MainTabNavItem
import com.owenlejeune.tvtime.ui.navigation.MediaFetchFun import com.owenlejeune.tvtime.ui.navigation.MediaFetchFun
import com.owenlejeune.tvtime.ui.navigation.MediaTabNavItem
import com.owenlejeune.tvtime.ui.screens.MediaViewType import com.owenlejeune.tvtime.ui.screens.MediaViewType
import com.owenlejeune.tvtime.ui.screens.tabs.top.Tabs import com.owenlejeune.tvtime.ui.screens.tabs.top.Tabs
import com.owenlejeune.tvtime.ui.screens.tabs.top.TabsContent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -25,13 +28,13 @@ import kotlinx.coroutines.withContext
fun MediaTab(appNavController: NavHostController, mediaType: MediaViewType) { fun MediaTab(appNavController: NavHostController, mediaType: MediaViewType) {
Column { Column {
val tabs = when (mediaType) { val tabs = when (mediaType) {
MediaViewType.MOVIE -> MainTabNavItem.MovieItems MediaViewType.MOVIE -> MediaTabNavItem.MovieItems
MediaViewType.TV -> MainTabNavItem.TvItems MediaViewType.TV -> MediaTabNavItem.TvItems
else -> throw IllegalArgumentException("Media type given: ${mediaType}, \n expected one of MediaViewType.MOVIE, MediaViewType.TV") // shouldn't happen else -> throw IllegalArgumentException("Media type given: ${mediaType}, \n expected one of MediaViewType.MOVIE, MediaViewType.TV") // shouldn't happen
} }
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
Tabs(tabs = tabs, pagerState = pagerState) Tabs(tabs = tabs, pagerState = pagerState)
TabsContent( MediaTabs(
tabs = tabs, tabs = tabs,
pagerState = pagerState, pagerState = pagerState,
appNavController = appNavController, appNavController = appNavController,
@@ -66,6 +69,28 @@ fun MediaTabContent(appNavController: NavHostController, mediaType: MediaViewTyp
) )
} }
@OptIn(ExperimentalPagerApi::class)
@Composable
fun MediaTabs(
tabs: List<MediaTabNavItem>,
pagerState: PagerState,
mediaViewType: MediaViewType,
appNavController: NavHostController = rememberNavController()
) {
HorizontalPager(count = tabs.size, state = pagerState) { page ->
tabs[page].screen(appNavController, mediaViewType, tabs[page].mediaFetchFun)
}
}
@OptIn(ExperimentalPagerApi::class)
@Preview(showBackground = true)
@Composable
fun MediaTabsPreview() {
val tabs = MediaTabNavItem.MovieItems
val pagerState = rememberPagerState()
MediaTabs(tabs = tabs, pagerState = pagerState, MediaViewType.MOVIE)
}
// val moviesViewModel = viewModel(PopularMovieViewModel::class.java) // val moviesViewModel = viewModel(PopularMovieViewModel::class.java)
// val moviesList = moviesViewModel.moviePage // val moviesList = moviesViewModel.moviePage
// val movieListItems: LazyPagingItems<PopularMovie> = moviesList.collectAsLazyPagingItems() // val movieListItems: LazyPagingItems<PopularMovie> = moviesList.collectAsLazyPagingItems()

View File

@@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ScrollableTabRow
import androidx.compose.material.Tab import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -17,15 +17,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.ui.navigation.MainTabNavItem import com.owenlejeune.tvtime.ui.navigation.MediaTabNavItem
import com.owenlejeune.tvtime.ui.navigation.TabNavItem import com.owenlejeune.tvtime.ui.navigation.TabNavItem
import com.owenlejeune.tvtime.ui.screens.MediaViewType
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalPagerApi::class)
@@ -43,12 +39,11 @@ fun Tabs(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
ScrollableTabRow( TabRow(
modifier = modifier, modifier = modifier,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
contentColor = contentColor, contentColor = contentColor,
edgePadding = 8.dp,
indicator = { tabPositions -> indicator = { tabPositions ->
SmallTabIndicator( SmallTabIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage]), modifier = Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage]),
@@ -93,30 +88,8 @@ private fun SmallTabIndicator(
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun TabsPreview() { fun TabsPreview() {
val tabs = MainTabNavItem.MovieItems val tabs = MediaTabNavItem.MovieItems
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
Tabs(tabs = tabs, pagerState = pagerState) Tabs(tabs = tabs, pagerState = pagerState)
} }
@OptIn(ExperimentalPagerApi::class)
@Composable
fun TabsContent(
tabs: List<TabNavItem>,
pagerState: PagerState,
mediaViewType: MediaViewType,
appNavController: NavHostController = rememberNavController()
) {
HorizontalPager(count = tabs.size, state = pagerState) { page ->
tabs[page].screen(appNavController, mediaViewType, tabs[page].mediaFetchFun)
}
}
@OptIn(ExperimentalPagerApi::class)
@Preview(showBackground = true)
@Composable
fun TabsContentPreview() {
val tabs = MainTabNavItem.MovieItems
val pagerState = rememberPagerState()
TabsContent(tabs = tabs, pagerState = pagerState, MediaViewType.MOVIE)
}

View File

@@ -1,8 +1,11 @@
package com.owenlejeune.tvtime.utils package com.owenlejeune.tvtime.utils
import com.owenlejeune.tvtime.api.tmdb.GuestSessionApi
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.TmdbClient
import com.owenlejeune.tvtime.api.tmdb.model.RatedMedia import com.owenlejeune.tvtime.api.tmdb.model.RatedMedia
import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.preferences.AppPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@@ -61,6 +64,8 @@ object SessionManager: KoinComponent {
} }
abstract suspend fun initialize() abstract suspend fun initialize()
abstract suspend fun refresh()
} }
private class GuestSession: Session(preferences.guestSessionId, true) { private class GuestSession: Session(preferences.guestSessionId, true) {
@@ -68,21 +73,33 @@ object SessionManager: KoinComponent {
override var _ratedTvEpisodes: List<RatedMedia> = emptyList() override var _ratedTvEpisodes: List<RatedMedia> = emptyList()
override var _ratedTvShows: List<RatedMedia> = emptyList() override var _ratedTvShows: List<RatedMedia> = emptyList()
private lateinit var service: GuestSessionApi
override suspend fun initialize() { override suspend fun initialize() {
val service = TmdbClient().createGuestSessionService() service = TmdbClient().createGuestSessionService()
refresh()
}
override suspend fun refresh() {
service.getRatedMovies(sessionId).apply { service.getRatedMovies(sessionId).apply {
if (isSuccessful) { if (isSuccessful) {
_ratedMovies = body()?.results ?: emptyList() withContext(Dispatchers.Main) {
_ratedMovies = body()?.results ?: _ratedMovies
}
} }
} }
service.getRatedTvShows(sessionId).apply { service.getRatedTvShows(sessionId).apply {
if (isSuccessful) { if (isSuccessful) {
_ratedTvShows = body()?.results ?: emptyList() withContext(Dispatchers.Main) {
_ratedTvShows = body()?.results ?: _ratedTvShows
}
} }
} }
service.getRatedTvEpisodes(sessionId).apply { service.getRatedTvEpisodes(sessionId).apply {
if (isSuccessful) { if (isSuccessful) {
_ratedTvEpisodes = body()?.results ?: emptyList() withContext(Dispatchers.Main) {
_ratedTvEpisodes = body()?.results ?: _ratedTvEpisodes
}
} }
} }
} }

View File

@@ -54,4 +54,7 @@
<string name="rating_dialog_confirm">Submit rating</string> <string name="rating_dialog_confirm">Submit rating</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</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_shows_title">Rated TV Shows</string>
<string name="nav_rated_episodes_title">Rated TV Episodes</string>
</resources> </resources>