Files
RN_Template/RN_TEMPLATE/app/screens/ChangeEmailScreen.tsx

541 lines
16 KiB
TypeScript
Raw Normal View History

2026-02-05 13:16:05 +08:00
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,
})