template_0205
This commit is contained in:
430
RN_TEMPLATE/app/screens/LoginScreen.tsx
Normal file
430
RN_TEMPLATE/app/screens/LoginScreen.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import { ComponentType, FC, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Image, ImageStyle, TextInput, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { useFocusEffect } from "@react-navigation/native"
|
||||
|
||||
import { Button } from "@/components/Button"
|
||||
import { Icon, PressableIcon } from "@/components/Icon"
|
||||
import { Screen } from "@/components/Screen"
|
||||
import { Text } from "@/components/Text"
|
||||
import { TextField, type TextFieldAccessoryProps } from "@/components/TextField"
|
||||
import { useAuth } from "@/context/AuthContext"
|
||||
import type { AppStackScreenProps } from "@/navigators/navigationTypes"
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
interface LoginScreenProps extends AppStackScreenProps<"Login"> {}
|
||||
|
||||
export const LoginScreen: FC<LoginScreenProps> = ({ navigation }) => {
|
||||
const authPasswordInput = useRef<TextInput>(null)
|
||||
const verificationCodeInput = useRef<TextInput>(null)
|
||||
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [verificationCode, setVerificationCode] = useState("")
|
||||
const [isPasswordHidden, setIsPasswordHidden] = useState(true)
|
||||
const [resendCountdown, setResendCountdown] = useState(0)
|
||||
|
||||
const {
|
||||
loginStep,
|
||||
pendingEmail,
|
||||
isLoading,
|
||||
error,
|
||||
preLogin,
|
||||
resendCode,
|
||||
verifyLogin,
|
||||
googleLogin,
|
||||
resetLoginStep,
|
||||
clearError,
|
||||
} = useAuth()
|
||||
|
||||
const {
|
||||
themed,
|
||||
theme: { colors },
|
||||
} = useAppTheme()
|
||||
|
||||
// Email validation
|
||||
const emailError = useMemo(() => {
|
||||
if (!email || email.length === 0) return ""
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Please enter a valid email"
|
||||
return ""
|
||||
}, [email])
|
||||
|
||||
// Password validation - requires uppercase, lowercase, number, and special character
|
||||
const passwordError = useMemo(() => {
|
||||
if (!password || password.length === 0) return ""
|
||||
if (password.length < 8) return "Password must be at least 8 characters"
|
||||
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter"
|
||||
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter"
|
||||
if (!/[0-9]/.test(password)) return "Password must contain a number"
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password))
|
||||
return "Password must contain a special character"
|
||||
return ""
|
||||
}, [password])
|
||||
|
||||
// Countdown timer for resend button
|
||||
useEffect(() => {
|
||||
if (resendCountdown > 0) {
|
||||
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
return undefined
|
||||
}, [resendCountdown])
|
||||
|
||||
// Auto-focus verification code input when entering verification step
|
||||
useEffect(() => {
|
||||
if (loginStep === "verification") {
|
||||
// Small delay to ensure the input is rendered
|
||||
const timer = setTimeout(() => {
|
||||
verificationCodeInput.current?.focus()
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
return undefined
|
||||
}, [loginStep])
|
||||
|
||||
// Reset state when screen comes into focus (e.g., navigating back from another screen)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Clear any lingering errors from previous screens
|
||||
clearError()
|
||||
// Reset countdown and verification code when screen is focused
|
||||
// but only if we're on credentials step (not in the middle of verification)
|
||||
if (loginStep === "credentials") {
|
||||
setResendCountdown(0)
|
||||
setVerificationCode("")
|
||||
}
|
||||
}, [loginStep, clearError]),
|
||||
)
|
||||
|
||||
// Clear error when inputs change (not when error changes)
|
||||
const prevInputsRef = useRef({ email, password, verificationCode })
|
||||
useEffect(() => {
|
||||
const prev = prevInputsRef.current
|
||||
// Only clear error if one of the input fields actually changed
|
||||
if (
|
||||
prev.email !== email ||
|
||||
prev.password !== password ||
|
||||
prev.verificationCode !== verificationCode
|
||||
) {
|
||||
if (error) clearError()
|
||||
prevInputsRef.current = { email, password, verificationCode }
|
||||
}
|
||||
}, [email, password, verificationCode, error, clearError])
|
||||
|
||||
// Handle pre-login (step 1)
|
||||
const handlePreLogin = useCallback(async () => {
|
||||
if (!email || emailError || !password) return
|
||||
|
||||
const success = await preLogin(email, password)
|
||||
if (success) {
|
||||
setResendCountdown(60)
|
||||
}
|
||||
}, [email, emailError, password, preLogin])
|
||||
|
||||
// Handle verify login (step 2)
|
||||
const handleVerifyLogin = useCallback(async () => {
|
||||
if (!verificationCode || verificationCode.length !== 6) return
|
||||
|
||||
await verifyLogin(verificationCode)
|
||||
}, [verificationCode, verifyLogin])
|
||||
|
||||
// Handle resend code
|
||||
const handleResendCode = useCallback(async () => {
|
||||
if (resendCountdown > 0) return
|
||||
|
||||
const success = await resendCode()
|
||||
if (success) {
|
||||
setResendCountdown(60)
|
||||
}
|
||||
}, [resendCountdown, resendCode])
|
||||
|
||||
// Handle Google login
|
||||
const handleGoogleLogin = useCallback(async () => {
|
||||
await googleLogin()
|
||||
}, [googleLogin])
|
||||
|
||||
// Handle back to credentials step
|
||||
const handleBackToCredentials = useCallback(() => {
|
||||
resetLoginStep()
|
||||
setVerificationCode("")
|
||||
}, [resetLoginStep])
|
||||
|
||||
// Navigate to register
|
||||
const handleGoToRegister = useCallback(() => {
|
||||
navigation.navigate("Register")
|
||||
}, [navigation])
|
||||
|
||||
// Navigate to forgot password
|
||||
const handleGoToForgotPassword = useCallback(() => {
|
||||
navigation.navigate("ForgotPassword")
|
||||
}, [navigation])
|
||||
|
||||
// Password visibility toggle
|
||||
const PasswordRightAccessory: ComponentType<TextFieldAccessoryProps> = useMemo(
|
||||
() =>
|
||||
function PasswordRightAccessory(props: TextFieldAccessoryProps) {
|
||||
return (
|
||||
<PressableIcon
|
||||
icon={isPasswordHidden ? "view" : "hidden"}
|
||||
color={colors.palette.neutral800}
|
||||
containerStyle={props.style}
|
||||
size={s(20)}
|
||||
onPress={() => setIsPasswordHidden(!isPasswordHidden)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[isPasswordHidden, colors.palette.neutral800],
|
||||
)
|
||||
|
||||
// Render credentials step (step 1)
|
||||
const renderCredentialsStep = () => (
|
||||
<>
|
||||
<Text testID="login-heading" text="Log In" preset="heading" style={themed($logIn)} />
|
||||
<Text
|
||||
text="Enter your credentials to continue"
|
||||
preset="subheading"
|
||||
style={themed($enterDetails)}
|
||||
/>
|
||||
|
||||
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
|
||||
|
||||
<TextField
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
containerStyle={themed($textField)}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
helper={emailError}
|
||||
status={emailError ? "error" : undefined}
|
||||
onSubmitEditing={() => authPasswordInput.current?.focus()}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
ref={authPasswordInput}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
containerStyle={themed($textField)}
|
||||
autoCapitalize="none"
|
||||
autoComplete="password"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={isPasswordHidden}
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
helper={passwordError}
|
||||
status={passwordError ? "error" : undefined}
|
||||
onSubmitEditing={handlePreLogin}
|
||||
RightAccessory={PasswordRightAccessory}
|
||||
/>
|
||||
|
||||
<View style={themed($forgotPasswordContainer)}>
|
||||
<Button
|
||||
testID="forgot-password-button"
|
||||
text="Forgot Password?"
|
||||
preset="link"
|
||||
onPress={handleGoToForgotPassword}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
testID="login-button"
|
||||
text="Log In"
|
||||
style={themed($tapButton)}
|
||||
preset="reversed"
|
||||
onPress={handlePreLogin}
|
||||
disabled={isLoading || !email || !!emailError || !password || !!passwordError}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
<View style={themed($dividerContainer)}>
|
||||
<View style={themed($dividerLine)} />
|
||||
<Text text="OR" size="xs" style={themed($dividerText)} />
|
||||
<View style={themed($dividerLine)} />
|
||||
</View>
|
||||
|
||||
<Button
|
||||
testID="google-login-button"
|
||||
style={themed($googleButton)}
|
||||
preset="default"
|
||||
onPress={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
LeftAccessory={() => (
|
||||
<Image source={require("@assets/images/google-logo.png")} style={themed($googleLogo)} />
|
||||
)}
|
||||
>
|
||||
<Text text="Continue with Google" style={themed($googleButtonText)} />
|
||||
</Button>
|
||||
|
||||
<View style={themed($registerLinkContainer)}>
|
||||
<Text text="Don't have an account? " size="sm" />
|
||||
<Button
|
||||
testID="go-to-register-button"
|
||||
text="Register"
|
||||
preset="link"
|
||||
onPress={handleGoToRegister}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
// Render verification step (step 2)
|
||||
const renderVerificationStep = () => (
|
||||
<>
|
||||
<Text
|
||||
testID="verification-heading"
|
||||
text="Verify Your Email"
|
||||
preset="heading"
|
||||
style={themed($logIn)}
|
||||
/>
|
||||
<Text
|
||||
text={`We've sent a verification code to ${pendingEmail}`}
|
||||
preset="subheading"
|
||||
style={themed($enterDetails)}
|
||||
/>
|
||||
|
||||
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
|
||||
|
||||
<TextField
|
||||
ref={verificationCodeInput}
|
||||
value={verificationCode}
|
||||
onChangeText={(text) => setVerificationCode(text.replace(/[^0-9]/g, "").slice(0, 6))}
|
||||
containerStyle={themed($textField)}
|
||||
autoCapitalize="none"
|
||||
autoComplete="one-time-code"
|
||||
autoCorrect={false}
|
||||
keyboardType="number-pad"
|
||||
label="Verification Code"
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
onSubmitEditing={handleVerifyLogin}
|
||||
/>
|
||||
|
||||
<Button
|
||||
testID="verify-button"
|
||||
text="Verify"
|
||||
style={themed($tapButton)}
|
||||
preset="reversed"
|
||||
onPress={handleVerifyLogin}
|
||||
disabled={isLoading || verificationCode.length !== 6}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
<View style={themed($resendContainer)}>
|
||||
<Text text="Didn't receive the code? " size="sm" />
|
||||
<Button
|
||||
testID="resend-button"
|
||||
text={resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend Code"}
|
||||
preset="link"
|
||||
onPress={handleResendCode}
|
||||
disabled={isLoading || resendCountdown > 0}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
testID="back-button"
|
||||
text="Back to Login"
|
||||
preset="default"
|
||||
style={themed($backButton)}
|
||||
onPress={handleBackToCredentials}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Screen
|
||||
preset="auto"
|
||||
contentContainerStyle={themed($screenContentContainer)}
|
||||
safeAreaEdges={["top", "bottom"]}
|
||||
>
|
||||
{loginStep === "credentials" ? renderCredentialsStep() : renderVerificationStep()}
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
const $screenContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
paddingVertical: spacing.xxl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
})
|
||||
|
||||
const $logIn: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
marginBottom: spacing.sm,
|
||||
})
|
||||
|
||||
const $enterDetails: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
marginBottom: spacing.lg,
|
||||
})
|
||||
|
||||
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||||
color: colors.error,
|
||||
marginBottom: spacing.md,
|
||||
})
|
||||
|
||||
const $textField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
marginBottom: spacing.lg,
|
||||
})
|
||||
|
||||
const $tapButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
marginTop: spacing.xs,
|
||||
})
|
||||
|
||||
const $dividerContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginVertical: spacing.xl,
|
||||
})
|
||||
|
||||
const $dividerLine: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.separator,
|
||||
})
|
||||
|
||||
const $dividerText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||||
color: colors.textDim,
|
||||
marginHorizontal: spacing.md,
|
||||
})
|
||||
|
||||
const $googleButton: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
paddingVertical: spacing.sm,
|
||||
})
|
||||
|
||||
const $googleLogo: ThemedStyle<ImageStyle> = ({ spacing }) => ({
|
||||
width: s(20),
|
||||
height: s(20),
|
||||
marginRight: spacing.xs,
|
||||
})
|
||||
|
||||
const $googleButtonText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||||
color: colors.text,
|
||||
})
|
||||
|
||||
const $resendContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: spacing.lg,
|
||||
})
|
||||
|
||||
const $backButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
marginTop: spacing.lg,
|
||||
})
|
||||
|
||||
const $registerLinkContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: spacing.xl,
|
||||
})
|
||||
|
||||
const $forgotPasswordContainer: ThemedStyle<ViewStyle> = () => ({
|
||||
alignItems: "flex-end",
|
||||
})
|
||||
Reference in New Issue
Block a user