feat: optimize login page
This commit is contained in:
parent
ee01f9a799
commit
e481486921
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { createContext } from "react"
|
||||
|
||||
export const LoginTermsCheckedContext = createContext(__DEV__)
|
||||
|
||||
export const LoginTermsCheckGuardContext = createContext<((callback: () => void) => void) | null>(
|
||||
() => {},
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue