add sign in menu to account tab

This commit is contained in:
Owen LeJeune
2022-03-08 21:26:42 -05:00
parent 6ce7f3fa5c
commit 0ed5fa88f1
22 changed files with 824 additions and 76 deletions

View File

@@ -1,21 +1,26 @@
package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.DeleteSessionBody
import com.owenlejeune.tvtime.api.tmdb.model.DeleteSessionResponse
import com.owenlejeune.tvtime.api.tmdb.model.GuestSessionResponse
import com.owenlejeune.tvtime.api.tmdb.model.*
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.POST
interface AuthenticationApi {
@GET("authentication/guest_session/new")
suspend fun getNewGuestSession(): Response<GuestSessionResponse>
// @DELETE("authentication/session")
@HTTP(method = "DELETE", path = "authentication/session", hasBody = true)
suspend fun deleteSession(@Body body: DeleteSessionBody): Response<DeleteSessionResponse>
suspend fun deleteSession(@Body body: SessionBody): Response<DeleteSessionResponse>
@GET("authentication/token/new")
suspend fun createRequestToken(): Response<CreateTokenResponse>
@POST("authentication/session/new")
suspend fun createSession(@Body body: SessionBody): Response<CreateSessionResponse>
@POST("authentication/token/validate_with_login")
suspend fun validateTokenWithLogin(@Body body: TokenValidationBody): Response<CreateTokenResponse>
}

View File

@@ -1,6 +1,6 @@
package com.owenlejeune.tvtime.api.tmdb
import com.owenlejeune.tvtime.api.tmdb.model.DeleteSessionBody
import com.owenlejeune.tvtime.api.tmdb.model.SessionBody
import com.owenlejeune.tvtime.api.tmdb.model.DeleteSessionResponse
import com.owenlejeune.tvtime.api.tmdb.model.GuestSessionResponse
import retrofit2.Response
@@ -13,7 +13,7 @@ class AuthenticationService {
return service.getNewGuestSession()
}
suspend fun deleteSession(body: DeleteSessionBody): Response<DeleteSessionResponse> {
suspend fun deleteSession(body: SessionBody): Response<DeleteSessionResponse> {
return service.deleteSession(body)
}

View File

@@ -55,7 +55,7 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
override suspend fun postRating(id: Int, rating: RatingBody): Response<RatingResponse> {
val session = SessionManager.currentSession ?: throw Exception("Session must not be null")
return if (session.isGuest) {
return if (!session.isAuthorized) {
movieService.postMovieRatingAsGuest(id, session.sessionId, rating)
} else {
movieService.postMovieRatingAsUser(id, session.sessionId, rating)
@@ -64,7 +64,7 @@ class MoviesService: KoinComponent, DetailService, HomePageService {
override suspend fun deleteRating(id: Int): Response<RatingResponse> {
val session = SessionManager.currentSession ?: throw Exception("Session must not be null")
return if (session.isGuest) {
return if (!session.isAuthorized) {
movieService.deleteMovieReviewAsGuest(id, session.sessionId)
} else {
movieService.deleteMovieReviewAsUser(id, session.sessionId)

View File

@@ -55,7 +55,7 @@ class TvService: KoinComponent, DetailService, HomePageService {
override suspend fun postRating(id: Int, rating: RatingBody): Response<RatingResponse> {
val session = SessionManager.currentSession ?: throw Exception("Session must not be null")
return if (session.isGuest) {
return if (!session.isAuthorized) {
service.postTvRatingAsGuest(id, session.sessionId, rating)
} else {
service.postTvRatingAsUser(id, session.sessionId, rating)
@@ -64,7 +64,7 @@ class TvService: KoinComponent, DetailService, HomePageService {
override suspend fun deleteRating(id: Int): Response<RatingResponse> {
val session = SessionManager.currentSession ?: throw Exception("Session must not be null")
return if (session.isGuest) {
return if (!session.isAuthorized) {
service.deleteTvReviewAsGuest(id, session.sessionId)
} else {
service.deleteTvReviewAsUser(id, session.sessionId)

View File

@@ -0,0 +1,8 @@
package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
class CreateSessionResponse(
@SerializedName("success") val isSuccess: Boolean,
@SerializedName("session_idd") val sessionId: String
)

View File

@@ -0,0 +1,9 @@
package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
class CreateTokenResponse(
@SerializedName("success") val success: Boolean,
@SerializedName("request_token") val requestToken: String,
@SerializedName("expires_at") val expiry: String
)

View File

@@ -2,6 +2,6 @@ package com.owenlejeune.tvtime.api.tmdb.model
import com.google.gson.annotations.SerializedName
class DeleteSessionBody(
class SessionBody(
@SerializedName("session_id") val sessionsId: String
)

View File

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

View File

@@ -0,0 +1,8 @@
package com.owenlejeune.tvtime.extensions
import android.text.TextUtils
import android.util.Patterns
fun String.isEmailValid(): Boolean {
return !TextUtils.isEmpty(this) && Patterns.EMAIL_ADDRESS.matcher(this).matches()
}

View File

@@ -12,6 +12,7 @@ class AppPreferences(context: Context) {
private val PERSISTENT_SEARCH = "persistent_search"
private val HIDE_TITLE = "hide_title"
private val GUEST_SESSION = "guest_session_id"
private val AUTHORIZED_SESSION = "authorized_session_id"
}
private val preferences: SharedPreferences = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
@@ -27,6 +28,10 @@ class AppPreferences(context: Context) {
var guestSessionId: String
get() = preferences.getString(GUEST_SESSION, "") ?: ""
set(value) { preferences.put(GUEST_SESSION, value) }
var authorizedSessionId: String
get() = preferences.getString(AUTHORIZED_SESSION, "") ?: ""
set(value) { preferences.put(AUTHORIZED_SESSION, value) }
// val usePreferences: MutableState<Boolean>
// var usePreferences: Boolean
// get() = preferences.getBoolean(USE_PREFERENCES, false)

View File

@@ -0,0 +1,81 @@
package com.owenlejeune.tvtime.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.AlertDialog
import androidx.compose.material.DropdownMenu
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
@Composable
fun TopAppBarDropdownMenu(
icon: @Composable () -> Unit = {},
content: @Composable ColumnScope.(expanded: MutableState<Boolean>) -> Unit = {}
) {
val expanded = remember { mutableStateOf(false) }
Box(
modifier = Modifier.wrapContentSize(Alignment.TopEnd)
) {
IconButton(
onClick = {
expanded.value = true
}
) {
icon()
}
}
DropdownMenu(
modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceVariant),
expanded = expanded.value,
onDismissRequest = { expanded.value = false }
) {
content(this, expanded)
}
}
@Composable
fun TopAppBarDialogMenu(
icon: @Composable () -> Unit = {},
content: @Composable (showing: MutableState<Boolean>) -> Unit = {}
) {
val expanded = remember { mutableStateOf(false) }
Box(
modifier = Modifier.wrapContentSize(Alignment.TopEnd)
) {
IconButton(
onClick = {
expanded.value = true
}
) {
icon()
}
}
if (expanded.value) {
Dialog(
onDismissRequest = { expanded.value = false },
content = { content(expanded) }
)
// AlertDialog(
// modifier = Modifier
// .fillMaxWidth()
// .wrapContentHeight(),
// backgroundColor = MaterialTheme.colorScheme.background,
// onDismissRequest = { expanded.value = false },
// text = { content(expanded) },
// buttons = {}
// )
}
}

View File

@@ -0,0 +1,159 @@
package com.owenlejeune.tvtime.ui.components
import android.content.Intent
import android.net.Uri
import android.text.TextUtils
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.extensions.isEmailValid
@Composable
fun SignInDialog(
showDialog: MutableState<Boolean>,
onSuccess: (success: Boolean) -> Unit
) {
val context = LocalContext.current
var emailState by rememberSaveable { mutableStateOf("") }
var emailHasErrors by rememberSaveable { mutableStateOf(false) }
var emailError = ""
var passwordState by rememberSaveable { mutableStateOf("") }
var passwordHasErrors by rememberSaveable { mutableStateOf(false) }
var passwordError = ""
fun validate(): Boolean {
emailError = ""
passwordError = ""
if (TextUtils.isEmpty(emailState)) {
emailError = context.getString(R.string.email_not_empty_error)
} else if (!emailState.isEmailValid()) {
emailError = context.getString(R.string.email_invalid_error)
}
if (TextUtils.isEmpty(passwordState)) {
passwordError = context.getString(R.string.password_empty_error)
}
emailHasErrors = emailError.isNotEmpty()
passwordHasErrors = passwordError.isNotEmpty()
return !emailHasErrors && !passwordHasErrors
}
AlertDialog(
title = { Text(text = stringResource(R.string.action_sign_in)) },
onDismissRequest = { showDialog.value = false },
confirmButton = { CancelButton(showDialog = showDialog) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.sign_in_dialog_message)
)
ThemedOutlineTextField(
value = emailState,
onValueChange = {
emailHasErrors = false
emailState = it
},
label = { Text(text = stringResource(R.string.email_label)) },
isError = emailHasErrors,
errorMessage = emailError
)
PasswordOutlineTextField(
value = passwordState,
onValueChange = {
passwordHasErrors = false
passwordState = it
},
label = { Text(text = stringResource(R.string.password_label)) },
isError = passwordHasErrors,
errorMessage = passwordError
)
SignInButton(validate = ::validate) { success ->
if (success) {
showDialog.value = false
} else {
Toast.makeText(context, "An error occurred, please try again", Toast.LENGTH_SHORT).show()
}
onSuccess(success)
}
CreateAccountLink()
}
}
)
}
@Composable
private fun CancelButton(showDialog: MutableState<Boolean>) {
TextButton(onClick = { showDialog.value = false }) {
Text(text = stringResource(R.string.action_cancel))
}
}
@Composable
private fun SignInButton(validate: () -> Boolean, onSuccess: (success: Boolean) -> Unit) {
var signInInProgress by remember { mutableStateOf(false) }
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (!signInInProgress) {
if (validate()) {
signInInProgress = true
// signIn(context, emailState, passwordState) { success ->
// signInInProgress = false
// if (success) {
// showDialog.value = false
// }
// }
onSuccess(false)
}
}
}
) {
if (signInInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.background,
strokeWidth = 2.dp
)
} else {
Text(text = stringResource(id = R.string.action_sign_in))
}
}
}
@Composable
private fun CreateAccountLink() {
val context = LocalContext.current
LinkableText(
text = stringResource(R.string.no_account_message),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = {
val url = "https://www.themoviedb.org/signup"
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(url)
context.startActivity(intent)
}
)
)
}

View File

@@ -6,6 +6,7 @@ import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
@@ -16,10 +17,17 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Card
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.TextFieldColors
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -30,21 +38,23 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.BrushPainter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
@@ -653,21 +663,209 @@ fun AvatarImage(
contentDescription = ""
)
} else {
Box(
val text = if (author.name.isNotEmpty()) author.name[0] else author.username[0]
RoundedLetterImage(
size = size,
character = text
)
// Box(
// modifier = Modifier
// .clip(CircleShape)
// .size(size)
// .background(color = MaterialTheme.colorScheme.tertiary)
// ) {
// Text(
// modifier = Modifier
// .fillMaxSize()
// .padding(top = size / 5),
// text = if (author.name.isNotEmpty()) author.name[0].uppercase() else author.username[0].toString(),
// color = MaterialTheme.colorScheme.onTertiary,
// textAlign = TextAlign.Center,
// style = MaterialTheme.typography.titleLarge
// )
// }
}
}
@Composable
fun RoundedLetterImage(
size: Dp,
character: Char,
modifier: Modifier = Modifier,
topPadding: Dp = size / 5
) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(size)
.background(color = MaterialTheme.colorScheme.tertiary)
) {
Text(
modifier = Modifier
.clip(CircleShape)
.size(size)
.background(color = MaterialTheme.colorScheme.tertiary)
) {
.fillMaxSize()
.padding(top = topPadding),
text = character.uppercase(),
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge
)
}
}
@Composable
fun ThemedOutlineTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = androidx.compose.material.LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
errorMessage: String = "",
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = androidx.compose.material.MaterialTheme.shapes.small
) {
Column(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = {
if (isError) {
Icon(Icons.Filled.Error, "error", tint = MaterialTheme.colorScheme.error)
} else {
trailingIcon?.invoke()
}
},
isError = isError,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
interactionSource = interactionSource,
shape = shape,
colors = TextFieldDefaults.outlinedTextFieldColors(
unfocusedBorderColor = MaterialTheme.colorScheme.onBackground,
focusedBorderColor = MaterialTheme.colorScheme.primary,
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
textColor = MaterialTheme.colorScheme.onBackground,
errorBorderColor = MaterialTheme.colorScheme.error,
errorCursorColor = MaterialTheme.colorScheme.error,
errorLabelColor = MaterialTheme.colorScheme.error
)
)
if (isError) {
Text(
modifier = Modifier
.fillMaxSize()
.padding(top = size / 5),
text = if (author.name.isNotEmpty()) author.name[0].uppercase() else author.username[0].toString(),
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
@Composable
fun PasswordOutlineTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = androidx.compose.material.LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
errorMessage: String = "",
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = androidx.compose.material.MaterialTheme.shapes.small
) {
var passwordVisible by rememberSaveable { mutableStateOf(false) }
ThemedOutlineTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
isError = isError,
errorMessage = errorMessage,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
interactionSource = interactionSource,
shape = shape,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
}
val description = if (passwordVisible) "Hide password" else "Show password"
IconButton(onClick = { passwordVisible = !passwordVisible } ) {
Icon(imageVector = image, contentDescription = description)
}
}
)
}
@Composable
fun LinkableText(
text: String,
modifier: Modifier = Modifier,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
Text(
text = text,
modifier = modifier,
color = Color(0xFF64B5F6),
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = TextDecoration.Underline,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
onTextLayout = onTextLayout,
style = style
)
}

View File

@@ -16,7 +16,7 @@ sealed class AccountTabNavItem(stringRes: Int, route: String, val mediaType: Med
override val name = resourceUtils.getString(stringRes)
companion object {
val GuestItems = listOf(RatedMovies, RatedTvShows)//, RatedTvEpisodes)
val GuestItems = listOf(RatedMovies, RatedTvShows, RatedTvEpisodes)
}
object RatedMovies: AccountTabNavItem(R.string.nav_rated_movies_title, "rated_movies_route", MediaViewType.MOVIE, screenContent, { SessionManager.currentSession?.ratedMovies ?: emptyList() } )

View File

@@ -1,5 +1,6 @@
package com.owenlejeune.tvtime.ui.navigation
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@@ -20,8 +21,12 @@ object NavConstants {
}
@Composable
fun MainNavigationRoutes(navController: NavHostController, displayUnderStatusBar: MutableState<Boolean> = mutableStateOf(false)) {
NavHost(navController = navController, startDestination = MainNavItem.MainView.route) {
fun MainNavigationRoutes(
navController: NavHostController,
displayUnderStatusBar: MutableState<Boolean> = mutableStateOf(false),
startDestination: String = MainNavItem.MainView.route
) {
NavHost(navController = navController, startDestination = startDestination) {
composable(MainNavItem.MainView.route) {
displayUnderStatusBar.value = false
MainAppView(appNavController = navController)
@@ -56,25 +61,31 @@ fun MainNavigationRoutes(navController: NavHostController, displayUnderStatusBar
fun BottomNavigationRoutes(
appNavController: NavHostController,
navController: NavHostController,
appBarTitle: MutableState<String>
appBarTitle: MutableState<String>,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) {
NavHost(navController = navController, startDestination = BottomNavItem.Movies.route) {
composable(BottomNavItem.Movies.route) {
appBarActions.value = {}
MediaTab(appNavController = appNavController, mediaType = MediaViewType.MOVIE)
}
composable(BottomNavItem.TV.route) {
appBarActions.value = {}
MediaTab(appNavController = appNavController, mediaType = MediaViewType.TV)
}
composable(BottomNavItem.Account.route) {
AccountTab(appBarTitle = appBarTitle, appNavController = appNavController)
AccountTab(appBarTitle = appBarTitle, appNavController = appNavController, appBarActions = appBarActions)
}
composable(BottomNavItem.People.route) {
appBarActions.value = {}
PeopleTab(appBarTitle, appNavController = appNavController)
}
composable(BottomNavItem.Favourites.route) {
appBarActions.value = {}
FavouritesTab()
}
composable(BottomNavItem.Settings.route) {
appBarActions.value = {}
SettingsTab()
}
}

View File

@@ -2,13 +2,11 @@ package com.owenlejeune.tvtime.ui.screens
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.material.Scaffold
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -40,7 +38,7 @@ fun MainAppView(appNavController: NavHostController, preferences: AppPreferences
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val appBarTitle = remember { mutableStateOf(BottomNavItem.Items[0].name) }
val appBarTitle = rememberSaveable { mutableStateOf(BottomNavItem.getByRoute(currentRoute)?.name ?: BottomNavItem.Items[0].name) }
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val scrollBehavior = remember(decayAnimationSpec) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(decayAnimationSpec)
@@ -50,6 +48,8 @@ fun MainAppView(appNavController: NavHostController, preferences: AppPreferences
val focusSearchBar = remember { mutableStateOf(false) }
val searchableScreens = listOf(BottomNavItem.Movies.route, BottomNavItem.TV.route, BottomNavItem.People.route)
val appBarActions = remember { mutableStateOf<@Composable RowScope.() -> Unit>( {} ) }
// todo - scroll state not remember when returing from detail screen
Scaffold(
@@ -72,7 +72,8 @@ fun MainAppView(appNavController: NavHostController, preferences: AppPreferences
} else {
TopBar(
title = appBarTitle,
scrollBehavior = scrollBehavior
scrollBehavior = scrollBehavior,
appBarActions = appBarActions
)
}
},
@@ -86,7 +87,7 @@ fun MainAppView(appNavController: NavHostController, preferences: AppPreferences
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
BottomNavigationRoutes(appNavController = appNavController, navController = navController, appBarTitle = appBarTitle)
BottomNavigationRoutes(appNavController = appNavController, navController = navController, appBarTitle = appBarTitle, appBarActions = appBarActions)
}
}
}
@@ -94,7 +95,8 @@ fun MainAppView(appNavController: NavHostController, preferences: AppPreferences
@Composable
private fun TopBar(
title: MutableState<String>,
scrollBehavior: TopAppBarScrollBehavior
scrollBehavior: TopAppBarScrollBehavior,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) {
LargeTopAppBar(
title = { Text(text = title.value) },
@@ -103,7 +105,8 @@ private fun TopBar(
.largeTopAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.primary
)
),
actions = appBarActions.value
)
}

View File

@@ -205,7 +205,7 @@ private fun ActionsView(
service = service
)
if (session?.isGuest == false) {
if (session?.isAuthorized == true) {
ActionButton(
modifier = Modifier.weight(1f),
text = stringResource(R.string.add_to_list_action_label),
@@ -641,7 +641,7 @@ private fun ReviewsCard(
)
},
footer = {
if (SessionManager.currentSession?.isGuest == false) {
if (SessionManager.currentSession?.isAuthorized == true) {
Row(
modifier = Modifier
.fillMaxWidth()

View File

@@ -1,14 +1,24 @@
package com.owenlejeune.tvtime.ui.screens.tabs.bottom
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
@@ -17,9 +27,13 @@ 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.api.tmdb.model.RatedMovie
import com.owenlejeune.tvtime.api.tmdb.model.RatedTopLevelMedia
import com.owenlejeune.tvtime.api.tmdb.model.RatedTv
import com.owenlejeune.tvtime.ui.components.RoundedLetterImage
import com.owenlejeune.tvtime.ui.components.SignInDialog
import com.owenlejeune.tvtime.ui.components.TopAppBarDropdownMenu
import com.owenlejeune.tvtime.ui.navigation.AccountTabNavItem
import com.owenlejeune.tvtime.ui.navigation.ListFetchFun
import com.owenlejeune.tvtime.ui.navigation.MainNavItem
@@ -27,31 +41,53 @@ 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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private const val GUEST_SIGN_IN = "guest_sign_in"
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_GUEST = "no_session_sign_in_guest"
@OptIn(ExperimentalPagerApi::class)
@Composable
fun AccountTab(appNavController: NavHostController, appBarTitle: MutableState<String>) {
if (SessionManager.currentSession?.isGuest == true) {
appBarTitle.value = "Hello, Guest"
fun AccountTab(
appNavController: NavHostController,
appBarTitle: MutableState<String>,
appBarActions: MutableState<@Composable (RowScope.() -> Unit)> = mutableStateOf({})
) {
if (SessionManager.currentSession?.isAuthorized == false) {
appBarTitle.value = stringResource(id = R.string.account_header_title_formatted).replace("%1\$s", stringResource(id = R.string.account_name_guest))
} else {
appBarTitle.value = "Not logged in"
appBarTitle.value = stringResource(id = R.string.account_not_logged_in)
}
SessionManager.currentSession?.let { session ->
val tabs = if (session.isGuest) {
AccountTabNavItem.GuestItems
} else {
AccountTabNavItem.GuestItems
}
val lastSelectedOption = remember { mutableStateOf("") }
Column {
val pagerState = rememberPagerState()
Tabs(tabs = tabs, pagerState = pagerState)
AccountTabs(
appNavController = appNavController,
tabs = tabs,
pagerState = pagerState
)
appBarActions.value = {
AccountDropdownMenu(session = SessionManager.currentSession, lastSelectedOption = lastSelectedOption)
}
if (lastSelectedOption.value.isNotBlank() || lastSelectedOption.value.isBlank()) {
SessionManager.currentSession?.let { session ->
val tabs = if (session.isAuthorized) {
AccountTabNavItem.GuestItems
} else {
AccountTabNavItem.GuestItems
}
Column {
val pagerState = rememberPagerState()
Tabs(tabs = tabs, pagerState = pagerState)
AccountTabs(
appNavController = appNavController,
tabs = tabs,
pagerState = pagerState
)
}
}
}
}
@@ -64,8 +100,22 @@ fun AccountTabContent(
) {
val contentItems = listFetchFun()
// if (contentItems.isNotEmpty() && contentItems[0] is RatedTopLevelMedia) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp)) {
LazyColumn(modifier = Modifier
.fillMaxSize()
.padding(12.dp)) {
if (contentItems.isEmpty()) {
item {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 15.dp),
text = stringResource(R.string.no_rated_content_message),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp,
textAlign = TextAlign.Center
)
}
} else {
items(contentItems.size) { i ->
val ratedItem = contentItems[i] as RatedTopLevelMedia
@@ -110,16 +160,163 @@ fun AccountTabContent(
)
Text(
text = "Rating: ${(ratedItem.rating * 10).toInt()}%",
text = stringResource(id = R.string.rating_test, (ratedItem.rating * 10).toInt()),
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
// }
}
}
}
@Composable
private fun AccountDropdownMenu(
session: SessionManager.Session?,
lastSelectedOption: MutableState<String>
) {
TopAppBarDropdownMenu(
icon = {
when(session?.isAuthorized) {
true -> { }
false -> { GuestSessionIcon() }
null -> { NoSessionAccountIcon() }
}
}
) { expanded ->
when(session?.isAuthorized) {
true -> { AuthorizedSessionMenuItems(expanded = expanded, lastSelectedOption = lastSelectedOption) }
false -> { GuestSessionMenuItems(expanded = expanded, lastSelectedOption = lastSelectedOption) }
null -> { NoSessionMenuItems(expanded = expanded, lastSelectedOption = lastSelectedOption) }
}
}
}
@Composable
private fun NoSessionMenuItems(
expanded: MutableState<Boolean>,
lastSelectedOption: MutableState<String>
) {
val showSignInDialog = remember { mutableStateOf(false) }
DropdownMenuItem(
onClick = {
showSignInDialog.value = true
},
modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = stringResource(R.string.action_sign_in), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (showSignInDialog.value) {
SignInDialog(showDialog = showSignInDialog) { success ->
if (success) {
lastSelectedOption.value = NO_SESSION_SIGN_IN
expanded.value = false
}
}
}
Divider(color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp))
DropdownMenuItem(
onClick = {
createGuestSession(lastSelectedOption)
expanded.value = false
}
) {
Text(text = stringResource(R.string.action_sign_in_as_guest), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun NoSessionAccountIcon() {
Icon(
modifier = Modifier
.size(50.dp)
.padding(end = 8.dp),
imageVector = Icons.Filled.AccountCircle,
contentDescription = stringResource(R.string.account_menu_content_description),
tint = MaterialTheme.colorScheme.primary
)
}
@Composable
private fun GuestSessionMenuItems(
expanded: MutableState<Boolean>,
lastSelectedOption: MutableState<String>
) {
val showSignInDialog = remember { mutableStateOf(false) }
DropdownMenuItem(
onClick = {
showSignInDialog.value = true
},
modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = stringResource(id = R.string.action_sign_in), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (showSignInDialog.value) {
SignInDialog(showDialog = showSignInDialog) { success ->
if (success) {
lastSelectedOption.value = GUEST_SIGN_IN
expanded.value = false
}
}
}
Divider(color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp))
DropdownMenuItem(
onClick = {
signOut(lastSelectedOption)
expanded.value = false
}
) {
Text(text = stringResource(id = R.string.action_sign_out), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun GuestSessionIcon() {
val guestName = stringResource(id = R.string.account_name_guest)
RoundedLetterImage(size = 40.dp, character = guestName[0], modifier = Modifier.padding(end = 8.dp), topPadding = 40.dp / 8)
}
@Composable
private fun AuthorizedSessionMenuItems(
expanded: MutableState<Boolean>,
lastSelectedOption: MutableState<String>
) {
DropdownMenuItem(
onClick = {
lastSelectedOption.value = ACCOUNT_SIGN_OUT
expanded.value = false
}
) {
Text(text = stringResource(id = R.string.action_sign_out), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
private fun createGuestSession(lastSelectedOption: MutableState<String>) {
CoroutineScope(Dispatchers.IO).launch {
val session = SessionManager.requestNewGuestSession()
if (session != null) {
withContext(Dispatchers.Main) {
lastSelectedOption.value = NO_SESSION_SIGN_IN_GUEST
}
}
}
}
private fun signOut(lastSelectedOption: MutableState<String>) {
SessionManager.clearSession { isSuccessful ->
if (isSuccessful) {
lastSelectedOption.value = SIGN_OUT
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun AccountTabs(

View File

@@ -1,10 +1,7 @@
package com.owenlejeune.tvtime.utils
import com.owenlejeune.tvtime.api.tmdb.TmdbClient
import com.owenlejeune.tvtime.api.tmdb.model.DeleteSessionBody
import com.owenlejeune.tvtime.api.tmdb.model.RatedEpisode
import com.owenlejeune.tvtime.api.tmdb.model.RatedMovie
import com.owenlejeune.tvtime.api.tmdb.model.RatedTv
import com.owenlejeune.tvtime.api.tmdb.model.*
import com.owenlejeune.tvtime.preferences.AppPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -27,13 +24,14 @@ object SessionManager: KoinComponent {
currentSession?.let { session ->
CoroutineScope(Dispatchers.IO).launch {
val deleteResponse = authenticationService.deleteSession(
DeleteSessionBody(
SessionBody(
session.sessionId
)
)
withContext(Dispatchers.Main) {
if (deleteResponse.isSuccessful) {
_currentSession = null
preferences.guestSessionId = ""
}
onResponse(deleteResponse.isSuccessful)
}
@@ -46,6 +44,8 @@ object SessionManager: KoinComponent {
val session = GuestSession()
session.initialize()
_currentSession = session
} else if (preferences.authorizedSessionId.isNotEmpty()) {
}
}
@@ -58,7 +58,29 @@ object SessionManager: KoinComponent {
return _currentSession
}
abstract class Session(val sessionId: String, val isGuest: Boolean) {
suspend fun signInWithLogin(email: String, password: String): Boolean {
val service = TmdbClient().createAuthenticationService()
val createTokenResponse = service.createRequestToken()
if (createTokenResponse.isSuccessful) {
createTokenResponse.body()?.let { ctr ->
val body = TokenValidationBody(email, password, ctr.requestToken)
val loginResponse = service.validateTokenWithLogin(body)
if (loginResponse.isSuccessful) {
loginResponse.body()?.let { lr ->
if (lr.success) {
preferences.authorizedSessionId = lr.requestToken
_currentSession = AuthorizedSession()
_currentSession?.initialize()
return true
}
}
}
}
}
return false
}
abstract class Session(val sessionId: String, val isAuthorized: Boolean) {
protected abstract var _ratedMovies: List<RatedMovie>
val ratedMovies: List<RatedMovie>
get() = _ratedMovies
@@ -88,7 +110,21 @@ object SessionManager: KoinComponent {
abstract suspend fun refresh()
}
private class GuestSession: Session(preferences.guestSessionId, true) {
private class AuthorizedSession: Session(preferences.authorizedSessionId, true) {
override var _ratedMovies: List<RatedMovie> = emptyList()
override var _ratedTvShows: List<RatedTv> = emptyList()
override var _ratedTvEpisodes: List<RatedEpisode> = emptyList()
override suspend fun initialize() {
refresh()
}
override suspend fun refresh() {
}
}
private class GuestSession: Session(preferences.guestSessionId, false) {
override var _ratedMovies: List<RatedMovie> = emptyList()
override var _ratedTvShows: List<RatedTv> = emptyList()
override var _ratedTvEpisodes: List<RatedEpisode> = emptyList()