541 lines
16 KiB
TypeScript
541 lines
16 KiB
TypeScript
import { FC, useCallback, useEffect, useRef, useState } from "react"
|
||
import { TextStyle, View, ViewStyle } from "react-native"
|
||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
|
||
|
||
import { Button } from "@/components/Button"
|
||
import { Dialog } from "@/components/Dialog"
|
||
import { Screen } from "@/components/Screen"
|
||
import { Text } from "@/components/Text"
|
||
import { TextField } from "@/components/TextField"
|
||
import { useAuth } from "@/context/AuthContext"
|
||
import { translate } from "@/i18n/translate"
|
||
import { AppStackScreenProps } from "@/navigators/navigationTypes"
|
||
import { useAppTheme } from "@/theme/context"
|
||
import { $styles } from "@/theme/styles"
|
||
import type { ThemedStyle } from "@/theme/types"
|
||
import { s } from "@/utils/responsive"
|
||
import { useHeader } from "@/utils/useHeader"
|
||
|
||
const COUNTDOWN_SECONDS = 60
|
||
|
||
export const ChangeEmailScreen: FC<AppStackScreenProps<"ChangeEmail">> =
|
||
function ChangeEmailScreen({ navigation }) {
|
||
const {
|
||
user,
|
||
emailChangeStep,
|
||
sendEmailCode,
|
||
verifyCurrentEmail,
|
||
bindNewEmail,
|
||
resetEmailChangeStep,
|
||
isLoading,
|
||
error,
|
||
clearError,
|
||
} = useAuth()
|
||
const { themed } = useAppTheme()
|
||
|
||
// Step 1: Verify current email
|
||
const [currentEmailCode, setCurrentEmailCode] = useState("")
|
||
|
||
// Step 2: Bind new email
|
||
const [newEmail, setNewEmail] = useState("")
|
||
const [newEmailCode, setNewEmailCode] = useState("")
|
||
const [newEmailCodeSent, setNewEmailCodeSent] = useState(false)
|
||
|
||
const [localError, setLocalError] = useState("")
|
||
const [successDialogVisible, setSuccessDialogVisible] = useState(false)
|
||
|
||
// Countdown timers
|
||
const [currentEmailCountdown, setCurrentEmailCountdown] = useState(0)
|
||
const [newEmailCountdown, setNewEmailCountdown] = useState(0)
|
||
const currentEmailTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
const newEmailTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
|
||
// Start countdown for current email
|
||
const startCurrentEmailCountdown = useCallback(() => {
|
||
setCurrentEmailCountdown(COUNTDOWN_SECONDS)
|
||
if (currentEmailTimerRef.current) {
|
||
clearInterval(currentEmailTimerRef.current)
|
||
}
|
||
currentEmailTimerRef.current = setInterval(() => {
|
||
setCurrentEmailCountdown((prev) => {
|
||
if (prev <= 1) {
|
||
if (currentEmailTimerRef.current) {
|
||
clearInterval(currentEmailTimerRef.current)
|
||
currentEmailTimerRef.current = null
|
||
}
|
||
return 0
|
||
}
|
||
return prev - 1
|
||
})
|
||
}, 1000)
|
||
}, [])
|
||
|
||
// Start countdown for new email
|
||
const startNewEmailCountdown = useCallback(() => {
|
||
setNewEmailCountdown(COUNTDOWN_SECONDS)
|
||
if (newEmailTimerRef.current) {
|
||
clearInterval(newEmailTimerRef.current)
|
||
}
|
||
newEmailTimerRef.current = setInterval(() => {
|
||
setNewEmailCountdown((prev) => {
|
||
if (prev <= 1) {
|
||
if (newEmailTimerRef.current) {
|
||
clearInterval(newEmailTimerRef.current)
|
||
newEmailTimerRef.current = null
|
||
}
|
||
return 0
|
||
}
|
||
return prev - 1
|
||
})
|
||
}, 1000)
|
||
}, [])
|
||
|
||
// Reset state when unmounting
|
||
useEffect(() => {
|
||
return () => {
|
||
resetEmailChangeStep()
|
||
if (currentEmailTimerRef.current) {
|
||
clearInterval(currentEmailTimerRef.current)
|
||
}
|
||
if (newEmailTimerRef.current) {
|
||
clearInterval(newEmailTimerRef.current)
|
||
}
|
||
}
|
||
}, [resetEmailChangeStep])
|
||
|
||
// Step 1: Send verification code to current email
|
||
const handleSendCurrentEmailCode = useCallback(async () => {
|
||
clearError()
|
||
setLocalError("")
|
||
if (user?.email) {
|
||
const success = await sendEmailCode(user.email)
|
||
if (success) {
|
||
startCurrentEmailCountdown()
|
||
}
|
||
}
|
||
}, [sendEmailCode, clearError, user?.email, startCurrentEmailCountdown])
|
||
|
||
// Step 1: Verify current email code
|
||
const handleVerifyCurrentEmail = useCallback(async () => {
|
||
clearError()
|
||
setLocalError("")
|
||
|
||
if (!currentEmailCode) {
|
||
setLocalError(translate("changeEmailScreen:codeRequired"))
|
||
return
|
||
}
|
||
|
||
if (currentEmailCode.length !== 6) {
|
||
setLocalError(translate("changeEmailScreen:codeInvalid"))
|
||
return
|
||
}
|
||
|
||
await verifyCurrentEmail(currentEmailCode)
|
||
}, [currentEmailCode, verifyCurrentEmail, clearError])
|
||
|
||
// Step 2: Send verification code to new email
|
||
const handleSendNewEmailCode = useCallback(async () => {
|
||
clearError()
|
||
setLocalError("")
|
||
|
||
if (!newEmail) {
|
||
setLocalError(translate("changeEmailScreen:newEmailRequired"))
|
||
return
|
||
}
|
||
|
||
// Basic email validation
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||
setLocalError(translate("changeEmailScreen:emailInvalid"))
|
||
return
|
||
}
|
||
|
||
if (newEmail.toLowerCase() === user?.email?.toLowerCase()) {
|
||
setLocalError(translate("changeEmailScreen:sameEmail"))
|
||
return
|
||
}
|
||
|
||
const success = await sendEmailCode(newEmail)
|
||
if (success) {
|
||
setNewEmailCodeSent(true)
|
||
startNewEmailCountdown()
|
||
}
|
||
}, [newEmail, user?.email, sendEmailCode, clearError, startNewEmailCountdown])
|
||
|
||
// Step 2: Bind new email
|
||
const handleBindNewEmail = useCallback(async () => {
|
||
clearError()
|
||
setLocalError("")
|
||
|
||
if (!newEmailCode) {
|
||
setLocalError(translate("changeEmailScreen:codeRequired"))
|
||
return
|
||
}
|
||
|
||
if (newEmailCode.length !== 6) {
|
||
setLocalError(translate("changeEmailScreen:codeInvalid"))
|
||
return
|
||
}
|
||
|
||
const success = await bindNewEmail(newEmail, newEmailCode)
|
||
if (success) {
|
||
setSuccessDialogVisible(true)
|
||
}
|
||
}, [newEmail, newEmailCode, bindNewEmail, clearError])
|
||
|
||
const displayError = localError || error
|
||
|
||
// Render Step 1 content (without buttons)
|
||
const renderStep1Content = () => (
|
||
<View style={themed($content)}>
|
||
<Text tx="changeEmailScreen:step1Title" preset="subheading" style={themed($stepTitle)} />
|
||
<Text tx="changeEmailScreen:step1Description" style={themed($description)} />
|
||
|
||
<View style={themed($currentEmailContainer)}>
|
||
<Text size="xs" style={themed($currentEmailLabel)}>
|
||
{translate("changeEmailScreen:currentEmail")}
|
||
</Text>
|
||
<Text preset="bold" style={themed($currentEmailText)}>
|
||
{user?.email || ""}
|
||
</Text>
|
||
</View>
|
||
|
||
{emailChangeStep !== "idle" && (
|
||
<>
|
||
<TextField
|
||
labelTx="changeEmailScreen:verificationCode"
|
||
value={currentEmailCode}
|
||
onChangeText={setCurrentEmailCode}
|
||
containerStyle={themed($inputContainer)}
|
||
keyboardType="number-pad"
|
||
maxLength={6}
|
||
placeholder="000000"
|
||
/>
|
||
|
||
{displayError ? (
|
||
<Text size="sm" style={themed($errorText)}>
|
||
{displayError}
|
||
</Text>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</View>
|
||
)
|
||
|
||
// Render Step 2 content (without buttons)
|
||
const renderStep2Content = () => (
|
||
<View style={themed($content)}>
|
||
<Text tx="changeEmailScreen:step2Title" preset="subheading" style={themed($stepTitle)} />
|
||
<Text tx="changeEmailScreen:step2Description" style={themed($description)} />
|
||
|
||
<TextField
|
||
labelTx="changeEmailScreen:newEmail"
|
||
value={newEmail}
|
||
onChangeText={setNewEmail}
|
||
containerStyle={themed($inputContainer)}
|
||
keyboardType="email-address"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
editable={!newEmailCodeSent}
|
||
/>
|
||
|
||
{!newEmailCodeSent ? (
|
||
displayError ? (
|
||
<Text size="sm" style={themed($errorText)}>
|
||
{displayError}
|
||
</Text>
|
||
) : null
|
||
) : (
|
||
<>
|
||
<TextField
|
||
labelTx="changeEmailScreen:verificationCode"
|
||
value={newEmailCode}
|
||
onChangeText={setNewEmailCode}
|
||
containerStyle={themed($inputContainer)}
|
||
keyboardType="number-pad"
|
||
maxLength={6}
|
||
placeholder="000000"
|
||
/>
|
||
|
||
{displayError ? (
|
||
<Text size="sm" style={themed($errorText)}>
|
||
{displayError}
|
||
</Text>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</View>
|
||
)
|
||
|
||
// Render bottom buttons for Step 1
|
||
const renderStep1Buttons = () => {
|
||
if (emailChangeStep === "idle") {
|
||
return (
|
||
<Button
|
||
tx="changeEmailScreen:sendCode"
|
||
preset="reversed"
|
||
onPress={handleSendCurrentEmailCode}
|
||
disabled={isLoading}
|
||
loading={isLoading}
|
||
/>
|
||
)
|
||
}
|
||
return (
|
||
<>
|
||
<Button
|
||
tx="changeEmailScreen:verify"
|
||
preset="reversed"
|
||
onPress={handleVerifyCurrentEmail}
|
||
disabled={isLoading}
|
||
loading={isLoading}
|
||
/>
|
||
<Button
|
||
text={
|
||
currentEmailCountdown > 0
|
||
? `${translate("changeEmailScreen:resendCode")} (${currentEmailCountdown}s)`
|
||
: undefined
|
||
}
|
||
tx={currentEmailCountdown > 0 ? undefined : "changeEmailScreen:resendCode"}
|
||
preset="default"
|
||
style={themed($resendButton)}
|
||
onPress={handleSendCurrentEmailCode}
|
||
disabled={isLoading || currentEmailCountdown > 0}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// Render bottom buttons for Step 2
|
||
const renderStep2Buttons = () => {
|
||
if (!newEmailCodeSent) {
|
||
return (
|
||
<Button
|
||
tx="changeEmailScreen:sendCodeToNewEmail"
|
||
preset="reversed"
|
||
onPress={handleSendNewEmailCode}
|
||
disabled={isLoading || !newEmail}
|
||
loading={isLoading}
|
||
/>
|
||
)
|
||
}
|
||
return (
|
||
<>
|
||
<Button
|
||
tx="changeEmailScreen:confirmNewEmail"
|
||
preset="reversed"
|
||
onPress={handleBindNewEmail}
|
||
disabled={isLoading}
|
||
loading={isLoading}
|
||
/>
|
||
<Button
|
||
text={
|
||
newEmailCountdown > 0
|
||
? `${translate("changeEmailScreen:resendCode")} (${newEmailCountdown}s)`
|
||
: undefined
|
||
}
|
||
tx={newEmailCountdown > 0 ? undefined : "changeEmailScreen:resendCode"}
|
||
preset="default"
|
||
style={themed($resendButton)}
|
||
onPress={handleSendNewEmailCode}
|
||
disabled={isLoading || newEmailCountdown > 0}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
useHeader(
|
||
{
|
||
title: translate("changeEmailScreen:title"),
|
||
leftIcon: "back",
|
||
onLeftPress: () => navigation.goBack(),
|
||
},
|
||
[],
|
||
)
|
||
|
||
return (
|
||
<Screen preset="fixed" safeAreaEdges={["bottom"]} contentContainerStyle={$styles.flex1}>
|
||
<KeyboardAwareScrollView
|
||
contentContainerStyle={[$styles.container, themed($container)]}
|
||
keyboardShouldPersistTaps="handled"
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* Step indicator */}
|
||
<View style={themed($stepIndicator)}>
|
||
{/* Step 1 */}
|
||
<View style={themed($stepItem)}>
|
||
<View
|
||
style={[themed($stepDot), emailChangeStep !== "bind-new" && themed($stepDotActive)]}
|
||
>
|
||
<Text
|
||
style={[
|
||
themed($stepNumber),
|
||
emailChangeStep !== "bind-new" && themed($stepNumberActive),
|
||
]}
|
||
>
|
||
1
|
||
</Text>
|
||
</View>
|
||
<Text
|
||
size="xxs"
|
||
style={[
|
||
themed($stepLabel),
|
||
emailChangeStep !== "bind-new" && themed($stepLabelActive),
|
||
]}
|
||
>
|
||
{translate("changeEmailScreen:step1Label")}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Line */}
|
||
<View style={themed($stepLine)} />
|
||
|
||
{/* Step 2 */}
|
||
<View style={themed($stepItem)}>
|
||
<View
|
||
style={[themed($stepDot), emailChangeStep === "bind-new" && themed($stepDotActive)]}
|
||
>
|
||
<Text
|
||
style={[
|
||
themed($stepNumber),
|
||
emailChangeStep === "bind-new" && themed($stepNumberActive),
|
||
]}
|
||
>
|
||
2
|
||
</Text>
|
||
</View>
|
||
<Text
|
||
size="xxs"
|
||
style={[
|
||
themed($stepLabel),
|
||
emailChangeStep === "bind-new" && themed($stepLabelActive),
|
||
]}
|
||
>
|
||
{translate("changeEmailScreen:step2Label")}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{emailChangeStep === "bind-new" ? renderStep2Content() : renderStep1Content()}
|
||
</KeyboardAwareScrollView>
|
||
|
||
{/* Fixed Bottom Buttons */}
|
||
<View style={themed($bottomContainer)}>
|
||
{emailChangeStep === "bind-new" ? renderStep2Buttons() : renderStep1Buttons()}
|
||
</View>
|
||
|
||
{/* Success Dialog */}
|
||
<Dialog
|
||
visible={successDialogVisible}
|
||
onClose={() => {
|
||
setSuccessDialogVisible(false)
|
||
navigation.goBack()
|
||
}}
|
||
titleTx="changeEmailScreen:success"
|
||
messageTx="changeEmailScreen:successMessage"
|
||
onConfirm={() => {
|
||
setSuccessDialogVisible(false)
|
||
navigation.goBack()
|
||
}}
|
||
/>
|
||
</Screen>
|
||
)
|
||
}
|
||
|
||
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
paddingTop: spacing.lg, // 覆盖 $styles.container 的 56px,使用 useHeader 时只需 24px
|
||
})
|
||
|
||
const $content: ThemedStyle<ViewStyle> = () => ({})
|
||
|
||
const $stepIndicator: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
flexDirection: "row",
|
||
alignItems: "flex-start",
|
||
justifyContent: "center",
|
||
marginBottom: spacing.lg,
|
||
})
|
||
|
||
const $stepItem: ThemedStyle<ViewStyle> = () => ({
|
||
alignItems: "center",
|
||
width: s(80),
|
||
})
|
||
|
||
const $stepDot: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
width: s(32),
|
||
height: s(32),
|
||
borderRadius: s(16),
|
||
backgroundColor: colors.palette.neutral300,
|
||
justifyContent: "center",
|
||
alignItems: "center",
|
||
})
|
||
|
||
const $stepDotActive: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
backgroundColor: colors.tint,
|
||
})
|
||
|
||
const $stepNumber: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||
color: colors.textDim,
|
||
fontWeight: "bold",
|
||
})
|
||
|
||
const $stepNumberActive: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||
color: colors.palette.neutral100,
|
||
})
|
||
|
||
const $stepLabel: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
color: colors.textDim,
|
||
marginTop: spacing.xs,
|
||
textAlign: "center",
|
||
})
|
||
|
||
const $stepLabelActive: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||
color: colors.tint,
|
||
fontWeight: "600",
|
||
})
|
||
|
||
const $stepLine: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
width: s(40),
|
||
height: 2,
|
||
backgroundColor: colors.palette.neutral300,
|
||
marginTop: s(15),
|
||
})
|
||
|
||
const $stepTitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||
marginBottom: spacing.xs,
|
||
})
|
||
|
||
const $description: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
color: colors.textDim,
|
||
marginBottom: spacing.lg,
|
||
})
|
||
|
||
const $currentEmailContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||
backgroundColor: colors.palette.neutral200,
|
||
borderRadius: s(8),
|
||
padding: spacing.md,
|
||
marginBottom: spacing.lg,
|
||
})
|
||
|
||
const $currentEmailLabel: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
color: colors.textDim,
|
||
marginBottom: spacing.xxs,
|
||
})
|
||
|
||
const $currentEmailText: ThemedStyle<TextStyle> = () => ({})
|
||
|
||
const $inputContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
marginBottom: spacing.md,
|
||
})
|
||
|
||
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
color: colors.error,
|
||
marginBottom: spacing.md,
|
||
})
|
||
|
||
const $bottomContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
paddingHorizontal: spacing.lg,
|
||
paddingBottom: spacing.md,
|
||
})
|
||
|
||
const $resendButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
marginTop: spacing.sm,
|
||
})
|