feat: optimize login page

This commit is contained in:
DIYgod 2025-02-26 20:55:29 +08:00
parent ee01f9a799
commit e481486921
No known key found for this signature in database
6 changed files with 86 additions and 163 deletions

View File

@ -1,9 +1,8 @@
import { cn } from "@follow/utils"
import { useEffect } from "react"
import type { PressableProps } from "react-native"
import { ActivityIndicator, Text } from "react-native"
import {
interpolate,
import { ActivityIndicator } from "react-native"
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
@ -20,7 +19,9 @@ export function SubmitButton({
title,
...props
}: PressableProps & { isLoading?: boolean; title: string }) {
const disableColor = useColor("gray3")
const disableColor = useColor("gray6")
const disabledTextColor = useColor("gray2")
const textColor = useColor("gray6")
const disabledValue = useSharedValue(1)
useEffect(() => {
@ -28,21 +29,26 @@ export function SubmitButton({
}, [props.disabled])
const buttonStyle = useAnimatedStyle(() => ({
opacity: interpolate(disabledValue.value, [0, 1], [1, 0.5]),
backgroundColor: interpolateColor(disabledValue.value, [1, 0], [disableColor, accentColor]),
}))
const textStyle = useAnimatedStyle(() => ({
color: interpolateColor(disabledValue.value, [1, 0], [disabledTextColor, textColor]),
}))
return (
<ReAnimatedPressable
{...props}
disabled={props.disabled || isLoading}
style={[buttonStyle, props.style]}
className={cn("h-10 flex-row items-center justify-center rounded-3xl", props.className)}
className={cn("h-[48] flex-row items-center justify-center rounded-2xl", props.className)}
>
{isLoading ? (
<ActivityIndicator className="text-white" />
) : (
<Text className="text-center font-semibold text-white">{title}</Text>
<Animated.Text className="text-center text-xl font-semibold" style={textStyle}>
{title}
</Animated.Text>
)}
</ReAnimatedPressable>
)

View File

@ -1,7 +0,0 @@
import { createContext } from "react"
export const LoginTermsCheckedContext = createContext(__DEV__)
export const LoginTermsCheckGuardContext = createContext<((callback: () => void) => void) | null>(
() => {},
)

View File

@ -1,16 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import { useContext } from "react"
import type { Control } from "react-hook-form"
import { useController, useForm } from "react-hook-form"
import type { TextInputProps } from "react-native"
import { Text, TextInput, View } from "react-native"
import { TextInput, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
import { z } from "zod"
import { SubmitButton } from "@/src/components/common/SubmitButton"
import { LoginTermsCheckGuardContext } from "@/src/contexts/LoginTermsContext"
import { signIn } from "@/src/lib/auth"
import { toast } from "@/src/lib/toast"
import { accentColor } from "@/src/theme/colors"
@ -77,16 +75,14 @@ export function EmailLogin() {
mutationFn: onSubmit,
})
const termsCheckGuard = useContext(LoginTermsCheckGuardContext)
const login = handleSubmit((values) => {
termsCheckGuard?.(() => submitMutation.mutate(values))
submitMutation.mutate(values)
})
return (
<View className="mx-auto flex w-full max-w-sm gap-6">
<View className="gap-4">
<View className="bg-system-grouped-background gap-4 rounded-2xl px-6 py-4">
<View className="flex-row">
<Text className="text-label w-28">Account</Text>
<Input
autoCapitalize="none"
autoCorrect={false}
@ -102,16 +98,15 @@ export function EmailLogin() {
}}
/>
</View>
<View className="border-b-opaque-separator border-b-hairline ml-28" />
<View className="border-b-opaque-separator border-b-hairline" />
<View className="flex-row">
<Text className="text-label w-28">Password</Text>
<Input
autoCapitalize="none"
autoCorrect={false}
autoComplete="current-password"
control={control}
name="password"
placeholder="Enter password"
placeholder="Password"
className="text-text flex-1"
secureTextEntry
returnKeyType="go"

View File

@ -1,138 +1,70 @@
import { Link, router } from "expo-router"
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"
import { useCallback } from "react"
import { Text, TouchableWithoutFeedback, View } from "react-native"
import BouncyCheckbox from "react-native-bouncy-checkbox"
import { KeyboardController } from "react-native-keyboard-controller"
import Animated, {
runOnUI,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated"
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"
import * as ContextMenu from "zeego/context-menu"
import { Logo } from "@/src/components/ui/logo"
import {
LoginTermsCheckedContext,
LoginTermsCheckGuardContext,
} from "@/src/contexts/LoginTermsContext"
import { isIOS } from "@/src/lib/platform"
import { toast } from "@/src/lib/toast"
import { TermsMarkdown } from "@/src/screens/(headless)/terms"
import { EmailLogin } from "./email"
import { SocialLogin } from "./social"
export function Login() {
const [isChecked, setIsChecked] = useState(false)
const termsCheckBoxRef = useRef<{ shake: () => void }>(null)
return (
<LoginTermsCheckedContext.Provider value={isChecked}>
<LoginTermsCheckGuardContext.Provider
value={useCallback(
(callback: () => void) => {
if (isChecked) {
callback()
} else {
toast.info("Please accept the Terms of Service and Privacy Policy")
termsCheckBoxRef.current?.shake()
}
},
[isChecked],
)}
<View className="flex h-full justify-center p-safe">
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View className="flex-1 p-safe">
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View className="flex-1 items-center gap-8 pt-20">
<Logo style={{ width: 80, height: 80 }} />
<Text className="text-label text-2xl font-bold">Login to Follow</Text>
<EmailLogin />
</View>
</TouchableWithoutFeedback>
<TermsCheckBox ref={termsCheckBoxRef} isChecked={isChecked} setIsChecked={setIsChecked} />
<View className="border-t-opaque-separator border-t-hairline mx-28" />
<View className="mt-2 items-center">
<View className="mb-4 flex w-full max-w-sm flex-row items-center gap-4">
<View className="bg-separator my-4 h-[0.5px] flex-1" />
<Text className="text-secondary-label text-lg">or</Text>
<View className="bg-separator my-4 h-[0.5px] flex-1" />
</View>
<SocialLogin />
</View>
<View className="mb-16 items-center gap-8 pt-20">
<Logo style={{ width: 80, height: 80 }} />
<Text className="text-label text-3xl">
Sign in to <Text className="font-bold">Follow</Text>
</Text>
<EmailLogin />
</View>
</LoginTermsCheckGuardContext.Provider>
</LoginTermsCheckedContext.Provider>
</TouchableWithoutFeedback>
<View className="border-t-opaque-separator border-t-hairline mx-28" />
<View className="mb-20 mt-2 items-center">
<View className="mb-4 flex w-full max-w-sm flex-row items-center gap-4">
<View className="bg-separator my-4 h-[0.5px] flex-1" />
<Text className="text-secondary-label text-lg">or</Text>
<View className="bg-separator my-4 h-[0.5px] flex-1" />
</View>
<SocialLogin />
</View>
<TermsCheckBox />
</View>
)
}
const TermsCheckBox = forwardRef<
{ shake: () => void },
{
isChecked: boolean
setIsChecked: (isChecked: boolean) => void
}
>(({ isChecked, setIsChecked }, ref) => {
const TermsCheckBox = () => {
const shakeSharedValue = useSharedValue(0)
const shakeStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shakeSharedValue.value }],
}))
useImperativeHandle(ref, () => ({
shake: () => {
const animations = [-10, 10, -8, 8, -6, 6, 0]
runOnUI(() => {
"worklet"
shakeSharedValue.value = 0
const runAnimation = (index: number) => {
"worklet"
if (index < animations.length) {
shakeSharedValue.value = withTiming(animations[index]!, { duration: 100 }, () => {
runAnimation(index + 1)
})
}
}
runAnimation(0)
})()
},
}))
return (
<Animated.View className="mb-4 flex-row items-center gap-2 px-8" style={shakeStyle}>
<BouncyCheckbox
className="gap-2"
isChecked={isChecked}
onPress={setIsChecked}
size={14}
textComponent={<TermsText />}
onLongPress={() => {
if (!isIOS) {
router.push("/terms")
}
}}
/>
<Animated.View
className="mt-4 w-full flex-row items-center justify-center gap-2 px-8"
style={shakeStyle}
>
<TermsText />
</Animated.View>
)
})
}
const TermsText = () => {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="overflow-hidden rounded-full">
<ContextMenu.Trigger className="w-full overflow-hidden rounded-full">
<Text className="text-secondary-label text-sm">
I agree to the{" "}
<Link href="/terms" className="text-primary-label">
Terms of Service
</Link>{" "}
and Privacy Policy
</Link>
</Text>
</ContextMenu.Trigger>

View File

@ -1,9 +1,7 @@
import * as AppleAuthentication from "expo-apple-authentication"
import { useColorScheme } from "nativewind"
import { useContext } from "react"
import { Platform, TouchableOpacity, View } from "react-native"
import { LoginTermsCheckGuardContext as LoginTermsCheckGuardContext } from "@/src/contexts/LoginTermsContext"
import { AppleCuteFiIcon } from "@/src/icons/apple_cute_fi"
import { GithubCuteFiIcon } from "@/src/icons/github_cute_fi"
import { GoogleCuteFiIcon } from "@/src/icons/google_cute_fi"
@ -40,7 +38,6 @@ const provider: Record<
export function SocialLogin() {
const { data } = useAuthProviders()
const termsCheckGuard = useContext(LoginTermsCheckGuardContext)
const { colorScheme } = useColorScheme()
return (
@ -53,42 +50,40 @@ export function SocialLogin() {
<TouchableOpacity
key={key}
className="border-opaque-separator border-hairline rounded-full p-2"
onPress={() =>
termsCheckGuard?.(async () => {
if (!data?.[providerInfo.id]) return
onPress={async () => {
if (!data?.[providerInfo.id]) return
if (providerInfo.id === "apple") {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
if (providerInfo.id === "apple") {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
})
if (credential.identityToken) {
await signIn.social({
provider: "apple",
idToken: {
token: credential.identityToken,
},
})
if (credential.identityToken) {
await signIn.social({
provider: "apple",
idToken: {
token: credential.identityToken,
},
})
} else {
throw new Error("No identityToken.")
}
} catch (e) {
console.error(e)
// handle errors
} else {
throw new Error("No identityToken.")
}
return
} catch (e) {
console.error(e)
// handle errors
}
return
}
signIn.social({
provider: providerInfo.id as any,
callbackURL: "/",
})
signIn.social({
provider: providerInfo.id as any,
callbackURL: "/",
})
}
}}
disabled={!data?.[providerInfo.id]}
>
<providerInfo.icon

View File

@ -33,7 +33,7 @@ function isAuthCodeValid(authCode: string) {
export default function TwoFactorAuthScreen() {
const scrollY = useAnimatedValue(0)
const label = useColor("label")
const secondaryLabel = useColor("secondaryLabel")
const tertiaryLabel = useColor("tertiaryLabel")
const otpInputRef = useRef<OtpInputRef>(null)
@ -91,10 +91,12 @@ export default function TwoFactorAuthScreen() {
theme={{
containerStyle: { paddingHorizontal: 20 },
pinCodeTextStyle: { color: label },
placeholderTextStyle: { color: secondaryLabel },
placeholderTextStyle: { color: tertiaryLabel },
filledPinCodeContainerStyle: { borderColor: label },
pinCodeContainerStyle: {
borderColor: secondaryLabel,
borderColor: tertiaryLabel,
aspectRatio: 1,
width: 50,
},
focusedPinCodeContainerStyle: { borderColor: accentColor },
}}