Files
digital-pilates/app/auth/login.tsx
richarjiang 3312250f2d feat: 添加教练功能和更新用户界面
- 新增教练页面,用户可以与教练进行互动和咨询
- 更新首页,切换到教练 tab 并传递名称参数
- 优化个人信息页面,添加注销帐号和退出登录功能
- 更新隐私政策和用户协议的链接,确保用户在使用前同意相关条款
- 修改今日训练页面标题为“开始训练”,提升用户体验
- 删除不再使用的进度条组件,简化代码结构
2025-08-15 21:38:19 +08:00

410 lines
14 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 { Ionicons } from '@expo/vector-icons';
import * as AppleAuthentication from 'expo-apple-authentication';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { login } from '@/store/userSlice';
export default function LoginScreen() {
const router = useRouter();
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画
const translateAnim = useRef(new Animated.Value(0)).current;
const rotateAnim = useRef(new Animated.Value(0)).current;
const pulseAnimA = useRef(new Animated.Value(0)).current;
const pulseAnimB = useRef(new Animated.Value(0)).current;
useEffect(() => {
const loopTranslate = Animated.loop(
Animated.sequence([
Animated.timing(translateAnim, { toValue: 1, duration: 6000, useNativeDriver: true }),
Animated.timing(translateAnim, { toValue: 0, duration: 6000, useNativeDriver: true }),
])
);
const loopRotate = Animated.loop(
Animated.sequence([
Animated.timing(rotateAnim, { toValue: 1, duration: 10000, useNativeDriver: true }),
Animated.timing(rotateAnim, { toValue: 0, duration: 10000, useNativeDriver: true }),
])
);
const loopPulseA = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimA, { toValue: 1, duration: 3500, useNativeDriver: true }),
Animated.timing(pulseAnimA, { toValue: 0, duration: 3500, useNativeDriver: true }),
])
);
const loopPulseB = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimB, { toValue: 1, duration: 4200, useNativeDriver: true }),
Animated.timing(pulseAnimB, { toValue: 0, duration: 4200, useNativeDriver: true }),
])
);
loopTranslate.start();
loopRotate.start();
loopPulseA.start();
loopPulseB.start();
return () => {
loopTranslate.stop();
loopRotate.stop();
loopPulseA.stop();
loopPulseB.stop();
};
}, [pulseAnimA, pulseAnimB, rotateAnim, translateAnim]);
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(() => setAppleAvailable(false));
}, []);
const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) {
Alert.alert(
'请先阅读并同意',
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
[
{ text: '取消', style: 'cancel' },
{
text: '同意并继续',
onPress: () => {
setHasAgreed(true);
setTimeout(() => action(), 0);
},
},
],
{ cancelable: true }
);
return;
}
action();
}, [hasAgreed]);
const onAppleLogin = useCallback(async () => {
if (!appleAvailable) return;
try {
setLoading(true);
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const identityToken = (credential as any)?.identityToken;
if (!identityToken || typeof identityToken !== 'string') {
throw new Error('未获取到 Apple 身份令牌');
}
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
// 登录成功后处理重定向
const to = searchParams?.redirectTo as string | undefined;
const paramsJson = searchParams?.redirectParams as string | undefined;
let parsedParams: Record<string, any> | undefined;
if (paramsJson) {
try { parsedParams = JSON.parse(paramsJson); } catch { }
}
if (to) {
router.replace({ pathname: to, params: parsedParams } as any);
} else {
router.back();
}
} catch (err: any) {
if (err?.code === 'ERR_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试';
Alert.alert('登录失败', message);
} finally {
setLoading(false);
}
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
const onGuestLogin = useCallback(() => {
// 游客继续:若有 redirect 则前往,无则返回
const to = searchParams?.redirectTo as string | undefined;
const paramsJson = searchParams?.redirectParams as string | undefined;
let parsedParams: Record<string, any> | undefined;
if (paramsJson) {
try { parsedParams = JSON.parse(paramsJson); } catch { }
}
if (to) {
router.replace({ pathname: to, params: parsedParams } as any);
} else {
router.back();
}
}, [router, searchParams?.redirectParams, searchParams?.redirectTo]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
return (
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: pageBackground }]}>
<ThemedView style={[styles.container, { backgroundColor: pageBackground }]}>
{/* 动态背景层(置于内容之下) */}
<View pointerEvents="none" style={styles.bgWrap}>
{/* 基础全屏渐变:保证覆盖全屏 */}
<AnimatedLinear
colors={
scheme === 'light'
? [color.pageBackgroundEmphasis, color.heroSurfaceTint, color.surface]
: [color.background, '#0F1112', color.surface]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.bgGradientFull]}
/>
{/* 次级大面积渐变:对角线方向形成层次 */}
<AnimatedLinear
colors={
scheme === 'light'
? ['rgba(164,138,237,0.12)', 'rgba(187,242,70,0.16)', 'transparent']
: ['rgba(164,138,237,0.16)', 'rgba(187,242,70,0.12)', 'transparent']
}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
style={[
styles.bgGradientCover,
{
transform: [
{
rotate: rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['-4deg', '6deg'] }),
},
],
opacity: scheme === 'light' ? 0.9 : 0.65,
},
]}
/>
{/* 动感色块 A主色呼吸置于左下 */}
<Animated.View
style={[
styles.accentBlobLarge,
{
backgroundColor: color.ornamentPrimary,
transform: [
{ translateX: -80 },
{ translateY: 320 },
{ scale: pulseAnimA.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] }) },
],
opacity: scheme === 'light' ? 0.55 : 0.4,
},
]}
/>
{/* 动感色块 B辅色漂移置于右上 */}
<Animated.View
style={[
styles.accentBlobMedium,
{
backgroundColor: color.ornamentAccent,
transform: [
{ translateX: 240 },
{ translateY: -40 },
{ scale: pulseAnimB.interpolate({ inputRange: [0, 1], outputRange: [1, 1.07] }) },
],
opacity: scheme === 'light' ? 0.5 : 0.38,
},
]}
/>
</View>
{/* 自定义头部,与其它页面风格一致 */}
<View style={styles.header}>
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: color.text }]}></Text>
<View style={{ width: 32 }} />
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}></ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}></ThemedText>
</View>
{/* Apple 登录 */}
{appleAvailable && (
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onAppleLogin)}
disabled={loading}
style={({ pressed }) => [
styles.appleButton,
{ backgroundColor: '#000000' },
loading && { opacity: 0.7 },
pressed && { transform: [{ scale: 0.98 }] },
]}
>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</Pressable>
)}
{/* 协议勾选 */}
<View style={styles.agreementRow}>
<Pressable onPress={() => setHasAgreed((v) => !v)} style={styles.checkboxWrap} accessibilityRole="checkbox" accessibilityState={{ checked: hasAgreed }}>
<View
style={[styles.checkbox, {
backgroundColor: hasAgreed ? color.primary : 'transparent',
borderColor: hasAgreed ? color.primary : color.border,
}]}
>
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
</View>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
</View>
{/* 占位底部间距 */}
<View style={{ height: 40 }} />
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
container: { flex: 1 },
content: {
flexGrow: 1,
paddingHorizontal: 24,
justifyContent: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 8,
},
backButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
headerTitle: { fontSize: 18, fontWeight: '700' },
headerWrap: {
marginBottom: 36,
},
title: {
fontSize: 32,
fontWeight: '500',
letterSpacing: 0.5,
lineHeight: 38,
},
subtitle: {
marginTop: 8,
fontSize: 14,
fontWeight: '500',
},
appleButton: {
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 2,
},
appleText: {
fontSize: 16,
color: '#FFFFFF',
fontWeight: '600',
},
guestButton: {
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
borderWidth: 1,
marginTop: 6,
},
guestText: {
fontSize: 15,
fontWeight: '500',
},
agreementRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
marginTop: 24,
},
checkboxWrap: { marginRight: 8 },
checkbox: {
width: 18,
height: 18,
borderRadius: 5,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
agreementText: { fontSize: 12 },
link: { fontSize: 12, fontWeight: '600' },
footerHint: { marginTop: 24 },
hintText: { fontSize: 12 },
// 背景样式
bgWrap: {
...StyleSheet.absoluteFillObject,
zIndex: 0,
},
bgGradientFull: {
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
},
bgGradientCover: {
position: 'absolute',
left: '-10%',
top: '-15%',
width: '130%',
height: '70%',
borderBottomLeftRadius: 36,
borderBottomRightRadius: 36,
},
accentBlob: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
accentBlobLarge: {
position: 'absolute',
width: 260,
height: 260,
borderRadius: 130,
},
accentBlobMedium: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
});