11import * as Clipboard from 'expo-clipboard' ;
22import { useFocusEffect , useRouter } from 'expo-router' ;
33import React , { useCallback , useEffect , useState } from 'react' ;
4- import { ActivityIndicator , Alert , Image , Linking , Pressable , ScrollView , StyleSheet , Text , View } from 'react-native' ;
4+ import { ActivityIndicator , Alert , AppState , Image , KeyboardAvoidingView , Linking , Modal , Platform , Pressable , ScrollView , StyleSheet , Text , TextInput , View } from 'react-native' ;
55import { useSafeAreaInsets } from 'react-native-safe-area-context' ;
6- import { getCheckinStatus , getSubscription , getWallet , listInvitations , resolveAssetUrl , submitCheckin } from '@/api/client' ;
6+ import { getCheckinStatus , getSubscription , getWallet , listInvitations , resolveAssetUrl , sendBindEmailVerification , submitCheckin } from '@/api/client' ;
77import { checkAppUpdate , checkOta , currentOtaId , downloadAndApplyOta , installedAppVersion } from '@/updates/useOtaUpdate' ;
88import { obtainCaptchaToken } from '@/api/captcha' ;
99import type { InvitationItem , Subscription , Wallet } from '@/api/types' ;
@@ -103,7 +103,7 @@ function About({ t }: { t: Theme }) {
103103 { text : '取消' , style : 'cancel' } ,
104104 { text : '立即更新' , onPress : applyOtaNow } ,
105105 ] ) ;
106- } , [ otaBusy , appVersion , applyOtaNow ] ) ;
106+ } , [ otaBusy , applyOtaNow , verLine ] ) ;
107107 return (
108108 < Card style = { { padding : 16 } } >
109109 < Text style = { { fontSize : 12 , fontWeight : '700' , color : t . tx3 , letterSpacing : 0.5 , marginBottom : 13 } } > 关于</ Text >
@@ -145,6 +145,86 @@ function About({ t }: { t: Theme }) {
145145 ) ;
146146}
147147
148+ const EMAIL_RE = / ^ [ ^ \s @ ] + @ [ ^ \s @ ] + \. [ ^ \s @ ] + $ / ;
149+
150+ function BindEmailSheet ( {
151+ visible,
152+ email,
153+ busy,
154+ onChangeEmail,
155+ onClose,
156+ onSubmit,
157+ } : {
158+ visible : boolean ;
159+ email : string ;
160+ busy : boolean ;
161+ onChangeEmail : ( value : string ) => void ;
162+ onClose : ( ) => void ;
163+ onSubmit : ( ) => void ;
164+ } ) {
165+ const t = useTheme ( ) ;
166+ const insets = useSafeAreaInsets ( ) ;
167+ const [ focused , setFocused ] = useState ( false ) ;
168+
169+ useEffect ( ( ) => {
170+ if ( ! visible ) setFocused ( false ) ;
171+ } , [ visible ] ) ;
172+
173+ return (
174+ < Modal visible = { visible } transparent animationType = "slide" onRequestClose = { onClose } statusBarTranslucent >
175+ < KeyboardAvoidingView behavior = { Platform . OS === 'ios' ? 'padding' : undefined } style = { { flex : 1 , justifyContent : 'flex-end' } } >
176+ < Pressable style = { StyleSheet . absoluteFill } onPress = { onClose } />
177+ < View style = { { backgroundColor : t . bg2 , borderTopLeftRadius : 24 , borderTopRightRadius : 24 , borderTopWidth : StyleSheet . hairlineWidth , borderColor : t . line2 , paddingHorizontal : spacing . pad , paddingBottom : insets . bottom + 16 } } >
178+ < View style = { { width : 38 , height : 4 , borderRadius : 99 , backgroundColor : t . line2 , alignSelf : 'center' , marginTop : 10 , marginBottom : 14 } } />
179+ < Text style = { { fontSize : 18 , fontWeight : '800' , color : t . tx } } > 绑定邮箱</ Text >
180+ < Text style = { { fontSize : 13 , color : t . tx2 , fontWeight : '600' , marginTop : 18 , marginBottom : 8 } } > 邮箱地址</ Text >
181+ < TextInput
182+ value = { email }
183+ onChangeText = { onChangeEmail }
184+ placeholder = "name@example.com"
185+ placeholderTextColor = { t . tx3 }
186+ keyboardType = "email-address"
187+ autoCapitalize = "none"
188+ autoCorrect = { false }
189+ editable = { ! busy }
190+ returnKeyType = "send"
191+ onFocus = { ( ) => setFocused ( true ) }
192+ onBlur = { ( ) => setFocused ( false ) }
193+ onSubmitEditing = { onSubmit }
194+ style = { {
195+ backgroundColor : t . bg3 ,
196+ borderWidth : 1 ,
197+ borderColor : focused ? t . ac : t . line2 ,
198+ borderRadius : 14 ,
199+ paddingHorizontal : 14 ,
200+ paddingVertical : Platform . OS === 'ios' ? 13 : 9 ,
201+ color : t . tx ,
202+ fontSize : 15 ,
203+ } }
204+ />
205+ < View style = { { flexDirection : 'row' , gap : 10 , marginTop : 18 } } >
206+ < Pressable
207+ onPress = { onClose }
208+ disabled = { busy }
209+ style = { ( { pressed } ) => [ { flex : 1 , height : 46 , borderRadius : 15 , alignItems : 'center' , justifyContent : 'center' , backgroundColor : t . bg3 } , pressed && { opacity : 0.65 } , busy && { opacity : 0.5 } ] }
210+ >
211+ < Text style = { { color : t . tx2 , fontSize : 14.5 , fontWeight : '700' } } > 取消</ Text >
212+ </ Pressable >
213+ < Pressable
214+ onPress = { onSubmit }
215+ disabled = { busy || ! email . trim ( ) }
216+ style = { ( { pressed } ) => [ { flex : 1.45 , height : 46 , borderRadius : 15 , flexDirection : 'row' , alignItems : 'center' , justifyContent : 'center' , gap : 7 , backgroundColor : t . ac } , pressed && { transform : [ { scale : 0.98 } ] } , ( busy || ! email . trim ( ) ) && { opacity : 0.45 } ] }
217+ >
218+ { busy ? < ActivityIndicator size = "small" color = { t . acInk } /> : < Icons . mail size = { 16 } color = { t . acInk } sw = { 2.1 } /> }
219+ < Text style = { { color : t . acInk , fontSize : 14.5 , fontWeight : '800' } } > 发送验证邮件</ Text >
220+ </ Pressable >
221+ </ View >
222+ </ View >
223+ </ KeyboardAvoidingView >
224+ </ Modal >
225+ ) ;
226+ }
227+
148228const INVITE_REWARD = 5000 ;
149229
150230type PlanKey = 'basic' | 'pro' | 'ultra' ;
@@ -226,7 +306,7 @@ export default function ProfileScreen() {
226306 const t = useTheme ( ) ;
227307 const router = useRouter ( ) ;
228308 const insets = useSafeAreaInsets ( ) ;
229- const { user, baseUrl, logout, deleteAccount, appleSession } = useAuth ( ) ;
309+ const { user, baseUrl, logout, deleteAccount, appleSession, refreshUser } = useAuth ( ) ;
230310 const [ busy , setBusy ] = useState ( false ) ;
231311 const [ wallet , setWallet ] = useState < Wallet | null > ( null ) ;
232312 const [ subscription , setSubscription ] = useState < Subscription | null > ( null ) ;
@@ -238,11 +318,14 @@ export default function ProfileScreen() {
238318 const [ avatarBroken , setAvatarBroken ] = useState ( false ) ;
239319 const [ checkedIn , setCheckedIn ] = useState < boolean | null > ( null ) ;
240320 const [ checkingIn , setCheckingIn ] = useState ( false ) ;
321+ const [ bindEmailOpen , setBindEmailOpen ] = useState ( false ) ;
322+ const [ bindEmail , setBindEmail ] = useState ( '' ) ;
323+ const [ bindingEmail , setBindingEmail ] = useState ( false ) ;
241324
242325 useFocusEffect (
243326 useCallback ( ( ) => {
244327 let active = true ;
245- Promise . allSettled ( [ getWallet ( ) , getSubscription ( ) , listInvitations ( ) , getCheckinStatus ( ) ] ) . then ( ( [ w , s , inv , c ] ) => {
328+ Promise . allSettled ( [ refreshUser ( ) , getWallet ( ) , getSubscription ( ) , listInvitations ( ) , getCheckinStatus ( ) ] ) . then ( ( [ , w , s , inv , c ] ) => {
246329 if ( ! active ) return ;
247330 if ( w . status === 'fulfilled' ) setWallet ( w . value ) ;
248331 if ( s . status === 'fulfilled' ) setSubscription ( s . value ) ;
@@ -251,7 +334,7 @@ export default function ProfileScreen() {
251334 setLoadingWallet ( false ) ;
252335 } ) ;
253336 return ( ) => { active = false ; } ;
254- } , [ ] ) ,
337+ } , [ refreshUser ] ) ,
255338 ) ;
256339
257340 const showToast = useCallback ( ( msg : string ) => {
@@ -264,6 +347,33 @@ export default function ProfileScreen() {
264347 showToast ( msg ) ;
265348 } , [ showToast ] ) ;
266349
350+ const closeBindEmail = useCallback ( ( ) => {
351+ if ( bindingEmail ) return ;
352+ setBindEmailOpen ( false ) ;
353+ setBindEmail ( '' ) ;
354+ } , [ bindingEmail ] ) ;
355+
356+ const handleBindEmail = useCallback ( async ( ) => {
357+ if ( bindingEmail ) return ;
358+ const nextEmail = bindEmail . trim ( ) ;
359+ if ( ! EMAIL_RE . test ( nextEmail ) ) {
360+ Alert . alert ( '邮箱格式不正确' , '请输入有效的邮箱地址。' ) ;
361+ return ;
362+ }
363+
364+ setBindingEmail ( true ) ;
365+ try {
366+ await sendBindEmailVerification ( nextEmail ) ;
367+ setBindEmailOpen ( false ) ;
368+ setBindEmail ( '' ) ;
369+ Alert . alert ( '验证邮件已发送' , `请前往 ${ nextEmail } 查收验证邮件,完成验证后邮箱会显示在本页。` ) ;
370+ } catch ( e ) {
371+ Alert . alert ( '绑定邮箱失败' , e instanceof Error && e . message ? e . message : '请稍后重试' ) ;
372+ } finally {
373+ setBindingEmail ( false ) ;
374+ }
375+ } , [ bindEmail , bindingEmail ] ) ;
376+
267377 const doCheckin = useCallback ( async ( ) => {
268378 if ( checkingIn || checkedIn ) return ;
269379 setCheckingIn ( true ) ;
@@ -320,6 +430,13 @@ export default function ProfileScreen() {
320430 const email = user ?. email || '' ;
321431 const avatarUrl = resolveAssetUrl ( user ?. avatar_url || user ?. avatar ) ;
322432 useEffect ( ( ) => { setAvatarBroken ( false ) ; } , [ avatarUrl ] ) ;
433+ useEffect ( ( ) => {
434+ if ( email ) return ;
435+ const sub = AppState . addEventListener ( 'change' , ( state ) => {
436+ if ( state === 'active' ) refreshUser ( ) . catch ( ( ) => undefined ) ;
437+ } ) ;
438+ return ( ) => sub . remove ( ) ;
439+ } , [ email , refreshUser ] ) ;
323440 const credits = Math . floor ( ( wallet ?. balance ?? 0 ) / 1000 ) . toLocaleString ( 'zh-CN' ) ;
324441 const inviteLink = user ?. id ? `${ baseUrl } /?ic=${ user . id } ` : '' ;
325442 const planKey = normalizePlan ( subscription ?. plan ) ;
@@ -341,20 +458,30 @@ export default function ProfileScreen() {
341458 < View style = { { paddingHorizontal : spacing . pad , paddingTop : 12 , gap : spacing . gap } } >
342459 { /* identity */ }
343460 < Card style = { { padding : 16 , flexDirection : 'row' , alignItems : 'center' , gap : 14 } } >
344- < View style = { [ { width : 60 , height : 60 , borderRadius : 99 , alignItems : 'center' , justifyContent : 'center' , overflow : 'hidden' , backgroundColor : avatarUrl && ! avatarBroken ? t . ac : t . dark ? t . bg3 : '#fff' } , t . shCard ] } >
461+ < View style = { [ { width : 60 , height : 60 , borderRadius : 18 , alignItems : 'center' , justifyContent : 'center' , overflow : 'hidden' , backgroundColor : avatarUrl && ! avatarBroken ? t . ac : t . dark ? t . bg3 : '#fff' } , t . shCard ] } >
345462 { avatarUrl && ! avatarBroken
346- ? < Image source = { { uri : avatarUrl } } onError = { ( ) => setAvatarBroken ( true ) } style = { { width : 60 , height : 60 , borderRadius : 99 } } />
463+ ? < Image source = { { uri : avatarUrl } } onError = { ( ) => setAvatarBroken ( true ) } style = { { width : 60 , height : 60 , borderRadius : 18 } } />
347464 : < MonkeyLogo size = { 52 } /> }
348465 </ View >
349466 < View style = { { flex : 1 , minWidth : 0 } } >
350- < Text numberOfLines = { 1 } style = { { fontSize : 18 , fontWeight : '700' , color : t . tx } } > { name } </ Text >
351- { email ? < Text numberOfLines = { 1 } style = { { marginTop : 3 , fontSize : 13 , color : t . tx2 } } > { email } </ Text > : null }
352- { user ?. id ? (
353- < Pressable onPress = { ( ) => copy ( user . id ! , '用户 ID 已复制' ) } style = { ( { pressed } ) => [ { flexDirection : 'row' , alignItems : 'center' , gap : 5 , marginTop : 8 , alignSelf : 'flex-start' , paddingHorizontal : 9 , paddingVertical : 4 , borderRadius : 99 , backgroundColor : t . bg3 } , pressed && { opacity : 0.6 } ] } >
354- < Text style = { { fontSize : 11 , color : t . tx3 , fontFamily : 'monospace' } } > ID { user . id . slice ( 0 , 8 ) } </ Text >
355- < Icons . copy size = { 11 } color = { t . tx3 } sw = { 1.8 } />
356- </ Pressable >
357- ) : null }
467+ < Text numberOfLines = { 1 } style = { { fontSize : 18 , fontWeight : '800' , color : t . tx } } > { name } </ Text >
468+ < View style = { { marginTop : 7 , gap : 5 } } >
469+ < View style = { { flexDirection : 'row' , alignItems : 'center' , gap : 7 , minWidth : 0 } } >
470+ < Icons . mail size = { 13 } color = { t . tx3 } sw = { 1.8 } />
471+ < Text numberOfLines = { 1 } style = { { flex : 1 , minWidth : 0 , fontSize : 12.5 , color : t . tx3 , fontWeight : '500' } } > { email || '未绑定邮箱' } </ Text >
472+ { ! email ? (
473+ < Pressable onPress = { ( ) => setBindEmailOpen ( true ) } hitSlop = { 8 } style = { ( { pressed } ) => [ { paddingHorizontal : 4 , paddingVertical : 2 } , pressed && { opacity : 0.55 } ] } >
474+ < Text style = { { color : t . acTx , fontSize : 12.5 , fontWeight : '700' } } > 绑定</ Text >
475+ </ Pressable >
476+ ) : null }
477+ </ View >
478+ { user ?. id ? (
479+ < Pressable onPress = { ( ) => copy ( user . id ! , '用户 ID 已复制' ) } style = { ( { pressed } ) => [ { flexDirection : 'row' , alignItems : 'center' , gap : 7 , alignSelf : 'flex-start' } , pressed && { opacity : 0.55 } ] } >
480+ < Icons . copy size = { 13 } color = { t . tx3 } sw = { 1.8 } />
481+ < Text style = { { fontSize : 12.5 , color : t . tx3 , fontFamily : 'monospace' } } > { user . id . slice ( 0 , 8 ) } </ Text >
482+ </ Pressable >
483+ ) : null }
484+ </ View >
358485 </ View >
359486 </ Card >
360487
@@ -430,6 +557,7 @@ export default function ProfileScreen() {
430557 </ ScrollView >
431558
432559 < GlassTop title = "我的" collapsed = { collapsed } />
560+ < BindEmailSheet visible = { bindEmailOpen } email = { bindEmail } busy = { bindingEmail } onChangeEmail = { setBindEmail } onClose = { closeBindEmail } onSubmit = { handleBindEmail } />
433561 { toast ? < Toast text = { toast } bottom = { insets . bottom + 116 } /> : null }
434562 </ View >
435563 ) ;
0 commit comments