feat(workout): 新增锻炼历史记录功能与健康数据集成

- 新增锻炼历史页面,展示最近一个月的锻炼记录详情
- 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据
- 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息
- 完善锻炼数据处理工具,包含统计分析和格式化功能
- 优化后台任务,随机选择挑战发送鼓励通知
- 版本升级至1.0.16
This commit is contained in:
2025-10-02 22:13:59 +08:00
parent 303c36025b
commit 79ddd41a49
13 changed files with 1437 additions and 34 deletions

View 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',
},
});

View File

@@ -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,