feat(workout): 新增锻炼历史记录功能与健康数据集成
- 新增锻炼历史页面,展示最近一个月的锻炼记录详情 - 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据 - 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息 - 完善锻炼数据处理工具,包含统计分析和格式化功能 - 优化后台任务,随机选择挑战发送鼓励通知 - 版本升级至1.0.16
This commit is contained in:
376
components/WorkoutSummaryCard.tsx
Normal file
376
components/WorkoutSummaryCard.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -308,7 +308,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 22,
|
||||
padding: 16,
|
||||
marginBottom: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
|
||||
Reference in New Issue
Block a user