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

View File

@@ -187,7 +187,13 @@ export default function ExploreScreen() {
) : (
<Text style={styles.stepsValue}>/2000</Text>
)}
<ProgressBar progress={Math.min(1, Math.max(0, (stepCount ?? 0) / 2000))} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
<ProgressBar
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / 2000))}
height={18}
trackColor="#FFEBCB"
fillColor="#FFC365"
showLabel={false}
/>
</View>
</View>
</View>

View File

@@ -5,7 +5,7 @@ import { ThemedView } from '@/components/ThemedView';
import { WorkoutCard } from '@/components/WorkoutCard';
import { getChineseGreeting } from '@/utils/date';
import { useRouter } from 'expo-router';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
const workoutData = [
@@ -24,6 +24,15 @@ const workoutData = [
export default function HomeScreen() {
const router = useRouter();
const hasOpenedLoginRef = useRef(false);
useEffect(() => {
// 仅在本次会话首次进入首页时打开登录页,可返回关闭
if (!hasOpenedLoginRef.current) {
hasOpenedLoginRef.current = true;
router.push('/auth/login');
}
}, [router]);
return (
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.container}>

View File

@@ -31,8 +31,6 @@ export default function PersonalScreen() {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
type WeightUnit = 'kg' | 'lb';
type HeightUnit = 'cm' | 'ft';
type UserProfile = {
fullName?: string;
email?: string;
@@ -40,7 +38,7 @@ export default function PersonalScreen() {
age?: string;
weightKg?: number;
heightCm?: number;
unitPref?: { weight: WeightUnit; height: HeightUnit };
avatarUri?: string | null;
};
const [profile, setProfile] = useState<UserProfile>({});
@@ -78,16 +76,12 @@ export default function PersonalScreen() {
const formatHeight = () => {
if (profile.heightCm == null) return '--';
const unit = profile.unitPref?.height ?? 'cm';
if (unit === 'cm') return `${Math.round(profile.heightCm)}cm`;
return `${round(profile.heightCm / 30.48, 1)}ft`;
return `${Math.round(profile.heightCm)}cm`;
};
const formatWeight = () => {
if (profile.weightKg == null) return '--';
const unit = profile.unitPref?.weight ?? 'kg';
if (unit === 'kg') return `${round(profile.weightKg, 1)}kg`;
return `${round(profile.weightKg * 2.2046226218, 1)}lb`;
return `${round(profile.weightKg, 1)}kg`;
};
const formatAge = () => (profile.age ? `${profile.age}` : '--');

View File

@@ -24,6 +24,9 @@ export default function RootLayout() {
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />

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 },
});

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ScrollView, Text } from 'react-native';
export default function PrivacyPolicy() {
return (
<ScrollView style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 20, fontWeight: '700', marginBottom: 12 }}></Text>
<Text style={{ lineHeight: 22, color: '#4A4A4A' }}>
</Text>
</ScrollView>
);
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ScrollView, Text } from 'react-native';
export default function UserAgreement() {
return (
<ScrollView style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 20, fontWeight: '700', marginBottom: 12 }}></Text>
<Text style={{ lineHeight: 22, color: '#4A4A4A' }}>
</Text>
</ScrollView>
);
}

View File

@@ -4,7 +4,7 @@ import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Alert,
Image,
@@ -32,10 +32,6 @@ interface UserProfile {
weightKg?: number; // kg
heightCm?: number; // cm
avatarUri?: string | null;
unitPref?: {
weight: WeightUnit;
height: HeightUnit;
};
}
const STORAGE_KEY = '@user_profile';
@@ -52,26 +48,12 @@ export default function EditProfileScreen() {
weightKg: undefined,
heightCm: undefined,
avatarUri: null,
unitPref: { weight: 'kg', height: 'cm' },
});
const [weightInput, setWeightInput] = useState<string>('');
const [heightInput, setHeightInput] = useState<string>('');
// 将存储的公制值转换为当前选择单位显示
const displayedWeight = useMemo(() => {
if (profile.weightKg == null || isNaN(profile.weightKg)) return '';
return profile.unitPref?.weight === 'kg'
? String(round(profile.weightKg, 1))
: String(round(kgToLb(profile.weightKg), 1));
}, [profile.weightKg, profile.unitPref?.weight]);
const displayedHeight = useMemo(() => {
if (profile.heightCm == null || isNaN(profile.heightCm)) return '';
return profile.unitPref?.height === 'cm'
? String(Math.round(profile.heightCm))
: String(round(cmToFt(profile.heightCm), 1));
}, [profile.heightCm, profile.unitPref?.height]);
// 输入框字符串
useEffect(() => {
(async () => {
@@ -89,7 +71,6 @@ export default function EditProfileScreen() {
weightKg: undefined,
heightCm: undefined,
avatarUri: null,
unitPref: { weight: 'kg', height: 'cm' },
};
if (fromOnboarding) {
@@ -109,8 +90,8 @@ export default function EditProfileScreen() {
} catch { }
}
setProfile(next);
setWeightInput(next.weightKg != null ? (next.unitPref?.weight === 'kg' ? String(round(next.weightKg, 1)) : String(round(kgToLb(next.weightKg), 1))) : '');
setHeightInput(next.heightCm != null ? (next.unitPref?.height === 'cm' ? String(Math.round(next.heightCm)) : String(round(cmToFt(next.heightCm), 1))) : '');
setWeightInput(next.weightKg != null ? String(round(next.weightKg, 1)) : '');
setHeightInput(next.heightCm != null ? String(Math.round(next.heightCm)) : '');
} catch (e) {
console.warn('读取资料失败', e);
}
@@ -124,17 +105,17 @@ export default function EditProfileScreen() {
try {
const next: UserProfile = { ...profile };
// 将当前输入反向同步为公制
// 将当前输入同步为公制(固定 kg/cm
const w = parseFloat(weightInput);
if (!isNaN(w)) {
next.weightKg = profile.unitPref?.weight === 'kg' ? w : lbToKg(w);
next.weightKg = w;
} else {
next.weightKg = undefined;
}
const h = parseFloat(heightInput);
if (!isNaN(h)) {
next.heightCm = profile.unitPref?.height === 'cm' ? h : ftToCm(h);
next.heightCm = h;
} else {
next.heightCm = undefined;
}
@@ -147,27 +128,7 @@ export default function EditProfileScreen() {
}
};
const toggleWeightUnit = (unit: WeightUnit) => {
if (unit === profile.unitPref?.weight) return;
const current = parseFloat(weightInput);
let nextValueStr = weightInput;
if (!isNaN(current)) {
nextValueStr = unit === 'kg' ? String(round(lbToKg(current), 1)) : String(round(kgToLb(current), 1));
}
setProfile((p) => ({ ...p, unitPref: { ...(p.unitPref || { weight: 'kg', height: 'cm' }), weight: unit } }));
setWeightInput(nextValueStr);
};
const toggleHeightUnit = (unit: HeightUnit) => {
if (unit === profile.unitPref?.height) return;
const current = parseFloat(heightInput);
let nextValueStr = heightInput;
if (!isNaN(current)) {
nextValueStr = unit === 'cm' ? String(Math.round(ftToCm(current))) : String(round(cmToFt(current), 1));
}
setProfile((p) => ({ ...p, unitPref: { ...(p.unitPref || { weight: 'kg', height: 'cm' }), height: unit } }));
setHeightInput(nextValueStr);
};
// 不再需要单位切换
const pickAvatarFromLibrary = async () => {
try {
@@ -235,44 +196,36 @@ export default function EditProfileScreen() {
{!!profile.fullName && <Text style={{ color: '#C4C4C4' }}></Text>}
</View>
{/* 体重 */}
{/* 体重kg */}
<FieldLabel text="体重" />
<View style={styles.row}>
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder={profile.unitPref?.weight === 'kg' ? '输入体重' : '输入体重'}
placeholder={'输入体重'}
placeholderTextColor={placeholderColor}
keyboardType="numeric"
value={weightInput}
onChangeText={setWeightInput}
/>
<Text style={{ color: '#5E6468', marginLeft: 6 }}>kg</Text>
</View>
<SegmentedTwo
options={[{ key: 'lb', label: 'LBS' }, { key: 'kg', label: 'KG' }]}
activeKey={profile.unitPref?.weight || 'kg'}
onChange={(key) => toggleWeightUnit(key as WeightUnit)}
/>
</View>
{/* 身高 */}
{/* 身高cm */}
<FieldLabel text="身高" />
<View style={styles.row}>
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder={profile.unitPref?.height === 'cm' ? '输入身高' : '输入身高'}
placeholder={'输入身高'}
placeholderTextColor={placeholderColor}
keyboardType="numeric"
value={heightInput}
onChangeText={setHeightInput}
/>
<Text style={{ color: '#5E6468', marginLeft: 6 }}>cm</Text>
</View>
<SegmentedTwo
options={[{ key: 'ft', label: 'FEET' }, { key: 'cm', label: 'CM' }]}
activeKey={profile.unitPref?.height || 'cm'}
onChange={(key) => toggleHeightUnit(key as HeightUnit)}
/>
</View>
{/* 性别 */}
@@ -323,29 +276,10 @@ function FieldLabel({ text }: { text: string }) {
);
}
function SegmentedTwo(props: {
options: { key: string; label: string }[];
activeKey: string;
onChange: (key: string) => void;
}) {
const { options, activeKey, onChange } = props;
return (
<View style={[styles.segmented, { backgroundColor: '#EFEFEF' }]}>
{options.map((opt) => (
<TouchableOpacity
key={opt.key}
style={[styles.segmentBtn, activeKey === opt.key && { backgroundColor: '#FFFFFF' }]}
onPress={() => onChange(opt.key)}
activeOpacity={0.8}
>
<Text style={{ fontSize: 14, fontWeight: '600', color: activeKey === opt.key ? '#000' : '#5E6468' }}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
);
}
// 单位切换组件已移除(固定 kg/cm
// 工具函数
// 转换函数不再使用,保留 round
function kgToLb(kg: number) { return kg * 2.2046226218; }
function lbToKg(lb: number) { return lb / 2.2046226218; }
function cmToFt(cm: number) { return cm / 30.48; }