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> = { [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 = ({ date, style }) => { const router = useRouter(); const [summary, setSummary] = useState(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(); 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 ( 健身 分钟 千卡 {cardContent.label} {cardContent.time} {cardContent.source} {isLoading && } {!isLoading && cardContent.badges.length === 0 && ( )} ); }; 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', }, });