feat: 添加用户登录和法律协议页面

- 新增登录页面,支持 Apple 登录和游客登录功能
- 添加用户协议和隐私政策页面,用户需同意后才能登录
- 更新首页逻辑,首次进入时自动跳转到登录页面
- 修改个人信息页面,移除单位选择功能,统一使用 kg 和 cm
- 更新依赖,添加 expo-apple-authentication 库以支持 Apple 登录
- 更新布局以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-12 19:21:07 +08:00
parent 8ffebfb297
commit c3d4630801
13 changed files with 326 additions and 103 deletions

225
app/auth/login.tsx Normal file
View File

@@ -0,0 +1,225 @@
import { Ionicons } from '@expo/vector-icons';
import * as AppleAuthentication from 'expo-apple-authentication';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export default function LoginScreen() {
const router = useRouter();
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const color = Colors[scheme];
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('请先阅读并同意', '勾选“我已阅读并同意用户协议与隐私政策”后才可继续登录');
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,
],
});
// TODO: 将 credential 发送到后端换取应用会话。这里先直接返回上一页。
router.back();
} catch (err: any) {
if (err?.code === 'ERR_CANCELED') return;
Alert.alert('登录失败', '请稍后再试');
} finally {
setLoading(false);
}
}, [appleAvailable, router]);
const onGuestLogin = useCallback(() => {
// TODO: 标记为游客身份,可在此写入本地状态/上报统计
router.back();
}, [router]);
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: color.background }]}>
<ThemedView style={styles.container}>
{/* 自定义头部,与其它页面风格一致 */}
<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 }]}>Digital Pilates</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}></ThemedText>
</View>
{/* Apple 登录 */}
{appleAvailable && (
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onAppleLogin)}
disabled={!hasAgreed || loading}
style={({ pressed }) => [
styles.appleButton,
{ backgroundColor: '#000000' },
disabledStyle,
pressed && { transform: [{ scale: 0.98 }] },
]}
>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</Pressable>
)}
{/* 游客登录(弱化样式) */}
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onGuestLogin)}
disabled={!hasAgreed || loading}
style={({ pressed }) => [
styles.guestButton,
{ borderColor: color.border, backgroundColor: color.surface },
disabledStyle,
pressed && { transform: [{ scale: 0.98 }] },
]}
>
<Ionicons name="person-circle-outline" size={22} color={Colors.light.neutral200} style={{ marginRight: 8 }} />
<Text style={[styles.guestText, { color: Colors.light.neutral200 }]}></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={() => router.push('/legal/privacy-policy')}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => router.push('/legal/user-agreement')}>
<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: '800',
letterSpacing: 0.5,
},
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 },
});