sign in and display account content

This commit is contained in:
Owen LeJeune
2022-03-11 22:34:08 -05:00
parent 695af865ec
commit de4f137df9
22 changed files with 226 additions and 126 deletions

View File

@@ -19,7 +19,7 @@ interface AuthenticationApi {
suspend fun createRequestToken(): Response<CreateTokenResponse> suspend fun createRequestToken(): Response<CreateTokenResponse>
@POST("authentication/session/new") @POST("authentication/session/new")
suspend fun createSession(@Body body: SessionBody): Response<CreateSessionResponse> suspend fun createSession(@Body body: TokenSessionBody): Response<CreateSessionResponse>
@POST("authentication/token/validate_with_login") @POST("authentication/token/validate_with_login")
suspend fun validateTokenWithLogin(@Body body: TokenValidationBody): Response<CreateTokenResponse> suspend fun validateTokenWithLogin(@Body body: TokenValidationBody): Response<CreateTokenResponse>

View File

@@ -1,8 +1,6 @@
package com.owenlejeune.tvtime.api.tmdb package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.SessionBody import com.owenlejeune.tvtime.api.tmdb.model.*
import com.owenlejeune.tvtime.api.tmdb.model.DeleteSessionResponse
import com.owenlejeune.tvtime.api.tmdb.model.GuestSessionResponse
import retrofit2.Response import retrofit2.Response
class AuthenticationService { class AuthenticationService {
@@ -17,4 +15,15 @@ class AuthenticationService {
return service.deleteSession(body) return service.deleteSession(body)
} }
suspend fun createRequestToken(): Response<CreateTokenResponse> {
return service.createRequestToken()
}
suspend fun createSession(body: TokenSessionBody): Response<CreateSessionResponse> {
return service.createSession(body)
}
suspend fun validateTokenWithLogin(body: TokenValidationBody): Response<CreateTokenResponse> {
return service.validateTokenWithLogin(body)
}
} }

View File

@@ -5,6 +5,7 @@ import com.owenlejeune.tvtime.BuildConfig
import com.owenlejeune.tvtime.api.Client import com.owenlejeune.tvtime.api.Client
import com.owenlejeune.tvtime.api.QueryParam import com.owenlejeune.tvtime.api.QueryParam
import com.owenlejeune.tvtime.extensions.addQueryParams import com.owenlejeune.tvtime.extensions.addQueryParams
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
@@ -19,6 +20,7 @@ class TmdbClient: KoinComponent {
} }
private val client: Client by inject { parametersOf(BASE_URL) } private val client: Client by inject { parametersOf(BASE_URL) }
private val preferences: AppPreferences by inject()
init { init {
client.addInterceptor(TmdbInterceptor()) client.addInterceptor(TmdbInterceptor())
@@ -56,11 +58,8 @@ class TmdbClient: KoinComponent {
val languageCode = "${locale.language}-${locale.region}" val languageCode = "${locale.language}-${locale.region}"
val languageParam = QueryParam("language", languageCode) val languageParam = QueryParam("language", languageCode)
var sessionIdParam: QueryParam? = null val segments = chain.request().url.encodedPathSegments
val segments = chain.request().url().encodedPathSegments() val sessionIdParam: QueryParam? = sessionIdParam(segments)
if (segments.size > 1 && segments[1].equals("account") && SessionManager.currentSession?.isAuthorized == true) {
sessionIdParam = QueryParam("session_id", SessionManager.currentSession!!.sessionId)
}
val request = chain.addQueryParams(apiParam, languageParam, sessionIdParam) val request = chain.addQueryParams(apiParam, languageParam, sessionIdParam)
@@ -68,4 +67,16 @@ class TmdbClient: KoinComponent {
} }
} }
private fun sessionIdParam(urlSegments: List<String>): QueryParam? {
var sessionIdParam: QueryParam? = null
if (urlSegments.size > 1 && urlSegments[1] == "account") {
if (SessionManager.currentSession?.isAuthorized == true) {
sessionIdParam = QueryParam("session_id", SessionManager.currentSession!!.sessionId)
} else if (preferences.authorizedSessionId.isNotEmpty()) {
sessionIdParam = QueryParam("session_id", preferences.authorizedSessionId)
}
}
return sessionIdParam
}
} }

View File

@@ -13,9 +13,19 @@ class AccountDetails(
) )
class Avatar( class Avatar(
@SerializedName("gravatar") val gravatar: Gravatar @SerializedName("gravatar") val gravatar: Gravatar?,
@SerializedName("tmdb") val tmdb: TmdbAvatar?
) )
class Gravatar( class Gravatar(
@SerializedName("hash") val hash: String @SerializedName("hash") val hash: String?
) {
companion object {
private const val DEF_HASH = "88c8a9052642ec51d85d4f7beb178a97"
}
fun isDefault() = hash?.equals(DEF_HASH)
}
class TmdbAvatar(
@SerializedName("avatar_path") val avatarPath: String?
) )

View File

@@ -4,5 +4,5 @@ import com.google.gson.annotations.SerializedName
class CreateSessionResponse( class CreateSessionResponse(
@SerializedName("success") val isSuccess: Boolean, @SerializedName("success") val isSuccess: Boolean,
@SerializedName("session_idd") val sessionId: String @SerializedName("session_id") val sessionId: String
) )

View File

@@ -10,10 +10,10 @@ class RatedEpisode(
voteAverage: Float, voteAverage: Float,
voteCount: Int, voteCount: Int,
rating: Float, rating: Float,
@SerializedName("air_date") val airDate: String, releaseDate: String,
@SerializedName("episode_number") val episodeNumber: Int, @SerializedName("episode_number") val episodeNumber: Int,
@SerializedName("production_code") val productionCode: String?, @SerializedName("production_code") val productionCode: String?,
@SerializedName("season_number") val seasonNumber: Int, @SerializedName("season_number") val seasonNumber: Int,
@SerializedName("show_id") val showId: Int, @SerializedName("show_id") val showId: Int,
@SerializedName("still_path") val stillPath: String?, @SerializedName("still_path") val stillPath: String?,
): RatedMedia(RatedType.EPISODE, id, overview, name, voteAverage, voteCount, rating) ): RatedMedia(RatedType.EPISODE, id, overview, name, voteAverage, voteCount, rating, releaseDate)

View File

@@ -9,7 +9,8 @@ abstract class RatedMedia(
@SerializedName("name", alternate = ["title"]) val name: String, @SerializedName("name", alternate = ["title"]) val name: String,
@SerializedName("vote_average") val voteAverage: Float, @SerializedName("vote_average") val voteAverage: Float,
@SerializedName("vote_count") val voteCount: Int, @SerializedName("vote_count") val voteCount: Int,
@SerializedName("rating") val rating: Float @SerializedName("rating") val rating: Float,
@SerializedName("release_date", alternate = ["first_air_date", "air_date"]) val releaseDate: String
) { ) {
enum class RatedType { enum class RatedType {
MOVIE, MOVIE,

View File

@@ -15,10 +15,10 @@ class RatedMovie(
originalName: String, originalName: String,
posterPath: String?, posterPath: String?,
popularity: Float, popularity: Float,
releaseDate: String,
@SerializedName("adult") val isAdult: Boolean, @SerializedName("adult") val isAdult: Boolean,
@SerializedName("release_date") val releaseDate: String,
@SerializedName("video") val video: Boolean, @SerializedName("video") val video: Boolean,
): RatedTopLevelMedia( ): RatedTopLevelMedia(
RatedType.MOVIE, id, overview, name, voteAverage, voteCount, rating, RatedType.MOVIE, id, overview, name, voteAverage, voteCount, rating, releaseDate,
backdropPath, genreIds, originalLanguage, originalName, posterPath, popularity backdropPath, genreIds, originalLanguage, originalName, posterPath, popularity
) )

View File

@@ -10,10 +10,11 @@ abstract class RatedTopLevelMedia(
voteAverage: Float, voteAverage: Float,
voteCount: Int, voteCount: Int,
rating: Float, rating: Float,
releaseDate: String,
@SerializedName("backdrop_path") val backdropPath: String?, @SerializedName("backdrop_path") val backdropPath: String?,
@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?, @SerializedName("poster_path") val posterPath: String?,
@SerializedName("popularity") val popularity: Float, @SerializedName("popularity") val popularity: Float,
): RatedMedia(type, id, overview, name, voteAverage, voteCount, rating) ): RatedMedia(type, id, overview, name, voteAverage, voteCount, rating, releaseDate)

View File

@@ -15,9 +15,9 @@ class RatedTv(
originalName: String, originalName: String,
posterPath: String?, posterPath: String?,
popularity: Float, popularity: Float,
@SerializedName("first_air_date") val firstAirDate: String, releaseDate: String,
@SerializedName("origin_country") val originCountry: List<String>, @SerializedName("origin_country") val originCountry: List<String>,
): RatedTopLevelMedia( ): RatedTopLevelMedia(
RatedType.SERIES, id, overview, name, voteAverage, voteCount, rating, RatedType.SERIES, id, overview, name, voteAverage, voteCount, rating, releaseDate,
backdropPath, genreIds, originalLanguage, originalName, posterPath, popularity backdropPath, genreIds, originalLanguage, originalName, posterPath, popularity
) )

View File

@@ -0,0 +1,7 @@
package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
class TokenSessionBody(
@SerializedName("request_token") val requestToken: String
)

View File

@@ -6,7 +6,7 @@ import okhttp3.Request
fun Interceptor.Chain.addQueryParams(vararg queryParams: QueryParam?): Request { fun Interceptor.Chain.addQueryParams(vararg queryParams: QueryParam?): Request {
val original = request() val original = request()
val originalHttpUrl = original.url() val originalHttpUrl = original.url
val urlBuilder = originalHttpUrl.newBuilder() val urlBuilder = originalHttpUrl.newBuilder()
queryParams.forEach { param -> queryParams.forEach { param ->

View File

@@ -6,3 +6,7 @@ import android.util.Patterns
fun String.isEmailValid(): Boolean { fun String.isEmailValid(): Boolean {
return !TextUtils.isEmpty(this) && Patterns.EMAIL_ADDRESS.matcher(this).matches() return !TextUtils.isEmpty(this) && Patterns.EMAIL_ADDRESS.matcher(this).matches()
} }
fun String.unlessEmpty(other: String): String {
return this.ifEmpty { other }
}

View File

@@ -58,7 +58,7 @@ fun CustomTopAppBarDropdownMenu(
) { ) {
val expanded = remember { mutableStateOf(false) } val expanded = remember { mutableStateOf(false) }
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) { Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd).padding(end = 12.dp)) {
IconButton(onClick = { expanded.value = true }) { IconButton(onClick = { expanded.value = true }) {
icon() icon()
} }

View File

@@ -9,17 +9,31 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.extensions.isEmailValid import com.owenlejeune.tvtime.utils.SessionManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun SignInDialog( fun SignInDialog(
showDialog: MutableState<Boolean>, showDialog: MutableState<Boolean>,
@@ -27,33 +41,34 @@ fun SignInDialog(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var emailState by rememberSaveable { mutableStateOf("") } var usernameState by rememberSaveable { mutableStateOf("") }
var emailHasErrors by rememberSaveable { mutableStateOf(false) } var usernameHasErrors by rememberSaveable { mutableStateOf(false) }
var emailError = "" var usernameError = ""
var passwordState by rememberSaveable { mutableStateOf("") } var passwordState by rememberSaveable { mutableStateOf("") }
var passwordHasErrors by rememberSaveable { mutableStateOf(false) } var passwordHasErrors by rememberSaveable { mutableStateOf(false) }
var passwordError = "" var passwordError = ""
fun validate(): Boolean { fun validate(): Boolean {
emailError = "" usernameError = ""
passwordError = "" passwordError = ""
if (TextUtils.isEmpty(emailState)) { if (TextUtils.isEmpty(usernameState)) {
emailError = context.getString(R.string.email_not_empty_error) usernameError = context.getString(R.string.username_not_empty_error)
} else if (!emailState.isEmailValid()) {
emailError = context.getString(R.string.email_invalid_error)
} }
if (TextUtils.isEmpty(passwordState)) { if (TextUtils.isEmpty(passwordState)) {
passwordError = context.getString(R.string.password_empty_error) passwordError = context.getString(R.string.password_empty_error)
} }
emailHasErrors = emailError.isNotEmpty() usernameHasErrors = usernameError.isNotEmpty()
passwordHasErrors = passwordError.isNotEmpty() passwordHasErrors = passwordError.isNotEmpty()
return !emailHasErrors && !passwordHasErrors return !usernameHasErrors && !passwordHasErrors
} }
val focusManager = LocalFocusManager.current
AlertDialog( AlertDialog(
title = { Text(text = stringResource(R.string.action_sign_in)) }, title = { Text(text = stringResource(R.string.action_sign_in)) },
onDismissRequest = { showDialog.value = false }, onDismissRequest = { showDialog.value = false },
@@ -66,14 +81,15 @@ fun SignInDialog(
text = stringResource(R.string.sign_in_dialog_message) text = stringResource(R.string.sign_in_dialog_message)
) )
ThemedOutlineTextField( ThemedOutlineTextField(
value = emailState, value = usernameState,
onValueChange = { onValueChange = {
emailHasErrors = false usernameHasErrors = false
emailState = it usernameState = it
}, },
label = { Text(text = stringResource(R.string.email_label)) }, label = { Text(text = stringResource(R.string.username_label)) },
isError = emailHasErrors, isError = usernameHasErrors,
errorMessage = emailError errorMessage = usernameError,
singleLine = true
) )
PasswordOutlineTextField( PasswordOutlineTextField(
value = passwordState, value = passwordState,
@@ -83,9 +99,10 @@ fun SignInDialog(
}, },
label = { Text(text = stringResource(R.string.password_label)) }, label = { Text(text = stringResource(R.string.password_label)) },
isError = passwordHasErrors, isError = passwordHasErrors,
errorMessage = passwordError errorMessage = passwordError,
singleLine = true
) )
SignInButton(validate = ::validate) { success -> SignInButton(username = usernameState, password = passwordState, validate = ::validate) { success ->
if (success) { if (success) {
showDialog.value = false showDialog.value = false
} else { } else {
@@ -107,7 +124,7 @@ private fun CancelButton(showDialog: MutableState<Boolean>) {
} }
@Composable @Composable
private fun SignInButton(validate: () -> Boolean, onSuccess: (success: Boolean) -> Unit) { private fun SignInButton(username: String, password: String, validate: () -> Boolean, onSuccess: (success: Boolean) -> Unit) {
var signInInProgress by remember { mutableStateOf(false) } var signInInProgress by remember { mutableStateOf(false) }
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -115,13 +132,13 @@ private fun SignInButton(validate: () -> Boolean, onSuccess: (success: Boolean)
if (!signInInProgress) { if (!signInInProgress) {
if (validate()) { if (validate()) {
signInInProgress = true signInInProgress = true
// signIn(context, emailState, passwordState) { success -> CoroutineScope(Dispatchers.IO).launch {
// signInInProgress = false val success = SessionManager.signInWithLogin(username, password)
// if (success) { withContext(Dispatchers.Main) {
// showDialog.value = false signInInProgress = false
// } onSuccess(success)
// } }
onSuccess(false) }
} }
} }
} }
@@ -133,7 +150,7 @@ private fun SignInButton(validate: () -> Boolean, onSuccess: (success: Boolean)
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} else { } else {
Text(text = stringResource(id = R.string.action_sign_in)) Text(text = stringResource(id = R.string.action_sign_in), color = MaterialTheme.colorScheme.background)
} }
} }

View File

@@ -794,6 +794,7 @@ fun PasswordOutlineTextField(
isError: Boolean = false, isError: Boolean = false,
errorMessage: String = "", errorMessage: String = "",
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
singleLine: Boolean = false, singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@@ -818,7 +819,7 @@ fun PasswordOutlineTextField(
interactionSource = interactionSource, interactionSource = interactionSource,
shape = shape, shape = shape,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = keyboardOptions.copy(keyboardType = KeyboardType.Password),
trailingIcon = { trailingIcon = {
val image = if (passwordVisible) { val image = if (passwordVisible) {
Icons.Filled.Visibility Icons.Filled.Visibility

View File

@@ -622,7 +622,7 @@ private fun ReviewsCard(
fetchReviews(itemId, service, reviewsResponse) fetchReviews(itemId, service, reviewsResponse)
} }
} }
// > 0
val hasReviews = reviewsResponse.value?.results?.size?.let { it > 0 } val hasReviews = reviewsResponse.value?.results?.size?.let { it > 0 }
val m = if (hasReviews == true) { val m = if (hasReviews == true) {
modifier.height(400.dp) modifier.height(400.dp)

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -14,12 +15,18 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.compose.rememberImagePainter import coil.compose.rememberImagePainter
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.transform.CircleCropTransformation
import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.PagerState
@@ -42,7 +49,6 @@ import kotlin.reflect.KClass
private const val GUEST_SIGN_IN = "guest_sign_in" private const val GUEST_SIGN_IN = "guest_sign_in"
private const val SIGN_OUT = "sign_out" private const val SIGN_OUT = "sign_out"
private const val ACCOUNT_SIGN_OUT = "account_sign_out"
private const val NO_SESSION_SIGN_IN = "no_session_sign_in" private const val NO_SESSION_SIGN_IN = "no_session_sign_in"
private const val NO_SESSION_SIGN_IN_GUEST = "no_session_sign_in_guest" private const val NO_SESSION_SIGN_IN_GUEST = "no_session_sign_in_guest"
@@ -53,10 +59,18 @@ fun AccountTab(
appBarTitle: MutableState<String>, appBarTitle: MutableState<String>,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({}) appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) { ) {
if (SessionManager.currentSession?.isAuthorized == false) { when (SessionManager.currentSession?.isAuthorized) {
appBarTitle.value = stringResource(id = R.string.account_header_title_formatted).replace("%1\$s", stringResource(id = R.string.account_name_guest)) false -> {
} else { appBarTitle.value =
appBarTitle.value = stringResource(id = R.string.account_not_logged_in) stringResource(id = R.string.account_header_title_formatted, stringResource(id = R.string.account_name_guest))
}
true -> {
appBarTitle.value =
stringResource(id = R.string.account_header_title_formatted, getAccountName(SessionManager.currentSession?.accountDetails))
}
else -> {
appBarTitle.value = stringResource(id = R.string.account_not_logged_in)
}
} }
val lastSelectedOption = remember { mutableStateOf("") } val lastSelectedOption = remember { mutableStateOf("") }
@@ -86,6 +100,17 @@ fun AccountTab(
} }
} }
private fun getAccountName(accountDetails: AccountDetails?): String {
if (accountDetails != null) {
if (accountDetails.name.isNotEmpty()) {
return accountDetails.name
} else if (accountDetails.username.isNotEmpty()) {
return accountDetails.username
}
}
return ""
}
@Composable @Composable
fun <T: Any> AccountTabContent( fun <T: Any> AccountTabContent(
appNavController: NavHostController, appNavController: NavHostController,
@@ -113,8 +138,8 @@ fun <T: Any> AccountTabContent(
} else { } else {
items(contentItems.size) { i -> items(contentItems.size) { i ->
when (clazz) { when (clazz) {
RatedMovie::class -> { RatedTv::class, RatedMovie::class -> {
val item = contentItems[i] as RatedMovie val item = contentItems[i] as RatedTopLevelMedia
MediaItemRow( MediaItemRow(
appNavController = appNavController, appNavController = appNavController,
mediaViewType = mediaViewType, mediaViewType = mediaViewType,
@@ -125,32 +150,19 @@ fun <T: Any> AccountTabContent(
rating = item.rating rating = item.rating
) )
} }
RatedTv::class -> {
val item = contentItems[i] as RatedTv
MediaItemRow(
appNavController = appNavController,
mediaViewType = mediaViewType,
id = item.id,
posterPath = TmdbUtils.getFullPosterPath(item.posterPath),
name = item.name,
date = item.firstAirDate,
rating = item.rating
)
}
RatedEpisode::class -> { RatedEpisode::class -> {
val item = contentItems[i] as RatedEpisode val item = contentItems[i] as RatedMedia
MediaItemRow( MediaItemRow(
appNavController = appNavController, appNavController = appNavController,
mediaViewType = mediaViewType, mediaViewType = mediaViewType,
id = item.id, id = item.id,
posterPath = null,
name = item.name, name = item.name,
date = item.airDate, date = item.releaseDate,
rating = item.rating rating = item.rating
) )
} }
FavoriteMovie::class -> { FavoriteMovie::class, FavoriteTvSeries::class -> {
val item = contentItems[i] as FavoriteMovie val item = contentItems[i] as FavoriteMedia
MediaItemRow( MediaItemRow(
appNavController = appNavController, appNavController = appNavController,
mediaViewType = mediaViewType, mediaViewType = mediaViewType,
@@ -160,30 +172,8 @@ fun <T: Any> AccountTabContent(
date = item.releaseDate date = item.releaseDate
) )
} }
FavoriteTvSeries::class -> { WatchlistMovie::class, WatchlistTvSeries::class -> {
val item = contentItems[i] as FavoriteTvSeries val item = contentItems[i] as WatchlistMedia
MediaItemRow(
appNavController = appNavController,
mediaViewType = mediaViewType,
id = item.id,
posterPath = TmdbUtils.getFullPosterPath(item.posterPath),
name = item.title,
date = item.releaseDate
)
}
WatchlistMovie::class -> {
val item = contentItems[i] as WatchlistMovie
MediaItemRow(
appNavController = appNavController,
mediaViewType = mediaViewType,
id = item.id,
posterPath = TmdbUtils.getFullPosterPath(item.posterPath),
name = item.title,
date = item.releaseDate
)
}
WatchlistTvSeries::class -> {
val item = contentItems[i] as WatchlistTvSeries
MediaItemRow( MediaItemRow(
appNavController = appNavController, appNavController = appNavController,
mediaViewType = mediaViewType, mediaViewType = mediaViewType,
@@ -204,9 +194,9 @@ private fun MediaItemRow(
appNavController: NavHostController, appNavController: NavHostController,
mediaViewType: MediaViewType, mediaViewType: MediaViewType,
id: Int, id: Int,
posterPath: String?,
name: String, name: String,
date: String, date: String,
posterPath: String? = null,
rating: Float? = null rating: Float? = null
) { ) {
Row( Row(
@@ -262,7 +252,7 @@ private fun AccountDropdownMenu(
CustomTopAppBarDropdownMenu( CustomTopAppBarDropdownMenu(
icon = { icon = {
when(session?.isAuthorized) { when(session?.isAuthorized) {
true -> { } true -> { AuthorizedSessionIcon() }
false -> { GuestSessionIcon() } false -> { GuestSessionIcon() }
null -> { NoSessionAccountIcon() } null -> { NoSessionAccountIcon() }
} }
@@ -312,9 +302,7 @@ private fun NoSessionMenuItems(
@Composable @Composable
private fun NoSessionAccountIcon() { private fun NoSessionAccountIcon() {
Icon( Icon(
modifier = Modifier modifier = Modifier.size(45.dp),
.size(50.dp)
.padding(end = 8.dp),
imageVector = Icons.Filled.AccountCircle, imageVector = Icons.Filled.AccountCircle,
contentDescription = stringResource(R.string.account_menu_content_description), contentDescription = stringResource(R.string.account_menu_content_description),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
@@ -366,12 +354,43 @@ private fun AuthorizedSessionMenuItems(
CustomMenuItem( CustomMenuItem(
text = stringResource(id = R.string.action_sign_out), text = stringResource(id = R.string.action_sign_out),
onClick = { onClick = {
lastSelectedOption.value = ACCOUNT_SIGN_OUT signOut(lastSelectedOption)
expanded.value = false expanded.value = false
} }
) )
} }
@Composable
private fun AuthorizedSessionIcon() {
val accountDetails = SessionManager.currentSession?.accountDetails
val avatarUrl = accountDetails?.let {
when {
accountDetails.avatar.tmdb?.avatarPath?.isNotEmpty() == true -> {
TmdbUtils.getAccountAvatarUrl(accountDetails)
}
accountDetails.avatar.gravatar?.isDefault() == false -> {
TmdbUtils.getAccountGravatarUrl(accountDetails)
}
else -> null
}
}
if (accountDetails == null || avatarUrl == null) {
val accLetter = (accountDetails?.name?.ifEmpty { accountDetails.username } ?: " ")[0]
RoundedLetterImage(size = 40.dp, character = accLetter, topPadding = 40.dp / 8)
} else {
Box(modifier = Modifier.size(50.dp)) {
AsyncImage(
model = avatarUrl,
contentDescription = "",
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
contentScale = ContentScale.Fit
)
}
}
}
private fun createGuestSession(lastSelectedOption: MutableState<String>) { private fun createGuestSession(lastSelectedOption: MutableState<String>) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val session = SessionManager.requestNewGuestSession() val session = SessionManager.requestNewGuestSession()

View File

@@ -1,6 +1,7 @@
package com.owenlejeune.tvtime.utils package com.owenlejeune.tvtime.utils
import com.owenlejeune.tvtime.api.tmdb.AccountService import com.owenlejeune.tvtime.api.tmdb.AccountService
import com.owenlejeune.tvtime.api.tmdb.AuthenticationService
import com.owenlejeune.tvtime.api.tmdb.GuestSessionService import com.owenlejeune.tvtime.api.tmdb.GuestSessionService
import com.owenlejeune.tvtime.api.tmdb.TmdbClient import com.owenlejeune.tvtime.api.tmdb.TmdbClient
import com.owenlejeune.tvtime.api.tmdb.model.* import com.owenlejeune.tvtime.api.tmdb.model.*
@@ -25,11 +26,7 @@ object SessionManager: KoinComponent {
fun clearSession(onResponse: (isSuccessful: Boolean) -> Unit) { fun clearSession(onResponse: (isSuccessful: Boolean) -> Unit) {
currentSession?.let { session -> currentSession?.let { session ->
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val deleteResponse = authenticationService.deleteSession( val deleteResponse = authenticationService.deleteSession(SessionBody(session.sessionId))
SessionBody(
session.sessionId
)
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (deleteResponse.isSuccessful) { if (deleteResponse.isSuccessful) {
_currentSession = null _currentSession = null
@@ -48,7 +45,9 @@ object SessionManager: KoinComponent {
session.initialize() session.initialize()
_currentSession = session _currentSession = session
} else if (preferences.authorizedSessionId.isNotEmpty()) { } else if (preferences.authorizedSessionId.isNotEmpty()) {
val session = AuthorizedSession()
session.initialize()
_currentSession = session
} }
} }
@@ -61,20 +60,29 @@ object SessionManager: KoinComponent {
return _currentSession return _currentSession
} }
suspend fun signInWithLogin(email: String, password: String): Boolean { suspend fun signInWithLogin(username: String, password: String): Boolean {
val service = TmdbClient().createAuthenticationService() val service = AuthenticationService()
val createTokenResponse = service.createRequestToken() val createTokenResponse = service.createRequestToken()
if (createTokenResponse.isSuccessful) { if (createTokenResponse.isSuccessful) {
createTokenResponse.body()?.let { ctr -> createTokenResponse.body()?.let { ctr ->
val body = TokenValidationBody(email, password, ctr.requestToken) val body = TokenValidationBody(username, password, ctr.requestToken)
val loginResponse = service.validateTokenWithLogin(body) val loginResponse = service.validateTokenWithLogin(body)
if (loginResponse.isSuccessful) { if (loginResponse.isSuccessful) {
loginResponse.body()?.let { lr -> loginResponse.body()?.let { lr ->
if (lr.success) { if (lr.success) {
preferences.authorizedSessionId = lr.requestToken val sessionBody = TokenSessionBody(lr.requestToken)
_currentSession = AuthorizedSession() val sessionResponse = service.createSession(sessionBody)
_currentSession?.initialize() if (sessionResponse.isSuccessful) {
return true sessionResponse.body()?.let { sr ->
if (sr.isSuccess) {
preferences.authorizedSessionId = sr.sessionId
preferences.guestSessionId = ""
_currentSession = AuthorizedSession()
_currentSession?.initialize()
return true
}
}
}
} }
} }
} }

View File

@@ -9,11 +9,13 @@ object TmdbUtils {
private const val POSTER_BASE = "https://image.tmdb.org/t/p/original" private const val POSTER_BASE = "https://image.tmdb.org/t/p/original"
private const val BACKDROP_BASE = "https://www.themoviedb.org/t/p/original" private const val BACKDROP_BASE = "https://www.themoviedb.org/t/p/original"
private const val PERSON_BASE = "https://www.themoviedb.org/t/p/w600_and_h900_bestv2" private const val PERSON_BASE = "https://www.themoviedb.org/t/p/w600_and_h900_bestv2"
private const val GRAVATAR_BASE = "https://www.gravatar.com/avatar/"
private const val AVATAR_BASE = "https://www.themoviedb.org/t/p/w150_and_h150_face"
private const val DEF_REGION = "US" private const val DEF_REGION = "US"
fun getFullPosterPath(posterPath: String?): String? { fun getFullPosterPath(posterPath: String?): String? {
return posterPath?.let { "https://image.tmdb.org/t/p/original${posterPath}" } return posterPath?.let { "${POSTER_BASE}${posterPath}" }
} }
fun getFullPosterPath(tmdbItem: TmdbItem?): String? { fun getFullPosterPath(tmdbItem: TmdbItem?): String? {
@@ -25,7 +27,7 @@ object TmdbUtils {
} }
fun getFullBackdropPath(backdropPath: String?): String? { fun getFullBackdropPath(backdropPath: String?): String? {
return backdropPath?.let { "https://www.themoviedb.org/t/p/original${backdropPath}" } return backdropPath?.let { "${BACKDROP_BASE}${backdropPath}" }
} }
fun getFullBackdropPath(detailItem: DetailedItem?): String? { fun getFullBackdropPath(detailItem: DetailedItem?): String? {
@@ -37,7 +39,7 @@ object TmdbUtils {
} }
fun getFullPersonImagePath(path: String?): String? { fun getFullPersonImagePath(path: String?): String? {
return path?.let { "https://www.themoviedb.org/t/p/w600_and_h900_bestv2${path}" } return path?.let { "${PERSON_BASE}${path}" }
} }
fun getFullPersonImagePath(person: Person): String? { fun getFullPersonImagePath(person: Person): String? {
@@ -49,7 +51,7 @@ object TmdbUtils {
if (path.contains("http")) { if (path.contains("http")) {
return path.substring(startIndex = 1) return path.substring(startIndex = 1)
} }
"https://www.themoviedb.org/t/p/w150_and_h150_face${path}" "${AVATAR_BASE}${path}"
} }
} }
@@ -162,4 +164,14 @@ object TmdbUtils {
return formatter.format(date) return formatter.format(date)
} }
fun getAccountGravatarUrl(accountDetails: AccountDetails): String {
val hash = accountDetails.avatar.gravatar?.hash
return "${GRAVATAR_BASE}${hash}"
}
fun getAccountAvatarUrl(accountDetails: AccountDetails): String {
val path = accountDetails.avatar.tmdb?.avatarPath
return "${AVATAR_BASE}${path}"
}
} }

View File

@@ -92,11 +92,11 @@
<string name="action_sign_in_as_guest">Sign In as Guest</string> <string name="action_sign_in_as_guest">Sign In as Guest</string>
<string name="account_menu_content_description">Account Menu</string> <string name="account_menu_content_description">Account Menu</string>
<string name="action_sign_out">Sign Out</string> <string name="action_sign_out">Sign Out</string>
<string name="email_not_empty_error">Email must not be empty</string> <string name="username_not_empty_error">Username must not be empty</string>
<string name="email_invalid_error">Invalid email</string> <string name="email_invalid_error">Invalid email</string>
<string name="password_empty_error">Must enter a password</string> <string name="password_empty_error">Must enter a password</string>
<string name="sign_in_dialog_message">Sign in using your TMDB credentials</string> <string name="sign_in_dialog_message">Sign in using your TMDB credentials</string>
<string name="email_label">Email</string> <string name="username_label">Username</string>
<string name="password_label">Password</string> <string name="password_label">Password</string>
<string name="no_account_message">Don\'t have an account?</string> <string name="no_account_message">Don\'t have an account?</string>
</resources> </resources>

View File

@@ -22,6 +22,6 @@ object Versions {
const val gson = "2.8.7" const val gson = "2.8.7"
const val koin = "3.1.4" const val koin = "3.1.4"
const val paging = "3.1.0" const val paging = "3.1.0"
const val coil = "1.4.0" const val coil = "2.0.0-rc01"
} }