From b9c3e62eb0f8259d72f762543b06cf5e61b4b5df Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Thu, 25 Jun 2026 02:46:53 +0530
Subject: [PATCH] ref: adjust authscreen layout and gradients
---
.../composeResources/values/strings.xml | 4 +-
.../com/nuvio/app/features/auth/AuthScreen.kt | 1240 +++++++++++++----
2 files changed, 944 insertions(+), 300 deletions(-)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 78f0e206..71d719ef 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -324,7 +324,9 @@
Sign up to sync your data across devices
Sign Up
Your data will only be stored locally
- Stream everything, everywhere
+ Watch your library, anywhere
+ By signing up, I agree to the
+ Terms
Welcome Back
Library
Trakt Library
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
index 0f84bacc..cd510a5a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt
@@ -4,40 +4,47 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Email
+import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -48,29 +55,41 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.auth.AuthRepository
-import com.nuvio.app.core.ui.nuvioOverlayGradientBrush
-import com.nuvio.app.core.ui.NuvioPrimaryButton
-import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.dev.DebugSyncBackendSwitch
import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.PI
+import kotlin.math.sin
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.app_logo_wordmark
@@ -87,10 +106,84 @@ import nuvio.composeapp.generated.resources.compose_auth_sign_up
import nuvio.composeapp.generated.resources.compose_auth_sign_up_subtitle
import nuvio.composeapp.generated.resources.compose_auth_store_locally
import nuvio.composeapp.generated.resources.compose_auth_tagline
+import nuvio.composeapp.generated.resources.compose_auth_terms_link
+import nuvio.composeapp.generated.resources.compose_auth_terms_prefix
import nuvio.composeapp.generated.resources.compose_auth_welcome_back
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
+private val AuthTextPrimary = Color(0xFFF5F7F8)
+private val AuthTextSecondary = Color(0xFF969CA3)
+private val AuthTextMuted = Color(0xFF6E7178)
+private val AuthPrimaryButtonBackground = Color(0xFFF5F5F5)
+private val AuthPrimaryButtonText = Color(0xFF111111)
+private val AuthFieldBackground = Color.White.copy(alpha = 0.04f)
+private val AuthFieldBackgroundMobile = Color.White.copy(alpha = 0.035f)
+private val AuthFieldBorder = Color.White.copy(alpha = 0.08f)
+private val AuthPaneBackground = Color.White.copy(alpha = 0.022f)
+private val AuthPaneBorder = Color.White.copy(alpha = 0.07f)
+private val AuthDividerColor = Color.White.copy(alpha = 0.10f)
+private val AuthSecondaryButtonBackground = Color.White.copy(alpha = 0.05f)
+private val AuthSecondaryButtonBorder = Color.White.copy(alpha = 0.09f)
+
+private data class AuthFormMetrics(
+ val fieldHeight: Dp,
+ val fieldHorizontalPadding: Dp,
+ val iconSize: Dp,
+ val passwordIconSize: Dp,
+ val primaryTop: Dp,
+ val primaryHeight: Dp,
+ val toggleTop: Dp,
+ val dividerTop: Dp,
+ val secondaryTop: Dp,
+ val secondaryHeight: Dp,
+ val fieldBackground: Color,
+)
+
+private val MobileAuthFormMetrics = AuthFormMetrics(
+ fieldHeight = 56.dp,
+ fieldHorizontalPadding = 16.dp,
+ iconSize = 19.dp,
+ passwordIconSize = 20.dp,
+ primaryTop = 22.dp,
+ primaryHeight = 54.dp,
+ toggleTop = 18.dp,
+ dividerTop = 28.dp,
+ secondaryTop = 24.dp,
+ secondaryHeight = 54.dp,
+ fieldBackground = AuthFieldBackgroundMobile,
+)
+
+private val LargeAuthFormMetrics = AuthFormMetrics(
+ fieldHeight = 58.dp,
+ fieldHorizontalPadding = 18.dp,
+ iconSize = 20.dp,
+ passwordIconSize = 21.dp,
+ primaryTop = 24.dp,
+ primaryHeight = 56.dp,
+ toggleTop = 18.dp,
+ dividerTop = 30.dp,
+ secondaryTop = 26.dp,
+ secondaryHeight = 56.dp,
+ fieldBackground = AuthFieldBackground,
+)
+
+private fun largeAuthScale(screenWidth: Dp): Float =
+ (screenWidth.value / 1194f).coerceIn(1f, 1.32f)
+
+private fun largeAuthFormMetrics(scale: Float): AuthFormMetrics = LargeAuthFormMetrics.copy(
+ fieldHeight = 58.dp * scale,
+ fieldHorizontalPadding = 18.dp * scale,
+ iconSize = 20.dp * scale,
+ passwordIconSize = 21.dp * scale,
+ primaryTop = 24.dp * scale,
+ primaryHeight = 56.dp * scale,
+ toggleTop = 18.dp * scale,
+ dividerTop = 30.dp * scale,
+ secondaryTop = 26.dp * scale,
+ secondaryHeight = 56.dp * scale,
+)
+
@Composable
fun AuthScreen(
modifier: Modifier = Modifier,
@@ -106,6 +199,23 @@ fun AuthScreen(
var emailFieldBounds by remember { mutableStateOf(null) }
var passwordFieldBounds by remember { mutableStateOf(null) }
+ fun submitAuth() {
+ if (email.isBlank() || password.length < 6 || isLoading) return
+ isLoading = true
+ focusManager.clearFocus(force = true)
+ scope.launch {
+ if (isSignUp) AuthRepository.signUpWithEmail(email, password)
+ else AuthRepository.signInWithEmail(email, password)
+ isLoading = false
+ }
+ }
+
+ fun toggleAuthMode() {
+ isSignUp = !isSignUp
+ AuthRepository.clearError()
+ }
+
+ val showDebugSwitch = shouldShowDebugSyncBackendSwitch()
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
@@ -126,40 +236,131 @@ fun AuthScreen(
}
},
) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(brush = nuvioOverlayGradientBrush()),
- )
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ val screenWidth = maxWidth
+ val largeScreen = screenWidth >= 900.dp
+ val compactLargeScreen = screenWidth < 1100.dp
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .authGradientBackground(largeScreen = largeScreen),
+ ) {
+ if (largeScreen) {
+ val largeScale = largeAuthScale(screenWidth)
+ val formPaneWidth = (screenWidth * 0.30f).coerceIn(460.dp * largeScale, 720.dp * largeScale)
+ val brandHorizontalPadding = (screenWidth * 0.055f).coerceIn(56.dp * largeScale, 128.dp * largeScale)
+ val formHorizontalPadding = (formPaneWidth * 0.14f).coerceIn(48.dp, 96.dp)
+ val formMetrics = largeAuthFormMetrics(largeScale)
+
+ AuthLargeLayout(
+ modifier = Modifier.fillMaxSize(),
+ isSignUp = isSignUp,
+ email = email,
+ password = password,
+ passwordVisible = passwordVisible,
+ isLoading = isLoading,
+ authError = authError,
+ showDebugSwitch = showDebugSwitch,
+ formPaneWidth = if (compactLargeScreen) 460.dp else formPaneWidth,
+ brandHorizontalPadding = brandHorizontalPadding,
+ formHorizontalPadding = formHorizontalPadding,
+ formMetrics = formMetrics,
+ scale = largeScale,
+ onEmailChange = {
+ email = it
+ AuthRepository.clearError()
+ },
+ onPasswordChange = {
+ password = it
+ AuthRepository.clearError()
+ },
+ onPasswordVisibilityToggle = { passwordVisible = !passwordVisible },
+ onSubmit = ::submitAuth,
+ onToggleAuthMode = ::toggleAuthMode,
+ onContinueWithoutAccount = {
+ focusManager.clearFocus(force = true)
+ AuthRepository.signInAnonymously()
+ },
+ onEmailBoundsChange = { emailFieldBounds = it },
+ onPasswordBoundsChange = { passwordFieldBounds = it },
+ )
+ } else {
+ AuthMobileLayout(
+ isSignUp = isSignUp,
+ email = email,
+ password = password,
+ passwordVisible = passwordVisible,
+ isLoading = isLoading,
+ authError = authError,
+ showDebugSwitch = showDebugSwitch,
+ statusBarTop = statusBarTop,
+ onEmailChange = {
+ email = it
+ AuthRepository.clearError()
+ },
+ onPasswordChange = {
+ password = it
+ AuthRepository.clearError()
+ },
+ onPasswordVisibilityToggle = { passwordVisible = !passwordVisible },
+ onSubmit = ::submitAuth,
+ onToggleAuthMode = ::toggleAuthMode,
+ onContinueWithoutAccount = {
+ focusManager.clearFocus(force = true)
+ AuthRepository.signInAnonymously()
+ },
+ onEmailBoundsChange = { emailFieldBounds = it },
+ onPasswordBoundsChange = { passwordFieldBounds = it },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AuthMobileLayout(
+ isSignUp: Boolean,
+ email: String,
+ password: String,
+ passwordVisible: Boolean,
+ isLoading: Boolean,
+ authError: String?,
+ showDebugSwitch: Boolean,
+ statusBarTop: Dp,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onPasswordVisibilityToggle: () -> Unit,
+ onSubmit: () -> Unit,
+ onToggleAuthMode: () -> Unit,
+ onContinueWithoutAccount: () -> Unit,
+ onEmailBoundsChange: (Rect) -> Unit,
+ onPasswordBoundsChange: (Rect) -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(
+ start = 30.dp,
+ top = statusBarTop + 48.dp,
+ end = 30.dp,
+ bottom = 40.dp,
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
Column(
modifier = Modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(start = 24.dp, end = 24.dp, top = statusBarTop + 60.dp, bottom = 40.dp),
+ .widthIn(max = 342.dp)
+ .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- Column(
- modifier = Modifier
- .widthIn(max = 460.dp)
- .fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Image(
- painter = painterResource(Res.drawable.app_logo_wordmark),
- contentDescription = null,
- modifier = Modifier
- .fillMaxWidth(0.6f)
- .height(48.dp),
- contentScale = ContentScale.Fit,
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = stringResource(Res.string.compose_auth_tagline),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+ AuthBrandLockup(logoHeight = 38.dp)
- if (shouldShowDebugSyncBackendSwitch()) {
+ if (showDebugSwitch) {
Spacer(modifier = Modifier.height(24.dp))
DebugSyncBackendSwitch(
modifier = Modifier.fillMaxWidth(),
@@ -168,274 +369,715 @@ fun AuthScreen(
)
}
- Spacer(modifier = Modifier.height(48.dp))
+ Spacer(modifier = Modifier.height(64.dp))
- NuvioSurfaceCard {
- AnimatedContent(
- targetState = isSignUp,
- transitionSpec = { fadeIn() togetherWith fadeOut() },
- label = "heading",
- ) { signUp ->
- Text(
- text = if (signUp) stringResource(Res.string.compose_auth_create_account)
- else stringResource(Res.string.compose_auth_welcome_back),
- style = MaterialTheme.typography.headlineLarge,
- color = MaterialTheme.colorScheme.onSurface,
- )
- }
- Spacer(modifier = Modifier.height(6.dp))
- AnimatedContent(
- targetState = isSignUp,
- transitionSpec = { fadeIn() togetherWith fadeOut() },
- label = "subtitle",
- ) { signUp ->
- Text(
- text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle)
- else stringResource(Res.string.compose_auth_sign_in_subtitle),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- OutlinedTextField(
- value = email,
- onValueChange = {
- email = it
- AuthRepository.clearError()
- },
- modifier = Modifier
- .fillMaxWidth()
- .onGloballyPositioned { coordinates ->
- emailFieldBounds = coordinates.boundsInRoot()
- },
- singleLine = true,
- placeholder = {
- Text(
- text = stringResource(Res.string.compose_auth_email),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- },
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Email,
- imeAction = ImeAction.Next,
- ),
- shape = RoundedCornerShape(14.dp),
- textStyle = MaterialTheme.typography.bodyLarge.copy(
- color = MaterialTheme.colorScheme.onSurface,
- ),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = MaterialTheme.colorScheme.primary,
- unfocusedBorderColor = MaterialTheme.colorScheme.outline,
- focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
- unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
- cursorColor = MaterialTheme.colorScheme.primary,
- ),
- )
-
- Spacer(modifier = Modifier.height(14.dp))
-
- OutlinedTextField(
- value = password,
- onValueChange = {
- password = it
- AuthRepository.clearError()
- },
- modifier = Modifier
- .fillMaxWidth()
- .onGloballyPositioned { coordinates ->
- passwordFieldBounds = coordinates.boundsInRoot()
- },
- singleLine = true,
- placeholder = {
- Text(
- text = stringResource(Res.string.compose_auth_password),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- },
- visualTransformation = if (passwordVisible) VisualTransformation.None
- else PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Password,
- imeAction = ImeAction.Done,
- ),
- keyboardActions = KeyboardActions(
- onDone = {
- if (email.isNotBlank() && password.isNotBlank() && !isLoading) {
- isLoading = true
- scope.launch {
- if (isSignUp) AuthRepository.signUpWithEmail(email, password)
- else AuthRepository.signInWithEmail(email, password)
- isLoading = false
- }
- }
- },
- ),
- trailingIcon = {
- IconButton(onClick = { passwordVisible = !passwordVisible }) {
- Icon(
- imageVector = if (passwordVisible) Icons.Rounded.VisibilityOff
- else Icons.Rounded.Visibility,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- },
- shape = RoundedCornerShape(14.dp),
- textStyle = MaterialTheme.typography.bodyLarge.copy(
- color = MaterialTheme.colorScheme.onSurface,
- ),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = MaterialTheme.colorScheme.primary,
- unfocusedBorderColor = MaterialTheme.colorScheme.outline,
- focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
- unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
- cursorColor = MaterialTheme.colorScheme.primary,
- ),
- )
-
- authError?.let { errorText ->
- Spacer(modifier = Modifier.height(12.dp))
- Text(
- text = errorText,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.error,
- )
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- NuvioPrimaryButton(
- text = if (isLoading) {
- ""
- } else if (isSignUp) {
- stringResource(Res.string.compose_auth_create_account)
- } else {
- stringResource(Res.string.compose_auth_sign_in)
- },
- enabled = email.isNotBlank() && password.length >= 6 && !isLoading,
- onClick = {
- isLoading = true
- scope.launch {
- if (isSignUp) AuthRepository.signUpWithEmail(email, password)
- else AuthRepository.signInWithEmail(email, password)
- isLoading = false
- }
- },
- )
-
- if (isLoading) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp)
- .padding(top = 4.dp),
- contentAlignment = Alignment.Center,
- ) {
- CircularProgressIndicator(
- color = MaterialTheme.colorScheme.primary,
- strokeWidth = 2.5.dp,
- )
- }
- }
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center,
- ) {
- AnimatedContent(
- targetState = isSignUp,
- transitionSpec = { fadeIn() togetherWith fadeOut() },
- label = "togglePrompt",
- ) { signUp ->
- Text(
- text = if (signUp) stringResource(Res.string.compose_auth_already_have_account)
- else stringResource(Res.string.compose_auth_dont_have_account),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- AnimatedContent(
- targetState = isSignUp,
- transitionSpec = { fadeIn() togetherWith fadeOut() },
- label = "toggleAction",
- ) { signUp ->
- Text(
- text = if (signUp) stringResource(Res.string.compose_auth_sign_in)
- else stringResource(Res.string.compose_auth_sign_up),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.clickable {
- isSignUp = !isSignUp
- AuthRepository.clearError()
- },
- )
- }
- }
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = Modifier
- .weight(1f)
- .height(1.dp)
- .background(MaterialTheme.colorScheme.outline),
- )
- Text(
- text = stringResource(Res.string.compose_auth_or_separator),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Box(
- modifier = Modifier
- .weight(1f)
- .height(1.dp)
- .background(MaterialTheme.colorScheme.outline),
- )
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- Button(
- onClick = {
- AuthRepository.signInAnonymously()
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp),
- enabled = !isLoading,
- shape = RoundedCornerShape(16.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant,
- contentColor = MaterialTheme.colorScheme.onSurface,
+ AuthHeading(
+ isSignUp = isSignUp,
+ headingStyle = MaterialTheme.typography.headlineLarge.copy(
+ color = AuthTextPrimary,
+ fontSize = 28.sp,
+ lineHeight = 31.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ subtitleStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = AuthTextSecondary,
+ fontSize = 15.sp,
+ lineHeight = 21.sp,
+ fontWeight = FontWeight.Normal,
),
- ) {
- Text(
- text = stringResource(Res.string.compose_auth_continue_without_account),
- style = MaterialTheme.typography.titleMedium,
- textAlign = TextAlign.Center,
- )
- }
-
- Spacer(modifier = Modifier.height(12.dp))
- Text(
- text = stringResource(Res.string.compose_auth_store_locally),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
)
+
+ Spacer(modifier = Modifier.height(28.dp))
+
+ AuthForm(
+ isSignUp = isSignUp,
+ email = email,
+ password = password,
+ passwordVisible = passwordVisible,
+ isLoading = isLoading,
+ authError = authError,
+ metrics = MobileAuthFormMetrics,
+ onEmailChange = onEmailChange,
+ onPasswordChange = onPasswordChange,
+ onPasswordVisibilityToggle = onPasswordVisibilityToggle,
+ onSubmit = onSubmit,
+ onToggleAuthMode = onToggleAuthMode,
+ onContinueWithoutAccount = onContinueWithoutAccount,
+ onEmailBoundsChange = onEmailBoundsChange,
+ onPasswordBoundsChange = onPasswordBoundsChange,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AuthLargeLayout(
+ modifier: Modifier = Modifier,
+ isSignUp: Boolean,
+ email: String,
+ password: String,
+ passwordVisible: Boolean,
+ isLoading: Boolean,
+ authError: String?,
+ showDebugSwitch: Boolean,
+ formPaneWidth: Dp,
+ brandHorizontalPadding: Dp,
+ formHorizontalPadding: Dp,
+ formMetrics: AuthFormMetrics,
+ scale: Float,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onPasswordVisibilityToggle: () -> Unit,
+ onSubmit: () -> Unit,
+ onToggleAuthMode: () -> Unit,
+ onContinueWithoutAccount: () -> Unit,
+ onEmailBoundsChange: (Rect) -> Unit,
+ onPasswordBoundsChange: (Rect) -> Unit,
+) {
+ Row(
+ modifier = modifier,
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(horizontal = brandHorizontalPadding),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Image(
+ painter = painterResource(Res.drawable.app_logo_wordmark),
+ contentDescription = null,
+ modifier = Modifier.height(60.dp * scale),
+ contentScale = ContentScale.Fit,
+ )
+ Spacer(modifier = Modifier.height(32.dp * scale))
+ Text(
+ text = stringResource(Res.string.compose_auth_tagline),
+ modifier = Modifier.widthIn(max = 440.dp * scale),
+ style = MaterialTheme.typography.displayLarge.copy(
+ color = AuthTextPrimary,
+ fontSize = (40f * scale).sp,
+ lineHeight = (45f * scale).sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ Spacer(modifier = Modifier.height(18.dp * scale))
+ Text(
+ text = stringResource(Res.string.compose_auth_sign_up_subtitle),
+ modifier = Modifier.widthIn(max = 400.dp * scale),
+ style = MaterialTheme.typography.bodyLarge.copy(
+ color = AuthTextSecondary,
+ fontSize = (17f * scale).sp,
+ lineHeight = (26f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+
+ if (showDebugSwitch) {
+ Spacer(modifier = Modifier.height(24.dp * scale))
+ DebugSyncBackendSwitch(
+ modifier = Modifier.widthIn(max = 520.dp * scale),
+ requireConfirmation = false,
+ container = true,
+ )
}
}
+
+ Box(
+ modifier = Modifier
+ .width(formPaneWidth)
+ .fillMaxHeight()
+ .background(AuthPaneBackground)
+ .drawBehind {
+ drawLine(
+ color = AuthPaneBorder,
+ start = Offset(0f, 0f),
+ end = Offset(0f, size.height),
+ strokeWidth = 1.dp.toPx(),
+ )
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = formHorizontalPadding),
+ ) {
+ AuthHeading(
+ isSignUp = isSignUp,
+ headingStyle = MaterialTheme.typography.headlineLarge.copy(
+ color = AuthTextPrimary,
+ fontSize = (30f * scale).sp,
+ lineHeight = (33f * scale).sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ subtitleStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = AuthTextSecondary,
+ fontSize = (15f * scale).sp,
+ lineHeight = (21f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+
+ Spacer(modifier = Modifier.height(32.dp * scale))
+
+ AuthForm(
+ isSignUp = isSignUp,
+ email = email,
+ password = password,
+ passwordVisible = passwordVisible,
+ isLoading = isLoading,
+ authError = authError,
+ metrics = formMetrics,
+ scale = scale,
+ onEmailChange = onEmailChange,
+ onPasswordChange = onPasswordChange,
+ onPasswordVisibilityToggle = onPasswordVisibilityToggle,
+ onSubmit = onSubmit,
+ onToggleAuthMode = onToggleAuthMode,
+ onContinueWithoutAccount = onContinueWithoutAccount,
+ onEmailBoundsChange = onEmailBoundsChange,
+ onPasswordBoundsChange = onPasswordBoundsChange,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AuthBrandLockup(
+ logoHeight: Dp,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(Res.drawable.app_logo_wordmark),
+ contentDescription = null,
+ modifier = Modifier.height(logoHeight),
+ contentScale = ContentScale.Fit,
+ )
+ Spacer(modifier = Modifier.height(14.dp))
+ Text(
+ text = stringResource(Res.string.compose_auth_tagline),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = AuthTextSecondary,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun AuthHeading(
+ isSignUp: Boolean,
+ headingStyle: TextStyle,
+ subtitleStyle: TextStyle,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ AnimatedContent(
+ targetState = isSignUp,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "authHeading",
+ ) { signUp ->
+ Text(
+ text = if (signUp) stringResource(Res.string.compose_auth_create_account)
+ else stringResource(Res.string.compose_auth_welcome_back),
+ style = headingStyle,
+ color = AuthTextPrimary,
+ )
+ }
+ AnimatedContent(
+ targetState = isSignUp,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "authSubtitle",
+ ) { signUp ->
+ Text(
+ text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle)
+ else stringResource(Res.string.compose_auth_sign_in_subtitle),
+ style = subtitleStyle,
+ color = AuthTextSecondary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AuthForm(
+ isSignUp: Boolean,
+ email: String,
+ password: String,
+ passwordVisible: Boolean,
+ isLoading: Boolean,
+ authError: String?,
+ metrics: AuthFormMetrics,
+ scale: Float = 1f,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onPasswordVisibilityToggle: () -> Unit,
+ onSubmit: () -> Unit,
+ onToggleAuthMode: () -> Unit,
+ onContinueWithoutAccount: () -> Unit,
+ onEmailBoundsChange: (Rect) -> Unit,
+ onPasswordBoundsChange: (Rect) -> Unit,
+) {
+ val uriHandler = LocalUriHandler.current
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ AuthTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ placeholder = stringResource(Res.string.compose_auth_email),
+ icon = Icons.Rounded.Email,
+ metrics = metrics,
+ scale = scale,
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ onEmailBoundsChange(coordinates.boundsInRoot())
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next,
+ ),
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ AuthTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ placeholder = stringResource(Res.string.compose_auth_password),
+ icon = Icons.Rounded.Lock,
+ metrics = metrics,
+ scale = scale,
+ isPassword = true,
+ passwordVisible = passwordVisible,
+ onPasswordVisibilityToggle = onPasswordVisibilityToggle,
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ onPasswordBoundsChange(coordinates.boundsInRoot())
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = { onSubmit() },
+ ),
+ )
+
+ authError?.let { errorText ->
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = errorText,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.error,
+ fontSize = (13f * scale).sp,
+ lineHeight = (18f * scale).sp,
+ ),
+ )
+ }
+
+ if (isSignUp) {
+ Spacer(modifier = Modifier.height(14.dp * scale))
+ AuthTermsAcknowledgement(
+ scale = scale,
+ onTermsClick = { uriHandler.openUri("https://nuvio.tv/terms") },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(metrics.primaryTop))
+
+ AuthPrimaryButton(
+ text = if (isSignUp) stringResource(Res.string.compose_auth_create_account)
+ else stringResource(Res.string.compose_auth_sign_in),
+ isLoading = isLoading,
+ enabled = !isLoading,
+ height = metrics.primaryHeight,
+ scale = scale,
+ onClick = onSubmit,
+ )
+
+ Spacer(modifier = Modifier.height(metrics.toggleTop))
+
+ AuthModeToggle(
+ isSignUp = isSignUp,
+ scale = scale,
+ onToggleAuthMode = onToggleAuthMode,
+ )
+
+ Spacer(modifier = Modifier.height(metrics.dividerTop))
+
+ AuthDivider(scale = scale)
+
+ Spacer(modifier = Modifier.height(metrics.secondaryTop))
+
+ AuthSecondaryButton(
+ text = stringResource(Res.string.compose_auth_continue_without_account),
+ enabled = !isLoading,
+ height = metrics.secondaryHeight,
+ scale = scale,
+ onClick = onContinueWithoutAccount,
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ Text(
+ text = stringResource(Res.string.compose_auth_store_locally),
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = AuthTextMuted,
+ fontSize = (13f * scale).sp,
+ lineHeight = (18f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun AuthTermsAcknowledgement(
+ scale: Float,
+ onTermsClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(Res.string.compose_auth_terms_prefix),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = AuthTextSecondary,
+ fontSize = (13f * scale).sp,
+ lineHeight = (18f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+ Spacer(modifier = Modifier.width(4.dp * scale))
+ Text(
+ text = stringResource(Res.string.compose_auth_terms_link),
+ modifier = Modifier.clickable(onClick = onTermsClick),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = AuthTextPrimary,
+ fontSize = (13f * scale).sp,
+ lineHeight = (18f * scale).sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun AuthTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String,
+ icon: ImageVector,
+ metrics: AuthFormMetrics,
+ scale: Float,
+ modifier: Modifier = Modifier,
+ isPassword: Boolean = false,
+ passwordVisible: Boolean = false,
+ onPasswordVisibilityToggle: () -> Unit = {},
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+) {
+ val shape = RoundedCornerShape(14.dp)
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(metrics.fieldHeight)
+ .background(metrics.fieldBackground, shape)
+ .border(1.dp, AuthFieldBorder, shape)
+ .padding(horizontal = metrics.fieldHorizontalPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(metrics.iconSize),
+ tint = AuthTextMuted,
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = AuthTextPrimary,
+ fontSize = (16f * scale).sp,
+ lineHeight = (22f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ cursorBrush = SolidColor(AuthTextPrimary),
+ visualTransformation = if (isPassword && !passwordVisible) {
+ PasswordVisualTransformation()
+ } else {
+ VisualTransformation.None
+ },
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ decorationBox = { innerTextField ->
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (value.isEmpty()) {
+ Text(
+ text = placeholder,
+ style = MaterialTheme.typography.bodyLarge.copy(
+ color = AuthTextMuted,
+ fontSize = (16f * scale).sp,
+ lineHeight = (22f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+ if (isPassword) {
+ Spacer(modifier = Modifier.width(12.dp))
+ Box(
+ modifier = Modifier
+ .size(30.dp)
+ .clip(RoundedCornerShape(15.dp))
+ .clickable(onClick = onPasswordVisibilityToggle),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = if (passwordVisible) Icons.Rounded.VisibilityOff
+ else Icons.Rounded.Visibility,
+ contentDescription = null,
+ modifier = Modifier.size(metrics.passwordIconSize),
+ tint = AuthTextSecondary,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AuthPrimaryButton(
+ text: String,
+ isLoading: Boolean,
+ enabled: Boolean,
+ height: Dp,
+ scale: Float,
+ onClick: () -> Unit,
+) {
+ Button(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(height),
+ enabled = enabled,
+ shape = RoundedCornerShape(16.dp),
+ contentPadding = PaddingValues(0.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = AuthPrimaryButtonBackground,
+ contentColor = AuthPrimaryButtonText,
+ disabledContainerColor = if (isLoading) {
+ AuthPrimaryButtonBackground
+ } else {
+ AuthPrimaryButtonBackground.copy(alpha = 0.45f)
+ },
+ disabledContentColor = AuthPrimaryButtonText.copy(alpha = 0.55f),
+ ),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp * scale),
+ color = AuthPrimaryButtonText,
+ strokeWidth = 2.dp * scale,
+ )
+ } else {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontSize = (16f * scale).sp,
+ lineHeight = (22f * scale).sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun AuthModeToggle(
+ isSignUp: Boolean,
+ scale: Float,
+ onToggleAuthMode: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AnimatedContent(
+ targetState = isSignUp,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "authTogglePrompt",
+ ) { signUp ->
+ Text(
+ text = if (signUp) {
+ stringResource(Res.string.compose_auth_already_have_account).trimEnd()
+ } else {
+ stringResource(Res.string.compose_auth_dont_have_account).trimEnd()
+ },
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = AuthTextSecondary,
+ fontSize = (14f * scale).sp,
+ lineHeight = (20f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+ }
+ Spacer(modifier = Modifier.width(6.dp))
+ AnimatedContent(
+ targetState = isSignUp,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "authToggleAction",
+ ) { signUp ->
+ Text(
+ text = if (signUp) stringResource(Res.string.compose_auth_sign_in)
+ else stringResource(Res.string.compose_auth_sign_up),
+ modifier = Modifier.clickable(onClick = onToggleAuthMode),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = AuthTextPrimary,
+ fontSize = (14f * scale).sp,
+ lineHeight = (20f * scale).sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun AuthDivider(scale: Float) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(1.dp)
+ .background(AuthDividerColor),
+ )
+ Text(
+ text = stringResource(Res.string.compose_auth_or_separator).trim(),
+ modifier = Modifier.padding(horizontal = 16.dp),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = AuthTextMuted,
+ fontSize = (13f * scale).sp,
+ lineHeight = (18f * scale).sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(1.dp)
+ .background(AuthDividerColor),
+ )
+ }
+}
+
+@Composable
+private fun AuthSecondaryButton(
+ text: String,
+ enabled: Boolean,
+ height: Dp,
+ scale: Float,
+ onClick: () -> Unit,
+) {
+ Button(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(height),
+ enabled = enabled,
+ shape = RoundedCornerShape(16.dp),
+ border = BorderStroke(1.dp, AuthSecondaryButtonBorder),
+ contentPadding = PaddingValues(0.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = AuthSecondaryButtonBackground,
+ contentColor = AuthTextPrimary,
+ disabledContainerColor = AuthSecondaryButtonBackground.copy(alpha = 0.45f),
+ disabledContentColor = AuthTextPrimary.copy(alpha = 0.55f),
+ ),
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontSize = (15f * scale).sp,
+ lineHeight = (21f * scale).sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+private fun Modifier.authGradientBackground(largeScreen: Boolean): Modifier = drawWithCache {
+ val angleDegrees = if (largeScreen) 122.0 else 148.0
+ val angleRadians = angleDegrees * PI / 180.0
+ val directionX = sin(angleRadians).toFloat()
+ val directionY = (-cos(angleRadians)).toFloat()
+ val halfLength = (abs(size.width * directionX) + abs(size.height * directionY)) / 2f
+ val center = Offset(size.width / 2f, size.height / 2f)
+ val start = Offset(
+ x = center.x - directionX * halfLength,
+ y = center.y - directionY * halfLength,
+ )
+ val end = Offset(
+ x = center.x + directionX * halfLength,
+ y = center.y + directionY * halfLength,
+ )
+ val colorStops = if (largeScreen) {
+ arrayOf(
+ 0f to Color(0xFF21113B),
+ 0.14f to Color(0xFF21113B),
+ 0.26f to Color(0xFF1A0E2F),
+ 0.36f to Color(0xFF130A23),
+ 0.48f to Color(0xFF0A060F),
+ 0.60f to Color(0xFF050408),
+ 0.70f to Color.Black,
+ 1f to Color.Black,
+ )
+ } else {
+ arrayOf(
+ 0f to Color(0xFF21113B),
+ 0.12f to Color(0xFF21113B),
+ 0.24f to Color(0xFF1A0E2F),
+ 0.34f to Color(0xFF130A23),
+ 0.44f to Color(0xFF0A060F),
+ 0.58f to Color(0xFF050408),
+ 0.64f to Color.Black,
+ 1f to Color.Black,
+ )
+ }
+ val brush = Brush.linearGradient(
+ colorStops = colorStops,
+ start = start,
+ end = end,
+ )
+ onDrawBehind {
+ drawRect(brush = brush)
}
}