Files
digital-pilates/components/WorkoutSummaryCard.tsx
richarjiang 79ddd41a49 feat(workout): 新增锻炼历史记录功能与健康数据集成
- 新增锻炼历史页面,展示最近一个月的锻炼记录详情
- 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据
- 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息
- 完善锻炼数据处理工具,包含统计分析和格式化功能
- 优化后台任务,随机选择挑战发送鼓励通知
- 版本升级至1.0.16
2025-10-02 22:13:59 +08:00

377 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
import { AnimatedNumber } from '@/components/AnimatedNumber';
import {
addHealthPermissionListener,
checkHealthPermissionStatus,
ensureHealthPermissions,
fetchWorkoutsForDateRange,
getHealthPermissionStatus,
getWorkoutTypeDisplayName,
HealthPermissionStatus,
removeHealthPermissionListener,
WorkoutActivityType,
WorkoutData,
} from '@/utils/health';
import { logger } from '@/utils/logger';
interface WorkoutSummaryCardProps {
date: Date;
style?: ViewStyle;
}
interface WorkoutSummary {
totalCalories: number;
totalMinutes: number;
workouts: WorkoutData[];
lastWorkout: WorkoutData | null;
}
const DEFAULT_SUMMARY: WorkoutSummary = {
totalCalories: 0,
totalMinutes: 0,
workouts: [],
lastWorkout: null,
};
const iconByWorkoutType: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
[WorkoutActivityType.Running]: 'run',
[WorkoutActivityType.Walking]: 'walk',
[WorkoutActivityType.Cycling]: 'bike',
[WorkoutActivityType.Swimming]: 'swim',
[WorkoutActivityType.Yoga]: 'meditation',
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
[WorkoutActivityType.CrossTraining]: 'arm-flex',
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
[WorkoutActivityType.Flexibility]: 'meditation',
[WorkoutActivityType.Cooldown]: 'meditation',
[WorkoutActivityType.Other]: 'arm-flex',
};
export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, style }) => {
const router = useRouter();
const [summary, setSummary] = useState<WorkoutSummary>(DEFAULT_SUMMARY);
const [isLoading, setIsLoading] = useState(false);
const [resetToken, setResetToken] = useState(0);
const isMountedRef = useRef(true);
const loadWorkoutData = useCallback(async (targetDate: Date) => {
setIsLoading(true);
try {
let permissionStatus = getHealthPermissionStatus();
// 如果当前状态未知或未授权,主动检查并尝试请求权限
if (permissionStatus !== HealthPermissionStatus.Authorized) {
permissionStatus = await checkHealthPermissionStatus(true);
}
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
if (!hasPermission) {
hasPermission = await ensureHealthPermissions();
}
if (!hasPermission) {
logger.warn('尚未获得HealthKit锻炼权限无法加载锻炼数据');
if (isMountedRef.current) {
setSummary(DEFAULT_SUMMARY);
}
return;
}
const startDate = dayjs(targetDate).startOf('day').toDate();
const endDate = dayjs(targetDate).endOf('day').toDate();
const workouts = await fetchWorkoutsForDateRange(startDate, endDate, 50);
console.log('workouts', workouts);
const workoutsInRange = workouts
.filter((workout) => {
// 额外防护:确保锻炼记录确实落在当天
const workoutDate = dayjs(workout.startDate);
return workoutDate.isSame(dayjs(targetDate), 'day');
})
// 依据结束时间排序,最新在前
.sort((a, b) => dayjs(b.endDate || b.startDate).valueOf() - dayjs(a.endDate || a.startDate).valueOf());
const totalCalories = workoutsInRange.reduce((total, workout) => total + (workout.totalEnergyBurned || 0), 0);
const totalMinutes = Math.round(
workoutsInRange.reduce((total, workout) => total + (workout.duration || 0), 0) / 60
);
const lastWorkout = workoutsInRange.length > 0 ? workoutsInRange[0] : null;
if (isMountedRef.current) {
setSummary({
totalCalories,
totalMinutes,
workouts: workoutsInRange,
lastWorkout,
});
setResetToken((token) => token + 1);
}
} catch (error) {
logger.error('加载锻炼数据失败', error);
if (isMountedRef.current) {
setSummary(DEFAULT_SUMMARY);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, []);
useEffect(() => {
isMountedRef.current = true;
loadWorkoutData(date);
return () => {
isMountedRef.current = false;
};
}, [date, loadWorkoutData]);
useEffect(() => {
const handlePermissionGranted = () => {
loadWorkoutData(date);
};
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
return () => {
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
};
}, [date, loadWorkoutData]);
const handlePress = useCallback(() => {
router.push('/workout/history');
}, [router]);
const handleAddPress = useCallback(() => {
router.push('/workout/create-session');
}, [router]);
const cardContent = useMemo(() => {
const hasWorkouts = summary.workouts.length > 0;
const lastWorkout = summary.lastWorkout;
const label = lastWorkout
? getWorkoutTypeDisplayName(lastWorkout.workoutActivityType)
: '尚无锻炼数据';
const time = lastWorkout
? `${dayjs(lastWorkout.endDate || lastWorkout.startDate).format('HH:mm')} 更新`
: '等待同步';
let source = '来源:等待同步';
if (hasWorkouts) {
const sourceNames = summary.workouts
.map((workout) => workout.source?.name?.trim() || workout.source?.bundleIdentifier?.trim())
.filter((name): name is string => Boolean(name));
if (sourceNames.length) {
const uniqueNames = Array.from(new Set(sourceNames));
const displayNames = uniqueNames.slice(0, 2).join('、');
source = uniqueNames.length > 2 ? `来源:${displayNames}` : `来源:${displayNames}`;
} else {
source = '来源:未知';
}
}
const seen = new Set<number>();
const uniqueBadges: WorkoutData[] = [];
for (const workout of summary.workouts) {
if (!seen.has(workout.workoutActivityType)) {
seen.add(workout.workoutActivityType);
uniqueBadges.push(workout);
}
if (uniqueBadges.length >= 3) {
break;
}
}
return {
label,
time,
source,
badges: uniqueBadges,
};
}, [summary]);
return (
<TouchableOpacity
activeOpacity={0.9}
style={[styles.container, style]}
onPress={handlePress}
>
<View style={styles.headerRow}>
<View style={styles.titleRow}>
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
<Text style={styles.titleText}></Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<AnimatedNumber value={summary.totalMinutes} resetToken={resetToken} style={styles.metricValue} />
<Text style={styles.metricLabel}></Text>
</View>
<View style={styles.metricItem}>
<AnimatedNumber value={summary.totalCalories} resetToken={resetToken} style={styles.metricValue} />
<Text style={styles.metricLabel}></Text>
</View>
</View>
<View style={styles.detailsRow}>
<View style={styles.detailsText}>
<Text style={styles.lastWorkoutLabel}>{cardContent.label}</Text>
<Text style={styles.lastWorkoutTime}>{cardContent.time}</Text>
<Text style={styles.sourceText}>{cardContent.source}</Text>
</View>
<View style={styles.badgesRow}>
{isLoading && <ActivityIndicator size="small" color="#7A8FFF" />}
{!isLoading && cardContent.badges.length === 0 && (
<View style={styles.badgePlaceholder}>
<MaterialCommunityIcons name="sleep" size={16} color="#7A8FFF" />
</View>
)}
</View>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 18,
paddingHorizontal: 18,
paddingVertical: 16,
width: '100%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
titleIcon: {
width: 20,
height: 20,
marginRight: 8,
resizeMode: 'contain',
},
titleText: {
fontSize: 16,
color: '#1F2355',
fontWeight: '600',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 3,
},
addButtonText: {
fontSize: 20,
color: '#7A8FFF',
marginTop: -2,
},
metricsRow: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
marginBottom: 10,
},
metricItem: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 6,
flex: 1,
},
metricDivider: {
width: 1,
height: 28,
backgroundColor: '#EEF0FF',
marginHorizontal: 12,
},
metricValue: {
fontSize: 24,
fontWeight: '700',
color: '#1F2355',
},
metricLabel: {
fontSize: 12,
color: '#4A5677',
fontWeight: '500',
marginBottom: 2,
},
detailsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
detailsText: {
flex: 1,
gap: 2,
},
lastWorkoutLabel: {
fontSize: 13,
color: '#1F2355',
fontWeight: '500',
},
lastWorkoutTime: {
fontSize: 12,
color: '#7C85A3',
},
sourceText: {
fontSize: 11,
color: '#9AA3C0',
},
badgesRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
badge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E5E9FF',
alignItems: 'center',
justifyContent: 'center',
},
badgePlaceholder: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E5E9FF',
alignItems: 'center',
justifyContent: 'center',
},
});