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

448 lines
14 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 { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
SectionList,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import {
addHealthPermissionListener,
checkHealthPermissionStatus,
ensureHealthPermissions,
fetchWorkoutsForDateRange,
getHealthPermissionStatus,
getWorkoutTypeDisplayName,
HealthPermissionStatus,
removeHealthPermissionListener,
WorkoutActivityType,
WorkoutData,
} from '@/utils/health';
type WorkoutSection = {
title: string;
data: WorkoutData[];
};
const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
// 球类运动
[WorkoutActivityType.AmericanFootball]: 'football',
[WorkoutActivityType.Archery]: 'target',
[WorkoutActivityType.AustralianFootball]: 'football',
[WorkoutActivityType.Badminton]: 'tennis',
[WorkoutActivityType.Baseball]: 'baseball',
[WorkoutActivityType.Basketball]: 'basketball',
[WorkoutActivityType.Bowling]: 'bowling',
[WorkoutActivityType.Boxing]: 'boxing-glove',
[WorkoutActivityType.Cricket]: 'cricket',
[WorkoutActivityType.Fencing]: 'sword',
[WorkoutActivityType.Golf]: 'golf',
[WorkoutActivityType.Handball]: 'basketball',
[WorkoutActivityType.Hockey]: 'hockey-sticks',
[WorkoutActivityType.Lacrosse]: 'tennis',
[WorkoutActivityType.Racquetball]: 'tennis',
[WorkoutActivityType.Soccer]: 'soccer',
[WorkoutActivityType.Softball]: 'baseball',
[WorkoutActivityType.Squash]: 'tennis',
[WorkoutActivityType.TableTennis]: 'table-tennis',
[WorkoutActivityType.Tennis]: 'tennis',
[WorkoutActivityType.Volleyball]: 'volleyball',
[WorkoutActivityType.WaterPolo]: 'swim',
[WorkoutActivityType.Pickleball]: 'tennis',
// 水上运动
[WorkoutActivityType.Swimming]: 'swim',
[WorkoutActivityType.Sailing]: 'sail-boat',
[WorkoutActivityType.SurfingSports]: 'waves',
[WorkoutActivityType.WaterFitness]: 'swim',
[WorkoutActivityType.WaterSports]: 'swim',
[WorkoutActivityType.UnderwaterDiving]: 'swim',
// 跑步和步行
[WorkoutActivityType.Running]: 'run',
[WorkoutActivityType.Walking]: 'walk',
[WorkoutActivityType.Hiking]: 'hiking',
[WorkoutActivityType.StairClimbing]: 'stairs',
[WorkoutActivityType.Stairs]: 'stairs',
// 骑行
[WorkoutActivityType.Cycling]: 'bike',
[WorkoutActivityType.HandCycling]: 'bike',
// 滑雪和滑冰
[WorkoutActivityType.CrossCountrySkiing]: 'ski',
[WorkoutActivityType.DownhillSkiing]: 'ski',
[WorkoutActivityType.Snowboarding]: 'snowboard',
[WorkoutActivityType.SkatingSports]: 'skateboarding',
[WorkoutActivityType.SnowSports]: 'ski',
// 力量训练
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
[WorkoutActivityType.CrossTraining]: 'arm-flex',
[WorkoutActivityType.CoreTraining]: 'arm-flex',
// 有氧运动
[WorkoutActivityType.Elliptical]: 'bike',
[WorkoutActivityType.Rowing]: 'rowing',
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
[WorkoutActivityType.MixedMetabolicCardioTraining]: 'heart-pulse',
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
[WorkoutActivityType.JumpRope]: 'skip-forward',
[WorkoutActivityType.StepTraining]: 'stairs',
// 舞蹈和身心训练
[WorkoutActivityType.Dance]: 'music',
[WorkoutActivityType.DanceInspiredTraining]: 'music',
[WorkoutActivityType.CardioDance]: 'music',
[WorkoutActivityType.SocialDance]: 'music',
[WorkoutActivityType.Yoga]: 'meditation',
[WorkoutActivityType.MindAndBody]: 'meditation',
[WorkoutActivityType.TaiChi]: 'meditation',
[WorkoutActivityType.Pilates]: 'meditation',
[WorkoutActivityType.Barre]: 'meditation',
[WorkoutActivityType.Flexibility]: 'meditation',
[WorkoutActivityType.Cooldown]: 'meditation',
[WorkoutActivityType.PreparationAndRecovery]: 'meditation',
// 户外运动
[WorkoutActivityType.Climbing]: 'hiking',
[WorkoutActivityType.EquestrianSports]: 'horse',
[WorkoutActivityType.Fishing]: 'target',
[WorkoutActivityType.Hunting]: 'target',
[WorkoutActivityType.PaddleSports]: 'rowing',
// 综合运动
[WorkoutActivityType.SwimBikeRun]: 'run-fast',
[WorkoutActivityType.Transition]: 'swap-horizontal-variant',
[WorkoutActivityType.Play]: 'gamepad-variant',
[WorkoutActivityType.FitnessGaming]: 'gamepad-variant',
[WorkoutActivityType.DiscSports]: 'target',
// 其他
[WorkoutActivityType.Other]: 'arm-flex',
[WorkoutActivityType.MartialArts]: 'karate',
[WorkoutActivityType.Kickboxing]: 'boxing-glove',
[WorkoutActivityType.Gymnastics]: 'human',
[WorkoutActivityType.TrackAndField]: 'run-fast',
[WorkoutActivityType.WheelchairWalkPace]: 'wheelchair',
[WorkoutActivityType.WheelchairRunPace]: 'wheelchair',
[WorkoutActivityType.Curling]: 'target',
};
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
}
const minutes = Math.max(durationInSeconds / 60, 1);
const caloriesPerMinute = totalCalories / minutes;
if (caloriesPerMinute >= 9) {
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
}
if (caloriesPerMinute >= 5) {
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' };
}
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' };
}
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
const grouped = workouts.reduce<Record<string, WorkoutData[]>>((acc, workout) => {
const dateKey = dayjs(workout.startDate || workout.endDate).format('YYYY-MM-DD');
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(workout);
return acc;
}, {});
return Object.keys(grouped)
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
.map((dateKey) => ({
title: dayjs(dateKey).format('M月D日'),
data: grouped[dateKey]
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
}));
}
export default function WorkoutHistoryScreen() {
const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadHistory = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
let permissionStatus = getHealthPermissionStatus();
if (permissionStatus !== HealthPermissionStatus.Authorized) {
permissionStatus = await checkHealthPermissionStatus(true);
}
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
if (!hasPermission) {
hasPermission = await ensureHealthPermissions();
}
if (!hasPermission) {
setSections([]);
setError('尚未授予健康数据权限');
return;
}
const end = dayjs();
const start = end.subtract(1, 'month');
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
setSections(groupWorkouts(filteredWorkouts));
} catch (err) {
console.error('加载锻炼历史失败:', err);
setError('加载锻炼记录失败,请稍后再试');
setSections([]);
} finally {
setIsLoading(false);
}
}, []);
useFocusEffect(
useCallback(() => {
loadHistory();
}, [loadHistory])
);
React.useEffect(() => {
const handlePermissionGranted = () => {
loadHistory();
};
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
return () => {
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
};
}, [loadHistory]);
const headerComponent = useMemo(() => (
<View style={styles.headerContainer}>
<Text style={styles.headerTitle}></Text>
<Text style={styles.headerSubtitle}></Text>
</View>
), []);
const emptyComponent = useMemo(() => (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubText}></Text>
</View>
), []);
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0);
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0);
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
const time = dayjs(item.startDate || item.endDate).format('HH:mm');
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
return (
<TouchableOpacity style={styles.historyCard} activeOpacity={0.85} onPress={() => { }}>
<View style={styles.cardIconWrapper}>
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
</View>
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text style={styles.cardTitle}>{calories} · {minutes}</Text>
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
</View>
</View>
<Text style={styles.cardSubtitle}>{activityLabel}{time}</Text>
</View>
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
</TouchableOpacity>
);
}, []);
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
<Text style={styles.sectionHeader}>{section.title}</Text>
), []);
return (
<View style={styles.safeArea}>
<LinearGradient
colors={["#F3F5FF", "#FFFFFF"]}
style={StyleSheet.absoluteFill}
/>
<HeaderBar title="锻炼历史" variant="minimal" transparent={true} />
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#5C55FF" />
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<SectionList
style={styles.sectionList}
sections={sections}
keyExtractor={(item) => item.id}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
ListHeaderComponent={headerComponent}
ListEmptyComponent={error ? (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
<Text style={styles.retryText}></Text>
</TouchableOpacity>
</View>
) : emptyComponent}
contentContainerStyle={styles.listContent}
stickySectionHeadersEnabled={false}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: 'transparent',
},
sectionList: {
flex: 1,
},
headerContainer: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 16,
},
headerTitle: {
fontSize: 26,
fontWeight: '700',
color: '#1F2355',
marginBottom: 6,
},
headerSubtitle: {
fontSize: 14,
color: '#677086',
},
listContent: {
paddingBottom: 40,
},
sectionHeader: {
fontSize: 14,
color: '#8087A2',
fontWeight: '600',
marginTop: 18,
paddingHorizontal: 20,
},
historyCard: {
marginTop: 12,
marginHorizontal: 16,
paddingVertical: 18,
paddingHorizontal: 18,
backgroundColor: '#FFFFFF',
borderRadius: 26,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#5460E54D',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.1,
shadowRadius: 16,
elevation: 6,
},
cardIconWrapper: {
width: 46,
height: 46,
borderRadius: 23,
backgroundColor: '#EEF0FF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
},
cardContent: {
flex: 1,
},
cardTitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
color: '#1F2355',
flexShrink: 1,
},
intensityBadge: {
marginLeft: 8,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
intensityText: {
fontSize: 12,
fontWeight: '600',
},
cardSubtitle: {
marginTop: 8,
fontSize: 13,
color: '#6B7693',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
loadingText: {
fontSize: 14,
color: '#596182',
},
emptyContainer: {
marginTop: 60,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
gap: 12,
},
emptyText: {
fontSize: 16,
fontWeight: '600',
color: '#596182',
},
emptySubText: {
fontSize: 13,
color: '#8F96AF',
textAlign: 'center',
lineHeight: 18,
},
retryButton: {
marginTop: 8,
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: '#5C55FF',
},
retryText: {
color: '#FFFFFF',
fontSize: 13,
fontWeight: '600',
},
});