feat: 更新健康数据功能和用户个人信息页面

- 在 Explore 页面中添加日期选择功能,允许用户查看指定日期的健康数据
- 重构健康数据获取逻辑,支持根据日期获取健康数据
- 在个人信息页面中集成用户资料编辑功能,支持姓名、性别、年龄、体重和身高的输入
- 新增 AnimatedNumber 和 CircularRing 组件,优化数据展示效果
- 更新 package.json 和 package-lock.json,添加 react-native-svg 依赖
- 修改布局以支持新功能的显示和交互
This commit is contained in:
richarjiang
2025-08-12 18:54:15 +08:00
parent 2fac3f899c
commit 8ffebfb297
14 changed files with 1034 additions and 72 deletions

View File

@@ -1,8 +1,10 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
import { CircularRing } from '@/components/CircularRing';
import { ProgressBar } from '@/components/ProgressBar'; import { ProgressBar } from '@/components/ProgressBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchTodayHealthData } from '@/utils/health'; import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
@@ -52,8 +54,11 @@ export default function ExploreScreen() {
const [stepCount, setStepCount] = useState<number | null>(null); const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null); const [activeCalories, setActiveCalories] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80%
const loadHealthData = async () => { const loadHealthData = async (targetDate?: Date) => {
try { try {
console.log('=== 开始HealthKit初始化流程 ==='); console.log('=== 开始HealthKit初始化流程 ===');
setIsLoading(true); setIsLoading(true);
@@ -66,11 +71,12 @@ export default function ExploreScreen() {
} }
console.log('权限获取成功,开始获取健康数据...'); console.log('权限获取成功,开始获取健康数据...');
const data = await fetchTodayHealthData(); const data = targetDate ? await fetchHealthDataForDate(targetDate) : await fetchTodayHealthData();
console.log('设置UI状态:', data); console.log('设置UI状态:', data);
setStepCount(data.steps); setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned)); setActiveCalories(Math.round(data.activeEnergyBurned));
setAnimToken((t) => t + 1);
console.log('=== HealthKit数据获取完成 ==='); console.log('=== HealthKit数据获取完成 ===');
} catch (error) { } catch (error) {
@@ -86,6 +92,16 @@ export default function ExploreScreen() {
}, []) }, [])
); );
// 日期点击时,加载对应日期数据
const onSelectDate = (index: number) => {
setSelectedIndex(index);
scrollToIndex(index);
const target = days[index]?.date?.toDate();
if (target) {
loadHealthData(target);
}
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -110,10 +126,7 @@ export default function ExploreScreen() {
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}> <View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity <TouchableOpacity
style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]} style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
onPress={() => { onPress={() => onSelectDate(i)}
setSelectedIndex(i);
scrollToIndex(i);
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text> <Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
@@ -128,47 +141,52 @@ export default function ExploreScreen() {
{/* 今日报告 标题 */} {/* 今日报告 标题 */}
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}></Text>
{/* 健康数据错误提示 */} {/* 取消卡片内 loading保持静默刷新提升体验 */}
{isLoading && (
<View style={styles.errorContainer}>
<Ionicons name="warning-outline" size={20} color="#E54D4D" />
<Text style={styles.errorText}>...</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={loadHealthData} disabled={isLoading}
>
<Ionicons
name="refresh-outline"
size={16}
color={isLoading ? '#9AA3AE' : '#E54D4D'}
/>
</TouchableOpacity>
</View>
)}
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */} {/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
<View style={styles.metricsRow}> <View style={styles.metricsRow}>
<View style={[styles.trainingCard, styles.metricsLeft]}> <View style={[styles.trainingCard, styles.metricsLeft]}>
<Text style={styles.cardTitleSecondary}></Text> <Text style={styles.cardTitleSecondary}></Text>
<View style={styles.trainingContent}> <View style={styles.trainingContent}>
<View style={styles.trainingRingTrack} /> <CircularRing
<View style={styles.trainingRingProgress} /> size={120}
<Text style={styles.trainingPercent}>80%</Text> strokeWidth={12}
trackColor="#E2D9FD"
progressColor="#8B74F3"
progress={trainingProgress}
resetToken={animToken}
/>
</View> </View>
</View> </View>
<View style={styles.metricsRight}> <View style={styles.metricsRight}>
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}> <View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
<Text style={styles.cardTitleSecondary}></Text> <Text style={styles.cardTitleSecondary}></Text>
<Text style={styles.caloriesValue}> {activeCalories != null ? (
{isLoading ? '加载中...' : activeCalories != null ? `${activeCalories} 千卡` : '——'} <AnimatedNumber
</Text> value={activeCalories}
resetToken={animToken}
style={styles.caloriesValue}
format={(v) => `${Math.round(v)} 千卡`}
/>
) : (
<Text style={styles.caloriesValue}></Text>
)}
</View> </View>
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}> <View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
<View style={styles.cardHeaderRow}> <View style={styles.cardHeaderRow}>
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View> <View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
</View> </View>
<Text style={styles.stepsValue}>{isLoading ? '加载中.../2000' : stepCount != null ? `${stepCount}/2000` : '——/2000'}</Text> {stepCount != null ? (
<AnimatedNumber
value={stepCount}
resetToken={animToken}
style={styles.stepsValue}
format={(v) => `${Math.round(v)}/2000`}
/>
) : (
<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={12} trackColor="#FFEBCB" fillColor="#FFC365" />
</View> </View>
</View> </View>

View File

@@ -4,7 +4,9 @@ import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons'; 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 { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import React, { useMemo, useState } from 'react'; import { useFocusEffect } from '@react-navigation/native';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
SafeAreaView, SafeAreaView,
@@ -29,6 +31,71 @@ 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 = {
fullName?: string;
email?: string;
gender?: 'male' | 'female' | '';
age?: string;
weightKg?: number;
heightCm?: number;
unitPref?: { weight: WeightUnit; height: HeightUnit };
};
const [profile, setProfile] = useState<UserProfile>({});
const load = async () => {
try {
const [p, o] = await Promise.all([
AsyncStorage.getItem('@user_profile'),
AsyncStorage.getItem('@user_personal_info'),
]);
let next: UserProfile = {};
if (o) {
try {
const parsed = JSON.parse(o);
next = {
...next,
age: parsed?.age ? String(parsed.age) : undefined,
gender: parsed?.gender || '',
heightCm: parsed?.height ? parseFloat(parsed.height) : undefined,
weightKg: parsed?.weight ? parseFloat(parsed.weight) : undefined,
};
} catch { }
}
if (p) {
try { next = { ...next, ...JSON.parse(p) }; } catch { }
}
setProfile(next);
} catch (e) {
console.warn('加载用户资料失败', e);
}
};
useEffect(() => { load(); }, []);
useFocusEffect(React.useCallback(() => { load(); return () => { }; }, []));
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`;
};
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`;
};
const formatAge = () => (profile.age ? `${profile.age}` : '--');
const round = (n: number, d = 0) => {
const p = Math.pow(10, d); return Math.round(n * p) / p;
};
const handleResetOnboarding = () => { const handleResetOnboarding = () => {
Alert.alert( Alert.alert(
'重置引导', '重置引导',
@@ -74,13 +141,13 @@ export default function PersonalScreen() {
{/* 用户信息 */} {/* 用户信息 */}
<View style={styles.userDetails}> <View style={styles.userDetails}>
<Text style={styles.userName}>Masi Ramezanzade</Text> <Text style={styles.userName}>{profile.fullName || '未设置姓名'}</Text>
<Text style={styles.userProgram}>Lose a Fat Program</Text> <Text style={styles.userProgram}></Text>
</View> </View>
{/* 编辑按钮 */} {/* 编辑按钮 */}
<TouchableOpacity style={dynamicStyles.editButton}> <TouchableOpacity style={dynamicStyles.editButton} onPress={() => router.push('/profile/edit')}>
<Text style={dynamicStyles.editButtonText}>Edit</Text> <Text style={dynamicStyles.editButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -89,16 +156,16 @@ export default function PersonalScreen() {
const StatsSection = () => ( const StatsSection = () => (
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>180cm</Text> <Text style={dynamicStyles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}>Height</Text> <Text style={styles.statLabel}></Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>65kg</Text> <Text style={dynamicStyles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}>Weight</Text> <Text style={styles.statLabel}></Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>22yo</Text> <Text style={dynamicStyles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}>Age</Text> <Text style={styles.statLabel}></Text>
</View> </View>
</View> </View>
); );
@@ -176,25 +243,26 @@ export default function PersonalScreen() {
icon: 'person-outline', icon: 'person-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Personal Data', title: '个人资料',
onPress: () => router.push('/profile/edit'),
}, },
{ {
icon: 'trophy-outline', icon: 'trophy-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Achievement', title: '成就',
}, },
{ {
icon: 'time-outline', icon: 'time-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Activity History', title: '活动历史',
}, },
{ {
icon: 'stats-chart-outline', icon: 'stats-chart-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Workout Progress', title: '训练进度',
}, },
]; ];
@@ -203,7 +271,7 @@ export default function PersonalScreen() {
icon: 'notifications-outline', icon: 'notifications-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Pop-up Notification', title: '弹窗通知',
type: 'switch', type: 'switch',
}, },
]; ];
@@ -213,19 +281,19 @@ export default function PersonalScreen() {
icon: 'mail-outline', icon: 'mail-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Contact Us', title: '联系我们',
}, },
{ {
icon: 'shield-checkmark-outline', icon: 'shield-checkmark-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Privacy Policy', title: '隐私政策',
}, },
{ {
icon: 'settings-outline', icon: 'settings-outline',
iconBg: '#E8F5E8', iconBg: '#E8F5E8',
iconColor: '#4ADE80', iconColor: '#4ADE80',
title: 'Settings', title: '设置',
}, },
]; ];
@@ -250,10 +318,10 @@ export default function PersonalScreen() {
> >
<UserInfoSection /> <UserInfoSection />
<StatsSection /> <StatsSection />
<MenuSection title="Account" items={accountItems} /> <MenuSection title="账户" items={accountItems} />
<MenuSection title="Notification" items={notificationItems} /> <MenuSection title="通知" items={notificationItems} />
<MenuSection title="Other" items={otherItems} /> <MenuSection title="其他" items={otherItems} />
<MenuSection title="Developer" items={developerItems} /> <MenuSection title="开发者" items={developerItems} />
{/* 底部浮动按钮 */} {/* 底部浮动按钮 */}
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}> <View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>

View File

@@ -19,10 +19,11 @@ export default function RootLayout() {
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" options={{ headerShown: false }} /> <Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="ai-posture-assessment" options={{ headerShown: false }} /> <Stack.Screen name="profile/edit" />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />

View File

@@ -2,9 +2,9 @@ import { Stack } from 'expo-router';
export default function OnboardingLayout() { export default function OnboardingLayout() {
return ( return (
<Stack> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" />
<Stack.Screen name="personal-info" options={{ headerShown: false }} /> <Stack.Screen name="personal-info" />
</Stack> </Stack>
); );
} }

454
app/profile/edit.tsx Normal file
View File

@@ -0,0 +1,454 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
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 {
Alert,
Image,
KeyboardAvoidingView,
Platform,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type WeightUnit = 'kg' | 'lb';
type HeightUnit = 'cm' | 'ft';
interface UserProfile {
fullName?: string;
gender?: 'male' | 'female' | '';
age?: string; // 存储为字符串,方便非必填
// 以公制为基准存储
weightKg?: number; // kg
heightCm?: number; // cm
avatarUri?: string | null;
unitPref?: {
weight: WeightUnit;
height: HeightUnit;
};
}
const STORAGE_KEY = '@user_profile';
export default function EditProfileScreen() {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
const insets = useSafeAreaInsets();
const [profile, setProfile] = useState<UserProfile>({
fullName: '',
gender: '',
age: '',
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 () => {
try {
// 读取已保存资料;兼容引导页的个人信息
const [p, fromOnboarding] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEY),
AsyncStorage.getItem('@user_personal_info'),
]);
let next: UserProfile = {
fullName: '',
gender: '',
age: '',
weightKg: undefined,
heightCm: undefined,
avatarUri: null,
unitPref: { weight: 'kg', height: 'cm' },
};
if (fromOnboarding) {
try {
const o = JSON.parse(fromOnboarding);
if (o?.weight) next.weightKg = parseFloat(o.weight) || undefined;
if (o?.height) next.heightCm = parseFloat(o.height) || undefined;
if (o?.age) next.age = String(o.age);
if (o?.gender) next.gender = o.gender;
} catch { }
}
if (p) {
try {
const parsed: UserProfile = JSON.parse(p);
next = { ...next, ...parsed };
} 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))) : '');
} catch (e) {
console.warn('读取资料失败', e);
}
})();
}, []);
const textColor = colors.text;
const placeholderColor = colors.icon;
const handleSave = async () => {
try {
const next: UserProfile = { ...profile };
// 将当前输入反向同步为公制
const w = parseFloat(weightInput);
if (!isNaN(w)) {
next.weightKg = profile.unitPref?.weight === 'kg' ? w : lbToKg(w);
} else {
next.weightKg = undefined;
}
const h = parseFloat(heightInput);
if (!isNaN(h)) {
next.heightCm = profile.unitPref?.height === 'cm' ? h : ftToCm(h);
} else {
next.heightCm = undefined;
}
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
Alert.alert('已保存', '个人资料已更新。');
router.back();
} catch (e) {
Alert.alert('保存失败', '请稍后重试');
}
};
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 {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert('权限不足', '需要相册权限以选择头像');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
quality: 0.9,
aspect: [1, 1],
mediaTypes: ImagePicker.MediaTypeOptions.Images,
});
if (!result.canceled) {
setProfile((p) => ({ ...p, avatarUri: result.assets?.[0]?.uri ?? null }));
}
} catch (e) {
Alert.alert('发生错误', '选择头像失败,请重试');
}
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
<StatusBar barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
{/* 统一头部 */}
<View style={[styles.header]}>
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#192126" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={{ width: 32 }} />
</View>
{/* 头像(带相机蒙层,点击从相册选择) */}
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary}>
<View style={styles.avatarCircle}>
{profile.avatarUri ? (
<Image source={{ uri: profile.avatarUri }} style={styles.avatarImage} />
) : (
<View style={{ width: 56, height: 56, borderRadius: 28, backgroundColor: '#D4A574' }} />
)}
<View style={styles.avatarOverlay}>
<Ionicons name="camera" size={22} color="#192126" />
</View>
</View>
</TouchableOpacity>
</View>
{/* 姓名 */}
<FieldLabel text="姓名" />
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder="填写姓名(可选)"
placeholderTextColor={placeholderColor}
value={profile.fullName}
onChangeText={(t) => setProfile((p) => ({ ...p, fullName: t }))}
/>
{/* 校验勾无需强制,仅装饰 */}
{!!profile.fullName && <Text style={{ color: '#C4C4C4' }}></Text>}
</View>
{/* 体重 */}
<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' ? '输入体重' : '输入体重'}
placeholderTextColor={placeholderColor}
keyboardType="numeric"
value={weightInput}
onChangeText={setWeightInput}
/>
</View>
<SegmentedTwo
options={[{ key: 'lb', label: 'LBS' }, { key: 'kg', label: 'KG' }]}
activeKey={profile.unitPref?.weight || 'kg'}
onChange={(key) => toggleWeightUnit(key as WeightUnit)}
/>
</View>
{/* 身高 */}
<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' ? '输入身高' : '输入身高'}
placeholderTextColor={placeholderColor}
keyboardType="numeric"
value={heightInput}
onChangeText={setHeightInput}
/>
</View>
<SegmentedTwo
options={[{ key: 'ft', label: 'FEET' }, { key: 'cm', label: 'CM' }]}
activeKey={profile.unitPref?.height || 'cm'}
onChange={(key) => toggleHeightUnit(key as HeightUnit)}
/>
</View>
{/* 性别 */}
<FieldLabel text="性别" />
<View style={[styles.selector, { borderColor: '#E0E0E0' }]}>
<TouchableOpacity
style={[styles.selectorItem, profile.gender === 'female' && { backgroundColor: colors.primary + '40' }]}
onPress={() => setProfile((p) => ({ ...p, gender: 'female' }))}
>
<Text style={styles.selectorEmoji}></Text>
<Text style={styles.selectorText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.selectorItem, profile.gender === 'male' && { backgroundColor: colors.primary + '40' }]}
onPress={() => setProfile((p) => ({ ...p, gender: 'male' }))}
>
<Text style={styles.selectorEmoji}></Text>
<Text style={styles.selectorText}></Text>
</TouchableOpacity>
</View>
{/* 年龄 */}
<FieldLabel text="年龄" />
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder="填写年龄(可选)"
placeholderTextColor={placeholderColor}
keyboardType="number-pad"
value={profile.age ?? ''}
onChangeText={(t) => setProfile((p) => ({ ...p, age: t }))}
/>
</View>
{/* 保存按钮 */}
<TouchableOpacity onPress={handleSave} activeOpacity={0.9} style={[styles.saveBtn, { backgroundColor: colors.primary }]}>
<Text style={{ color: colors.onPrimary, fontSize: 18, fontWeight: '700' }}></Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
function FieldLabel({ text }: { text: string }) {
return (
<Text style={{ fontSize: 14, color: '#5E6468', marginBottom: 8, marginTop: 10 }}>{text}</Text>
);
}
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>
);
}
// 工具函数
function kgToLb(kg: number) { return kg * 2.2046226218; }
function lbToKg(lb: number) { return lb / 2.2046226218; }
function cmToFt(cm: number) { return cm / 30.48; }
function ftToCm(ft: number) { return ft * 30.48; }
function round(n: number, d = 0) { const p = Math.pow(10, d); return Math.round(n * p) / p; }
const styles = StyleSheet.create({
container: { flex: 1 },
avatarCircle: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#E8D4F0',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
},
avatarImage: {
width: 120,
height: 120,
borderRadius: 60,
},
avatarOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.25)',
},
inputWrapper: {
height: 52,
backgroundColor: '#fff',
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 16,
alignItems: 'center',
flexDirection: 'row',
},
textInput: {
flex: 1,
fontSize: 16,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
flex1: { flex: 1 },
segmented: {
flexDirection: 'row',
borderRadius: 12,
padding: 4,
backgroundColor: '#EFEFEF',
},
segmentBtn: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: 'transparent',
minWidth: 64,
alignItems: 'center',
},
selector: {
flexDirection: 'row',
gap: 12,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderRadius: 12,
padding: 8,
},
selectorItem: {
flex: 1,
height: 48,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
selectorEmoji: { fontSize: 16, marginBottom: 2 },
selectorText: { fontSize: 15, fontWeight: '600' },
saveBtn: {
marginTop: 24,
height: 56,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 0,
marginBottom: 8,
},
backButton: { padding: 4, width: 32 },
headerTitle: { fontSize: 18, fontWeight: '700', color: '#192126' },
});

View File

@@ -0,0 +1,47 @@
import React, { useEffect, useRef, useState } from 'react';
import { Animated, Easing, TextStyle } from 'react-native';
type AnimatedNumberProps = {
value: number; // 最终值
durationMs?: number;
format?: (v: number) => string;
style?: TextStyle;
/** 当该值变化时从0重新动画 */
resetToken?: unknown;
};
export function AnimatedNumber({
value,
durationMs = 800,
format,
style,
resetToken,
}: AnimatedNumberProps) {
const animated = useRef(new Animated.Value(0)).current;
const [display, setDisplay] = useState<string>('0');
useEffect(() => {
animated.stopAnimation(() => {
animated.setValue(0);
Animated.timing(animated, {
toValue: value,
duration: durationMs,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, resetToken]);
useEffect(() => {
const id = animated.addListener(({ value: v }) => {
const num = Number(v) || 0;
setDisplay(format ? format(num) : `${Math.round(num)}`);
});
return () => animated.removeListener(id);
}, [animated, format]);
return <Animated.Text style={style}>{display}</Animated.Text>;
}

121
components/CircularRing.tsx Normal file
View File

@@ -0,0 +1,121 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { Animated, Easing, StyleSheet, Text, View } from 'react-native';
import Svg, { Circle, G } from 'react-native-svg';
type CircularRingProps = {
size?: number;
strokeWidth?: number;
trackColor?: string;
progressColor?: string;
progress: number; // 0..1
durationMs?: number;
showCenterText?: boolean;
/** 当该值变化时会从0重新动画到 progress */
resetToken?: unknown;
/** 进度起始角度(度),默认 -90使进度从正上方开始 */
startAngleDeg?: number;
};
/**
* 纯 View 实现的圆环进度条(无依赖),通过两个半圆与旋转实现。
*/
export function CircularRing({
size = 120,
strokeWidth = 12,
trackColor = '#E2D9FD',
progressColor = '#8B74F3',
progress,
durationMs = 900,
showCenterText = true,
resetToken,
startAngleDeg = -90,
}: CircularRingProps) {
const clamped = useMemo(() => {
if (Number.isNaN(progress)) return 0;
return Math.min(1, Math.max(0, progress));
}, [progress]);
const animated = useRef(new Animated.Value(0)).current;
useEffect(() => {
// 每次 resetToken 或目标进度变化时从0动画到 clamped
animated.stopAnimation(() => {
animated.setValue(0);
Animated.timing(animated, {
toValue: clamped,
duration: durationMs,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clamped, resetToken]);
const radius = useMemo(() => (size - strokeWidth) / 2, [size, strokeWidth]);
const circumference = useMemo(() => 2 * Math.PI * radius, [radius]);
const dashOffset = animated.interpolate({
inputRange: [0, 1],
outputRange: [circumference, 0],
});
const percentText = useMemo(() => `${Math.round(clamped * 100)}%`, [clamped]);
const containerStyle = useMemo(() => [styles.container, { width: size, height: size }], [size]);
return (
<View style={containerStyle}>
<Svg width={size} height={size}>
<G rotation={startAngleDeg} originX={size / 2} originY={size / 2}>
{/* 轨道 */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={trackColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
/>
{/* 进度 */}
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={progressColor}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset as unknown as number}
strokeLinecap="round"
fill="none"
/>
</G>
</Svg>
{showCenterText && (
<View style={styles.center}>
<Text style={styles.centerText}>{percentText}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
center: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
centerText: {
fontSize: 18,
fontWeight: '800',
color: '#8B74F3',
},
});
const AnimatedCircle = Animated.createAnimatedComponent(Circle);

View File

@@ -2087,6 +2087,55 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNSVG (15.12.1):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-hermes
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNSVG/common (= 15.12.1)
- Yoga
- RNSVG/common (15.12.1):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-hermes
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- SDWebImage (5.21.1): - SDWebImage (5.21.1):
- SDWebImage/Core (= 5.21.1) - SDWebImage/Core (= 5.21.1)
- SDWebImage/Core (5.21.1) - SDWebImage/Core (5.21.1)
@@ -2199,6 +2248,7 @@ DEPENDENCIES:
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNReanimated (from `../node_modules/react-native-reanimated`) - RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`) - RNScreens (from `../node_modules/react-native-screens`)
- RNSVG (from `../node_modules/react-native-svg`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS: SPEC REPOS:
@@ -2404,6 +2454,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-reanimated" :path: "../node_modules/react-native-reanimated"
RNScreens: RNScreens:
:path: "../node_modules/react-native-screens" :path: "../node_modules/react-native-screens"
RNSVG:
:path: "../node_modules/react-native-svg"
Yoga: Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga" :path: "../node_modules/react-native/ReactCommon/yoga"
@@ -2506,6 +2558,7 @@ SPEC CHECKSUMS:
RNGestureHandler: 7d0931a61d7ba0259f32db0ba7d0963c3ed15d2b RNGestureHandler: 7d0931a61d7ba0259f32db0ba7d0963c3ed15d2b
RNReanimated: 2313402fe27fecb7237619e9c6fcee3177f08a65 RNReanimated: 2313402fe27fecb7237619e9c6fcee3177f08a65
RNScreens: 482e9707f9826230810c92e765751af53826d509 RNScreens: 482e9707f9826230810c92e765751af53826d509
RNSVG: ba53827311fd9f8a14e06626365a749ce7713975
SDWebImage: f29024626962457f3470184232766516dee8dfea SDWebImage: f29024626962457f3470184232766516dee8dfea
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c

View File

@@ -290,6 +290,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
@@ -304,6 +305,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",

149
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-svg": "^15.12.1",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5" "react-native-webview": "13.13.5"
}, },
@@ -4543,6 +4544,11 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"node_modules/bplist-creator": { "node_modules/bplist-creator": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -5204,6 +5210,52 @@
"hyphenate-style-name": "^1.0.3" "hyphenate-style-name": "^1.0.3"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://mirrors.tencent.com/npm/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://mirrors.tencent.com/npm/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://mirrors.tencent.com/npm/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://mirrors.tencent.com/npm/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -5423,6 +5475,58 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://mirrors.tencent.com/npm/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://mirrors.tencent.com/npm/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://mirrors.tencent.com/npm/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.7", "version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -5498,6 +5602,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://mirrors.tencent.com/npm/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -8703,6 +8819,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://mirrors.tencent.com/npm/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -9343,6 +9465,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": { "node_modules/nullthrows": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -10538,6 +10672,21 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-svg": {
"version": "15.12.1",
"resolved": "https://mirrors.tencent.com/npm/react-native-svg/-/react-native-svg-15.12.1.tgz",
"integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": { "node_modules/react-native-web": {
"version": "0.20.0", "version": "0.20.0",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz",

View File

@@ -23,6 +23,7 @@
"expo-font": "~13.3.2", "expo-font": "~13.3.2",
"expo-haptics": "~14.1.4", "expo-haptics": "~14.1.4",
"expo-image": "~2.4.0", "expo-image": "~2.4.0",
"expo-image-picker": "~16.1.4",
"expo-linear-gradient": "^14.1.5", "expo-linear-gradient": "^14.1.5",
"expo-linking": "~7.1.7", "expo-linking": "~7.1.7",
"expo-router": "~5.1.4", "expo-router": "~5.1.4",
@@ -39,9 +40,9 @@
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-svg": "^15.12.1",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5"
"expo-image-picker": "~16.1.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

View File

@@ -12,6 +12,7 @@
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".expo/types/**/*.ts", ".expo/types/**/*.ts",
"expo-env.d.ts" "expo-env.d.ts",
"types/**/*.d.ts"
] ]
} }

39
types/react-native-svg.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
declare module 'react-native-svg' {
import * as React from 'react';
import { ViewProps } from 'react-native';
export interface SvgProps extends ViewProps {
width?: number | string;
height?: number | string;
viewBox?: string;
}
export default function Svg(props: React.PropsWithChildren<SvgProps>): React.ReactElement | null;
export interface CommonProps {
fill?: string;
stroke?: string;
strokeWidth?: number;
strokeLinecap?: 'butt' | 'round' | 'square';
strokeLinejoin?: 'miter' | 'round' | 'bevel';
strokeDasharray?: string | number[];
strokeDashoffset?: number;
}
export interface CircleProps extends CommonProps {
cx?: number;
cy?: number;
r?: number;
originX?: number;
originY?: number;
}
export const Circle: React.ComponentType<CircleProps>;
export interface GProps extends CommonProps {
rotation?: number;
originX?: number;
originY?: number;
}
export const G: React.ComponentType<React.PropsWithChildren<GProps>>;
}

View File

@@ -38,12 +38,15 @@ export async function ensureHealthPermissions(): Promise<boolean> {
}); });
} }
export async function fetchTodayHealthData(): Promise<TodayHealthData> { export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthData> {
console.log('开始获取今日健康数据...'); console.log('开始获取指定日期健康数据...', date);
const start = new Date(); const start = new Date(date);
start.setHours(0, 0, 0, 0); start.setHours(0, 0, 0, 0);
const options = { startDate: start.toISOString() } as any; const end = new Date(date);
end.setHours(23, 59, 59, 999);
const options = { startDate: start.toISOString(), endDate: end.toISOString() } as any;
console.log('查询选项:', options); console.log('查询选项:', options);
@@ -73,11 +76,16 @@ export async function fetchTodayHealthData(): Promise<TodayHealthData> {
return resolve(0); return resolve(0);
} }
console.log('卡路里数据:', res); console.log('卡路里数据:', res);
// library returns value as number in kilocalories // 求和该日内的所有记录(单位:千卡)
resolve(res[0]?.value || 0); const total = res.reduce((acc: number, item: any) => acc + (item?.value || 0), 0);
resolve(total);
}); });
}); });
console.log('今日健康数据获取完成:', { steps, calories }); console.log('指定日期健康数据获取完成:', { steps, calories });
return { steps, activeEnergyBurned: calories }; return { steps, activeEnergyBurned: calories };
} }
export async function fetchTodayHealthData(): Promise<TodayHealthData> {
return fetchHealthDataForDate(new Date());
}