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