Files
RN_Template/RN_TEMPLATE/app/screens/ChangeEmailScreen.tsx
2026-02-05 13:16:05 +08:00

541 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
})