feat: 添加用户登录和法律协议页面
- 新增登录页面,支持 Apple 登录和游客登录功能 - 添加用户协议和隐私政策页面,用户需同意后才能登录 - 更新首页逻辑,首次进入时自动跳转到登录页面 - 修改个人信息页面,移除单位选择功能,统一使用 kg 和 cm - 更新依赖,添加 expo-apple-authentication 库以支持 Apple 登录 - 更新布局以适应新功能的展示和交互
This commit is contained in:
@@ -187,7 +187,13 @@ export default function ExploreScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<Text style={styles.stepsValue}>——/2000</Text>
|
<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>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ThemedView } from '@/components/ThemedView';
|
|||||||
import { WorkoutCard } from '@/components/WorkoutCard';
|
import { WorkoutCard } from '@/components/WorkoutCard';
|
||||||
import { getChineseGreeting } from '@/utils/date';
|
import { getChineseGreeting } from '@/utils/date';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
const workoutData = [
|
const workoutData = [
|
||||||
@@ -24,6 +24,15 @@ const workoutData = [
|
|||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const hasOpenedLoginRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 仅在本次会话首次进入首页时打开登录页,可返回关闭
|
||||||
|
if (!hasOpenedLoginRef.current) {
|
||||||
|
hasOpenedLoginRef.current = true;
|
||||||
|
router.push('/auth/login');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ export default function PersonalScreen() {
|
|||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
|
|
||||||
type WeightUnit = 'kg' | 'lb';
|
|
||||||
type HeightUnit = 'cm' | 'ft';
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -40,7 +38,7 @@ export default function PersonalScreen() {
|
|||||||
age?: string;
|
age?: string;
|
||||||
weightKg?: number;
|
weightKg?: number;
|
||||||
heightCm?: number;
|
heightCm?: number;
|
||||||
unitPref?: { weight: WeightUnit; height: HeightUnit };
|
avatarUri?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [profile, setProfile] = useState<UserProfile>({});
|
const [profile, setProfile] = useState<UserProfile>({});
|
||||||
@@ -78,16 +76,12 @@ export default function PersonalScreen() {
|
|||||||
|
|
||||||
const formatHeight = () => {
|
const formatHeight = () => {
|
||||||
if (profile.heightCm == null) return '--';
|
if (profile.heightCm == null) return '--';
|
||||||
const unit = profile.unitPref?.height ?? 'cm';
|
return `${Math.round(profile.heightCm)}cm`;
|
||||||
if (unit === 'cm') return `${Math.round(profile.heightCm)}cm`;
|
|
||||||
return `${round(profile.heightCm / 30.48, 1)}ft`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatWeight = () => {
|
const formatWeight = () => {
|
||||||
if (profile.weightKg == null) return '--';
|
if (profile.weightKg == null) return '--';
|
||||||
const unit = profile.unitPref?.weight ?? 'kg';
|
return `${round(profile.weightKg, 1)}kg`;
|
||||||
if (unit === 'kg') return `${round(profile.weightKg, 1)}kg`;
|
|
||||||
return `${round(profile.weightKg * 2.2046226218, 1)}lb`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatAge = () => (profile.age ? `${profile.age}岁` : '--');
|
const formatAge = () => (profile.age ? `${profile.age}岁` : '--');
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="profile/edit" />
|
<Stack.Screen name="profile/edit" />
|
||||||
<Stack.Screen name="ai-posture-assessment" />
|
<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.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
|
|||||||
225
app/auth/login.tsx
Normal file
225
app/auth/login.tsx
Normal 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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
15
app/legal/privacy-policy.tsx
Normal file
15
app/legal/privacy-policy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
app/legal/user-agreement.tsx
Normal file
15
app/legal/user-agreement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
@@ -32,10 +32,6 @@ interface UserProfile {
|
|||||||
weightKg?: number; // kg
|
weightKg?: number; // kg
|
||||||
heightCm?: number; // cm
|
heightCm?: number; // cm
|
||||||
avatarUri?: string | null;
|
avatarUri?: string | null;
|
||||||
unitPref?: {
|
|
||||||
weight: WeightUnit;
|
|
||||||
height: HeightUnit;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = '@user_profile';
|
const STORAGE_KEY = '@user_profile';
|
||||||
@@ -52,26 +48,12 @@ export default function EditProfileScreen() {
|
|||||||
weightKg: undefined,
|
weightKg: undefined,
|
||||||
heightCm: undefined,
|
heightCm: undefined,
|
||||||
avatarUri: null,
|
avatarUri: null,
|
||||||
unitPref: { weight: 'kg', height: 'cm' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [weightInput, setWeightInput] = useState<string>('');
|
const [weightInput, setWeightInput] = useState<string>('');
|
||||||
const [heightInput, setHeightInput] = 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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -89,7 +71,6 @@ export default function EditProfileScreen() {
|
|||||||
weightKg: undefined,
|
weightKg: undefined,
|
||||||
heightCm: undefined,
|
heightCm: undefined,
|
||||||
avatarUri: null,
|
avatarUri: null,
|
||||||
unitPref: { weight: 'kg', height: 'cm' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fromOnboarding) {
|
if (fromOnboarding) {
|
||||||
@@ -109,8 +90,8 @@ export default function EditProfileScreen() {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
setProfile(next);
|
setProfile(next);
|
||||||
setWeightInput(next.weightKg != null ? (next.unitPref?.weight === 'kg' ? String(round(next.weightKg, 1)) : String(round(kgToLb(next.weightKg), 1))) : '');
|
setWeightInput(next.weightKg != null ? String(round(next.weightKg, 1)) : '');
|
||||||
setHeightInput(next.heightCm != null ? (next.unitPref?.height === 'cm' ? String(Math.round(next.heightCm)) : String(round(cmToFt(next.heightCm), 1))) : '');
|
setHeightInput(next.heightCm != null ? String(Math.round(next.heightCm)) : '');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('读取资料失败', e);
|
console.warn('读取资料失败', e);
|
||||||
}
|
}
|
||||||
@@ -124,17 +105,17 @@ export default function EditProfileScreen() {
|
|||||||
try {
|
try {
|
||||||
const next: UserProfile = { ...profile };
|
const next: UserProfile = { ...profile };
|
||||||
|
|
||||||
// 将当前输入反向同步为公制
|
// 将当前输入同步为公制(固定 kg/cm)
|
||||||
const w = parseFloat(weightInput);
|
const w = parseFloat(weightInput);
|
||||||
if (!isNaN(w)) {
|
if (!isNaN(w)) {
|
||||||
next.weightKg = profile.unitPref?.weight === 'kg' ? w : lbToKg(w);
|
next.weightKg = w;
|
||||||
} else {
|
} else {
|
||||||
next.weightKg = undefined;
|
next.weightKg = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const h = parseFloat(heightInput);
|
const h = parseFloat(heightInput);
|
||||||
if (!isNaN(h)) {
|
if (!isNaN(h)) {
|
||||||
next.heightCm = profile.unitPref?.height === 'cm' ? h : ftToCm(h);
|
next.heightCm = h;
|
||||||
} else {
|
} else {
|
||||||
next.heightCm = undefined;
|
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 () => {
|
const pickAvatarFromLibrary = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -235,44 +196,36 @@ export default function EditProfileScreen() {
|
|||||||
{!!profile.fullName && <Text style={{ color: '#C4C4C4' }}>✓</Text>}
|
{!!profile.fullName && <Text style={{ color: '#C4C4C4' }}>✓</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 体重 */}
|
{/* 体重(kg) */}
|
||||||
<FieldLabel text="体重" />
|
<FieldLabel text="体重" />
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.textInput, { color: textColor }]}
|
style={[styles.textInput, { color: textColor }]}
|
||||||
placeholder={profile.unitPref?.weight === 'kg' ? '输入体重' : '输入体重'}
|
placeholder={'输入体重'}
|
||||||
placeholderTextColor={placeholderColor}
|
placeholderTextColor={placeholderColor}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={weightInput}
|
value={weightInput}
|
||||||
onChangeText={setWeightInput}
|
onChangeText={setWeightInput}
|
||||||
/>
|
/>
|
||||||
|
<Text style={{ color: '#5E6468', marginLeft: 6 }}>kg</Text>
|
||||||
</View>
|
</View>
|
||||||
<SegmentedTwo
|
|
||||||
options={[{ key: 'lb', label: 'LBS' }, { key: 'kg', label: 'KG' }]}
|
|
||||||
activeKey={profile.unitPref?.weight || 'kg'}
|
|
||||||
onChange={(key) => toggleWeightUnit(key as WeightUnit)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 身高 */}
|
{/* 身高(cm) */}
|
||||||
<FieldLabel text="身高" />
|
<FieldLabel text="身高" />
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.textInput, { color: textColor }]}
|
style={[styles.textInput, { color: textColor }]}
|
||||||
placeholder={profile.unitPref?.height === 'cm' ? '输入身高' : '输入身高'}
|
placeholder={'输入身高'}
|
||||||
placeholderTextColor={placeholderColor}
|
placeholderTextColor={placeholderColor}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={heightInput}
|
value={heightInput}
|
||||||
onChangeText={setHeightInput}
|
onChangeText={setHeightInput}
|
||||||
/>
|
/>
|
||||||
|
<Text style={{ color: '#5E6468', marginLeft: 6 }}>cm</Text>
|
||||||
</View>
|
</View>
|
||||||
<SegmentedTwo
|
|
||||||
options={[{ key: 'ft', label: 'FEET' }, { key: 'cm', label: 'CM' }]}
|
|
||||||
activeKey={profile.unitPref?.height || 'cm'}
|
|
||||||
onChange={(key) => toggleHeightUnit(key as HeightUnit)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 性别 */}
|
{/* 性别 */}
|
||||||
@@ -323,29 +276,10 @@ function FieldLabel({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SegmentedTwo(props: {
|
// 单位切换组件已移除(固定 kg/cm)
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
|
// 转换函数不再使用,保留 round
|
||||||
function kgToLb(kg: number) { return kg * 2.2046226218; }
|
function kgToLb(kg: number) { return kg * 2.2046226218; }
|
||||||
function lbToKg(lb: number) { return lb / 2.2046226218; }
|
function lbToKg(lb: number) { return lb / 2.2046226218; }
|
||||||
function cmToFt(cm: number) { return cm / 30.48; }
|
function cmToFt(cm: number) { return cm / 30.48; }
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
|
- ExpoAppleAuthentication (6.4.2):
|
||||||
|
- ExpoModulesCore
|
||||||
- ExpoAsset (11.1.7):
|
- ExpoAsset (11.1.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoBlur (14.1.5):
|
- ExpoBlur (14.1.5):
|
||||||
@@ -2156,6 +2158,7 @@ DEPENDENCIES:
|
|||||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||||
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
||||||
- Expo (from `../node_modules/expo`)
|
- Expo (from `../node_modules/expo`)
|
||||||
|
- ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`)
|
||||||
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||||
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
||||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||||
@@ -2273,6 +2276,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/expo-image-loader/ios"
|
:path: "../node_modules/expo-image-loader/ios"
|
||||||
Expo:
|
Expo:
|
||||||
:path: "../node_modules/expo"
|
:path: "../node_modules/expo"
|
||||||
|
ExpoAppleAuthentication:
|
||||||
|
:path: "../node_modules/expo-apple-authentication/ios"
|
||||||
ExpoAsset:
|
ExpoAsset:
|
||||||
:path: "../node_modules/expo-asset/ios"
|
:path: "../node_modules/expo-asset/ios"
|
||||||
ExpoBlur:
|
ExpoBlur:
|
||||||
@@ -2465,6 +2470,7 @@ SPEC CHECKSUMS:
|
|||||||
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
||||||
EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee
|
EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee
|
||||||
Expo: a40d525c930dd1c8a158e082756ee071955baccb
|
Expo: a40d525c930dd1c8a158e082756ee071955baccb
|
||||||
|
ExpoAppleAuthentication: 8a661b6f4936affafd830f983ac22463c936dad5
|
||||||
ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6
|
ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6
|
||||||
ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9
|
ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9
|
||||||
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
|
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = digitalpilates/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = digitalpilates/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = digitalpilates/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = digitalpilates/Info.plist; sourceTree = "<group>"; };
|
||||||
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-digitalpilates.debug.xcconfig"; path = "Target Support Files/Pods-digitalpilates/Pods-digitalpilates.debug.xcconfig"; sourceTree = "<group>"; };
|
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-digitalpilates.debug.xcconfig"; path = "Target Support Files/Pods-digitalpilates/Pods-digitalpilates.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
@@ -73,7 +73,6 @@
|
|||||||
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */,
|
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */,
|
||||||
EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */,
|
EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Pods;
|
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -340,6 +339,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -376,6 +376,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||||
INFOPLIST_FILE = digitalpilates/Info.plist;
|
INFOPLIST_FILE = digitalpilates/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.healthkit</key>
|
<key>com.apple.developer.healthkit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.healthkit.access</key>
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
<array/>
|
<array/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
|
"expo-apple-authentication": "6.4.2",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
"expo-font": "~13.3.2",
|
"expo-font": "~13.3.2",
|
||||||
@@ -6329,6 +6330,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-apple-authentication": {
|
||||||
|
"version": "6.4.2",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/expo-apple-authentication/-/expo-apple-authentication-6.4.2.tgz",
|
||||||
|
"integrity": "sha512-X4u1n3Ql1hOpztXHbKNq4I1l4+Ff82gC6RmEeW43Eht7VE6E8PrQBpYKw+JJv8osrCJt7R5O1PZwed6WLN5oig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-asset": {
|
"node_modules/expo-asset": {
|
||||||
"version": "11.1.7",
|
"version": "11.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.1.7.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
|
"expo-apple-authentication": "6.4.2",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
"expo-font": "~13.3.2",
|
"expo-font": "~13.3.2",
|
||||||
"expo-haptics": "~14.1.4",
|
"expo-haptics": "~14.1.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user