template_0205
This commit is contained in:
540
RN_TEMPLATE/app/screens/ChangeEmailScreen.tsx
Normal file
540
RN_TEMPLATE/app/screens/ChangeEmailScreen.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
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,
|
||||
})
|
||||
Reference in New Issue
Block a user