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 { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
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 { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
@@ -52,8 +54,11 @@ export default function ExploreScreen() {
const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null);
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 {
console.log('=== 开始HealthKit初始化流程 ===');
setIsLoading(true);
@@ -66,11 +71,12 @@ export default function ExploreScreen() {
}
console.log('权限获取成功,开始获取健康数据...');
const data = await fetchTodayHealthData();
const data = targetDate ? await fetchHealthDataForDate(targetDate) : await fetchTodayHealthData();
console.log('设置UI状态:', data);
setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned));
setAnimToken((t) => t + 1);
console.log('=== HealthKit数据获取完成 ===');
} 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 (
<View style={styles.container}>
@@ -110,10 +126,7 @@ export default function ExploreScreen() {
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
onPress={() => {
setSelectedIndex(i);
scrollToIndex(i);
}}
onPress={() => onSelectDate(i)}
activeOpacity={0.8}
>
<Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
@@ -128,47 +141,52 @@ export default function ExploreScreen() {
{/* 今日报告 标题 */}
<Text style={styles.sectionTitle}></Text>
{/* 健康数据错误提示 */}
{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>
)}
{/* 取消卡片内 loading保持静默刷新提升体验 */}
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
<View style={styles.metricsRow}>
<View style={[styles.trainingCard, styles.metricsLeft]}>
<Text style={styles.cardTitleSecondary}></Text>
<View style={styles.trainingContent}>
<View style={styles.trainingRingTrack} />
<View style={styles.trainingRingProgress} />
<Text style={styles.trainingPercent}>80%</Text>
<CircularRing
size={120}
strokeWidth={12}
trackColor="#E2D9FD"
progressColor="#8B74F3"
progress={trainingProgress}
resetToken={animToken}
/>
</View>
</View>
<View style={styles.metricsRight}>
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
<Text style={styles.cardTitleSecondary}></Text>
<Text style={styles.caloriesValue}>
{isLoading ? '加载中...' : activeCalories != null ? `${activeCalories} 千卡` : '——'}
</Text>
{activeCalories != null ? (
<AnimatedNumber
value={activeCalories}
resetToken={animToken}
style={styles.caloriesValue}
format={(v) => `${Math.round(v)} 千卡`}
/>
) : (
<Text style={styles.caloriesValue}></Text>
)}
</View>
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
<View style={styles.cardHeaderRow}>
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
<Text style={styles.cardTitle}></Text>
</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" />
</View>
</View>

View File

@@ -4,7 +4,9 @@ import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
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 {
Alert,
SafeAreaView,
@@ -29,6 +31,71 @@ 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;
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 = () => {
Alert.alert(
'重置引导',
@@ -74,13 +141,13 @@ export default function PersonalScreen() {
{/* 用户信息 */}
<View style={styles.userDetails}>
<Text style={styles.userName}>Masi Ramezanzade</Text>
<Text style={styles.userProgram}>Lose a Fat Program</Text>
<Text style={styles.userName}>{profile.fullName || '未设置姓名'}</Text>
<Text style={styles.userProgram}></Text>
</View>
{/* 编辑按钮 */}
<TouchableOpacity style={dynamicStyles.editButton}>
<Text style={dynamicStyles.editButtonText}>Edit</Text>
<TouchableOpacity style={dynamicStyles.editButton} onPress={() => router.push('/profile/edit')}>
<Text style={dynamicStyles.editButtonText}></Text>
</TouchableOpacity>
</View>
</View>
@@ -89,16 +156,16 @@ export default function PersonalScreen() {
const StatsSection = () => (
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>180cm</Text>
<Text style={styles.statLabel}>Height</Text>
<Text style={dynamicStyles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>65kg</Text>
<Text style={styles.statLabel}>Weight</Text>
<Text style={dynamicStyles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>22yo</Text>
<Text style={styles.statLabel}>Age</Text>
<Text style={dynamicStyles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
);
@@ -176,25 +243,26 @@ export default function PersonalScreen() {
icon: 'person-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Personal Data',
title: '个人资料',
onPress: () => router.push('/profile/edit'),
},
{
icon: 'trophy-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Achievement',
title: '成就',
},
{
icon: 'time-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Activity History',
title: '活动历史',
},
{
icon: 'stats-chart-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Workout Progress',
title: '训练进度',
},
];
@@ -203,7 +271,7 @@ export default function PersonalScreen() {
icon: 'notifications-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Pop-up Notification',
title: '弹窗通知',
type: 'switch',
},
];
@@ -213,19 +281,19 @@ export default function PersonalScreen() {
icon: 'mail-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Contact Us',
title: '联系我们',
},
{
icon: 'shield-checkmark-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Privacy Policy',
title: '隐私政策',
},
{
icon: 'settings-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: 'Settings',
title: '设置',
},
];
@@ -250,10 +318,10 @@ export default function PersonalScreen() {
>
<UserInfoSection />
<StatsSection />
<MenuSection title="Account" items={accountItems} />
<MenuSection title="Notification" items={notificationItems} />
<MenuSection title="Other" items={otherItems} />
<MenuSection title="Developer" items={developerItems} />
<MenuSection title="账户" items={accountItems} />
<MenuSection title="通知" items={notificationItems} />
<MenuSection title="其他" items={otherItems} />
<MenuSection title="开发者" items={developerItems} />
{/* 底部浮动按钮 */}
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>