feat(workout): 新增锻炼历史记录功能与健康数据集成
- 新增锻炼历史页面,展示最近一个月的锻炼记录详情 - 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据 - 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息 - 完善锻炼数据处理工具,包含统计分析和格式化功能 - 优化后台任务,随机选择挑战发送鼓励通知 - 版本升级至1.0.16
This commit is contained in:
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.16",
|
||||
"orientation": "portrait",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
|
||||
@@ -10,6 +10,7 @@ import StepsCard from '@/components/StepsCard';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||
import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
@@ -400,6 +401,11 @@ export default function ExploreScreen() {
|
||||
|
||||
<WeightHistoryCard />
|
||||
|
||||
<WorkoutSummaryCard
|
||||
date={currentSelectedDate}
|
||||
style={styles.workoutCardOverride}
|
||||
/>
|
||||
|
||||
{/* 真正瀑布流布局 */}
|
||||
<View style={styles.masonryContainer}>
|
||||
{/* 左列 */}
|
||||
@@ -422,7 +428,6 @@ export default function ExploreScreen() {
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<StressMeter
|
||||
curDate={currentSelectedDate}
|
||||
@@ -817,6 +822,9 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
height: '100%', // 填充整个masonryCard
|
||||
},
|
||||
workoutCardOverride: {
|
||||
marginTop: 16,
|
||||
},
|
||||
waterCardOverride: {
|
||||
margin: -16, // 抵消 masonryCard 的 padding
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -17,6 +17,13 @@ export default function WorkoutLayout() {
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="history"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'card',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="session/[id]"
|
||||
options={{
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
BIN
assets/images/icons/icon-fitness.png
Normal file
BIN
assets/images/icons/icon-fitness.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
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,
|
||||
|
||||
@@ -10,28 +10,28 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */; };
|
||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
||||
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; };
|
||||
AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OutLive/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = "<group>"; };
|
||||
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
6F6136AA7113B3D210693D88 /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
@@ -42,7 +42,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */,
|
||||
AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -67,21 +67,11 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */,
|
||||
6F6136AA7113B3D210693D88 /* libPods-OutLive.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7B63456AB81271603E0039A3 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */,
|
||||
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -107,7 +97,7 @@
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */,
|
||||
7B63456AB81271603E0039A3 /* Pods */,
|
||||
D049F514815CB726258DD27E /* Pods */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
@@ -139,6 +129,16 @@
|
||||
name = OutLive;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D049F514815CB726258DD27E /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */,
|
||||
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -146,14 +146,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */;
|
||||
buildPhases = (
|
||||
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */,
|
||||
1EB539808FAFD6C62AD21A7F /* [CP] Check Pods Manifest.lock */,
|
||||
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */,
|
||||
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */,
|
||||
8F598744CAFFA386101BCC07 /* [CP] Embed Pods Frameworks */,
|
||||
54EB3ED8CF242B308A7FE01E /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -227,7 +227,7 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||
};
|
||||
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */ = {
|
||||
1EB539808FAFD6C62AD21A7F /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -249,7 +249,7 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */ = {
|
||||
54EB3ED8CF242B308A7FE01E /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -301,7 +301,7 @@
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */ = {
|
||||
8F598744CAFFA386101BCC07 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -367,7 +367,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */;
|
||||
baseConfigurationReference = 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
@@ -404,7 +404,7 @@
|
||||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */;
|
||||
baseConfigurationReference = 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
||||
@@ -77,4 +77,9 @@ RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
// Workout Data Methods
|
||||
RCT_EXTERN_METHOD(getRecentWorkouts:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
@end
|
||||
@@ -37,9 +37,14 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!
|
||||
static let activitySummary = HKObjectType.activitySummaryType()
|
||||
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
|
||||
static let workout = HKObjectType.workoutType()
|
||||
|
||||
static var all: Set<HKObjectType> {
|
||||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater]
|
||||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
|
||||
}
|
||||
|
||||
static var workoutType: HKWorkoutType {
|
||||
return HKObjectType.workoutType()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +562,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
|
||||
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: startDateComponents, end: endDateComponents)
|
||||
|
||||
let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] (query, summaries, error) in
|
||||
let query = HKActivitySummaryQuery(predicate: predicate) { (query, summaries, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("QUERY_ERROR", "Failed to query activity summary: \(error.localizedDescription)", error)
|
||||
@@ -673,7 +678,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
let result: [String: Any] = [
|
||||
"data": hrvData,
|
||||
"count": hrvData.count,
|
||||
"bestQualityValue": bestQualityValue,
|
||||
"bestQualityValue": bestQualityValue ?? NSNull(),
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
@@ -1577,4 +1582,125 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Workout Data Methods
|
||||
|
||||
@objc
|
||||
func getRecentWorkouts(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let workoutType = ReadTypes.workoutType
|
||||
|
||||
// Parse options
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
// 默认获取最近30天的锻炼记录
|
||||
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||
endDate = d
|
||||
} else {
|
||||
endDate = Date()
|
||||
}
|
||||
|
||||
let limit = options["limit"] as? Int ?? 10 // 默认返回最近10条记录
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
||||
|
||||
let query = HKSampleQuery(sampleType: ReadTypes.workoutType,
|
||||
predicate: predicate,
|
||||
limit: limit,
|
||||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("QUERY_ERROR", "Failed to query workouts: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let workoutSamples = samples as? [HKWorkout] else {
|
||||
resolver([
|
||||
"data": [],
|
||||
"count": 0,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let workoutData = workoutSamples.map { workout in
|
||||
var workoutDict: [String: Any] = [
|
||||
"id": workout.uuid.uuidString,
|
||||
"startDate": self?.dateToISOString(workout.startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(workout.endDate) ?? "",
|
||||
"duration": workout.duration,
|
||||
"workoutActivityType": workout.workoutActivityType.rawValue,
|
||||
"workoutActivityTypeString": self?.workoutActivityTypeToString(workout.workoutActivityType) ?? "unknown",
|
||||
"source": [
|
||||
"name": workout.sourceRevision.source.name,
|
||||
"bundleIdentifier": workout.sourceRevision.source.bundleIdentifier
|
||||
],
|
||||
"metadata": workout.metadata ?? [:]
|
||||
]
|
||||
|
||||
// 添加能量消耗信息(如果有)
|
||||
if let totalEnergyBurned = workout.totalEnergyBurned {
|
||||
workoutDict["totalEnergyBurned"] = totalEnergyBurned.doubleValue(for: HKUnit.kilocalorie())
|
||||
}
|
||||
|
||||
// 添加距离信息(如果有)
|
||||
if let totalDistance = workout.totalDistance {
|
||||
workoutDict["totalDistance"] = totalDistance.doubleValue(for: HKUnit.meter())
|
||||
}
|
||||
|
||||
// 添加平均心率信息(如果有)
|
||||
if let averageHeartRate = workout.metadata?["HKAverageHeartRate"] as? Double {
|
||||
workoutDict["averageHeartRate"] = averageHeartRate
|
||||
}
|
||||
|
||||
return workoutDict
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"data": workoutData,
|
||||
"count": workoutData.count,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
resolver(result)
|
||||
}
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Workout Helper Methods
|
||||
|
||||
// Normalizes the HealthKit enum case so JS receives a predictable camelCase identifier.
|
||||
private func workoutActivityTypeToString(_ workoutActivityType: HKWorkoutActivityType) -> String {
|
||||
let description = String(describing: workoutActivityType)
|
||||
let prefix = "HKWorkoutActivityType"
|
||||
|
||||
if description.hasPrefix(prefix) {
|
||||
let rawName = description.dropFirst(prefix.count)
|
||||
guard let first = rawName.first else {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
let normalized = String(first).lowercased() + rawName.dropFirst()
|
||||
return normalized
|
||||
}
|
||||
|
||||
return description.lowercased()
|
||||
}
|
||||
|
||||
} // end class
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.15</string>
|
||||
<string>1.0.16</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -125,6 +125,8 @@ async function executeChallengeReminderTask(): Promise<void> {
|
||||
|
||||
const todayKey = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 筛选出需要发送通知的挑战(未签到且今天未发送过通知)
|
||||
const eligibleChallenges = [];
|
||||
for (const challenge of joinedChallenges) {
|
||||
const progress = challenge.progress;
|
||||
if (!progress || progress.checkedInToday) {
|
||||
@@ -137,17 +139,30 @@ async function executeChallengeReminderTask(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
eligibleChallenges.push(challenge);
|
||||
}
|
||||
|
||||
// 如果有符合条件的挑战,随机选择一个发送通知
|
||||
if (eligibleChallenges.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * eligibleChallenges.length);
|
||||
const selectedChallenge = eligibleChallenges[randomIndex];
|
||||
|
||||
try {
|
||||
await ChallengeNotificationHelpers.sendEncouragementNotification({
|
||||
userName,
|
||||
challengeTitle: challenge.title,
|
||||
challengeId: challenge.id,
|
||||
challengeTitle: selectedChallenge.title,
|
||||
challengeId: selectedChallenge.id,
|
||||
});
|
||||
|
||||
const storageKey = `@challenge_encouragement_sent:${selectedChallenge.id}`;
|
||||
await AsyncStorage.setItem(storageKey, todayKey);
|
||||
|
||||
console.log(`已随机选择并发送挑战鼓励通知: ${selectedChallenge.title}`);
|
||||
} catch (notificationError) {
|
||||
console.error('发送挑战鼓励通知失败:', notificationError);
|
||||
}
|
||||
} else {
|
||||
console.log('没有符合条件的挑战需要发送鼓励通知');
|
||||
}
|
||||
|
||||
console.log('挑战鼓励提醒后台任务完成');
|
||||
|
||||
420
utils/health.ts
420
utils/health.ts
@@ -5,8 +5,120 @@ import { SimpleEventEmitter } from './SimpleEventEmitter';
|
||||
type HealthDataOptions = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
// 锻炼数据类型定义
|
||||
export interface WorkoutData {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
duration: number; // 秒
|
||||
workoutActivityType: number;
|
||||
workoutActivityTypeString: string;
|
||||
totalEnergyBurned?: number; // 千卡
|
||||
totalDistance?: number; // 米
|
||||
averageHeartRate?: number;
|
||||
source: {
|
||||
name: string;
|
||||
bundleIdentifier: string;
|
||||
};
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// 锻炼记录查询选项
|
||||
export interface WorkoutOptions extends HealthDataOptions {
|
||||
limit?: number; // 默认10条
|
||||
}
|
||||
|
||||
// 锻炼活动类型枚举
|
||||
export enum WorkoutActivityType {
|
||||
AmericanFootball = 1,
|
||||
Archery = 2,
|
||||
AustralianFootball = 3,
|
||||
Badminton = 4,
|
||||
Baseball = 5,
|
||||
Basketball = 6,
|
||||
Bowling = 7,
|
||||
Boxing = 8,
|
||||
Climbing = 9,
|
||||
Cricket = 10,
|
||||
CrossTraining = 11,
|
||||
Curling = 12,
|
||||
Cycling = 13,
|
||||
Dance = 14,
|
||||
DanceInspiredTraining = 15,
|
||||
Elliptical = 16,
|
||||
EquestrianSports = 17,
|
||||
Fencing = 18,
|
||||
Fishing = 19,
|
||||
FunctionalStrengthTraining = 20,
|
||||
Golf = 21,
|
||||
Gymnastics = 22,
|
||||
Handball = 23,
|
||||
Hiking = 24,
|
||||
Hockey = 25,
|
||||
Hunting = 26,
|
||||
Lacrosse = 27,
|
||||
MartialArts = 28,
|
||||
MindAndBody = 29,
|
||||
MixedMetabolicCardioTraining = 30,
|
||||
PaddleSports = 31,
|
||||
Play = 32,
|
||||
PreparationAndRecovery = 33,
|
||||
Racquetball = 34,
|
||||
Rowing = 35,
|
||||
Rugby = 36,
|
||||
Running = 37,
|
||||
Sailing = 38,
|
||||
SkatingSports = 39,
|
||||
SnowSports = 40,
|
||||
Soccer = 41,
|
||||
Softball = 42,
|
||||
Squash = 43,
|
||||
StairClimbing = 44,
|
||||
SurfingSports = 45,
|
||||
Swimming = 46,
|
||||
TableTennis = 47,
|
||||
Tennis = 48,
|
||||
TrackAndField = 49,
|
||||
TraditionalStrengthTraining = 50,
|
||||
Volleyball = 51,
|
||||
Walking = 52,
|
||||
WaterFitness = 53,
|
||||
WaterPolo = 54,
|
||||
WaterSports = 55,
|
||||
Wrestling = 56,
|
||||
Yoga = 57,
|
||||
Barre = 58,
|
||||
CoreTraining = 59,
|
||||
CrossCountrySkiing = 60,
|
||||
DownhillSkiing = 61,
|
||||
Flexibility = 62,
|
||||
HighIntensityIntervalTraining = 63,
|
||||
JumpRope = 64,
|
||||
Kickboxing = 65,
|
||||
Pilates = 66,
|
||||
Snowboarding = 67,
|
||||
Stairs = 68,
|
||||
StepTraining = 69,
|
||||
WheelchairWalkPace = 70,
|
||||
WheelchairRunPace = 71,
|
||||
TaiChi = 72,
|
||||
MixedCardio = 73,
|
||||
HandCycling = 74,
|
||||
DiscSports = 75,
|
||||
FitnessGaming = 76,
|
||||
CardioDance = 77,
|
||||
SocialDance = 78,
|
||||
Pickleball = 79,
|
||||
Cooldown = 80,
|
||||
SwimBikeRun = 82,
|
||||
Transition = 83,
|
||||
UnderwaterDiving = 84,
|
||||
Other = 3000
|
||||
}
|
||||
|
||||
// React Native bridge to native HealthKitManager
|
||||
const { HealthKitManager } = NativeModules;
|
||||
|
||||
@@ -1317,6 +1429,314 @@ export async function fetchSmartHRVData(date: Date): Promise<HRVData | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// === 锻炼记录相关方法 ===
|
||||
|
||||
// 获取最近锻炼记录
|
||||
export async function fetchRecentWorkouts(options?: Partial<WorkoutOptions>): Promise<WorkoutData[]> {
|
||||
try {
|
||||
console.log('开始获取最近锻炼记录...', options);
|
||||
|
||||
// 设置默认选项
|
||||
const defaultOptions: WorkoutOptions = {
|
||||
startDate: dayjs().subtract(30, 'day').startOf('day').toISOString(),
|
||||
endDate: dayjs().endOf('day').toISOString(),
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
const result = await HealthKitManager.getRecentWorkouts(finalOptions);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data)) {
|
||||
logSuccess('锻炼记录', result);
|
||||
|
||||
// 验证和处理返回的数据
|
||||
const validatedWorkouts: WorkoutData[] = result.data
|
||||
.filter((workout: any) => {
|
||||
// 基本数据验证
|
||||
return workout &&
|
||||
workout.id &&
|
||||
workout.startDate &&
|
||||
workout.endDate &&
|
||||
workout.duration !== undefined;
|
||||
})
|
||||
.map((workout: any) => ({
|
||||
id: workout.id,
|
||||
startDate: workout.startDate,
|
||||
endDate: workout.endDate,
|
||||
duration: workout.duration,
|
||||
workoutActivityType: workout.workoutActivityType || 0,
|
||||
workoutActivityTypeString: workout.workoutActivityTypeString || 'unknown',
|
||||
totalEnergyBurned: workout.totalEnergyBurned,
|
||||
totalDistance: workout.totalDistance,
|
||||
averageHeartRate: workout.averageHeartRate,
|
||||
source: {
|
||||
name: workout.source?.name || 'Unknown',
|
||||
bundleIdentifier: workout.source?.bundleIdentifier || ''
|
||||
},
|
||||
metadata: workout.metadata || {}
|
||||
}));
|
||||
|
||||
console.log(`成功获取 ${validatedWorkouts.length} 条锻炼记录`);
|
||||
return validatedWorkouts;
|
||||
} else {
|
||||
logWarning('锻炼记录', '为空或格式错误');
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logError('锻炼记录', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取指定日期范围内的锻炼记录
|
||||
export async function fetchWorkoutsForDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
limit: number = 10
|
||||
): Promise<WorkoutData[]> {
|
||||
const options: WorkoutOptions = {
|
||||
startDate: dayjs(startDate).startOf('day').toISOString(),
|
||||
endDate: dayjs(endDate).endOf('day').toISOString(),
|
||||
limit
|
||||
};
|
||||
|
||||
return fetchRecentWorkouts(options);
|
||||
}
|
||||
|
||||
// 获取今日锻炼记录
|
||||
export async function fetchTodayWorkouts(): Promise<WorkoutData[]> {
|
||||
const today = dayjs();
|
||||
return fetchWorkoutsForDateRange(today.toDate(), today.toDate(), 20);
|
||||
}
|
||||
|
||||
// 获取本周锻炼记录
|
||||
export async function fetchThisWeekWorkouts(): Promise<WorkoutData[]> {
|
||||
const today = dayjs();
|
||||
const startOfWeek = today.startOf('week');
|
||||
return fetchWorkoutsForDateRange(startOfWeek.toDate(), today.toDate(), 50);
|
||||
}
|
||||
|
||||
// 获取本月锻炼记录
|
||||
export async function fetchThisMonthWorkouts(): Promise<WorkoutData[]> {
|
||||
const today = dayjs();
|
||||
const startOfMonth = today.startOf('month');
|
||||
return fetchWorkoutsForDateRange(startOfMonth.toDate(), today.toDate(), 100);
|
||||
}
|
||||
|
||||
// 根据锻炼类型筛选锻炼记录
|
||||
export function filterWorkoutsByType(
|
||||
workouts: WorkoutData[],
|
||||
workoutType: WorkoutActivityType
|
||||
): WorkoutData[] {
|
||||
return workouts.filter(workout => workout.workoutActivityType === workoutType);
|
||||
}
|
||||
|
||||
// 获取锻炼统计信息
|
||||
export function getWorkoutStatistics(workouts: WorkoutData[]): {
|
||||
totalWorkouts: number;
|
||||
totalDuration: number; // 秒
|
||||
totalEnergyBurned: number; // 千卡
|
||||
totalDistance: number; // 米
|
||||
averageDuration: number; // 秒
|
||||
workoutTypes: Record<string, number>; // 各类型锻炼次数
|
||||
} {
|
||||
const stats = {
|
||||
totalWorkouts: workouts.length,
|
||||
totalDuration: 0,
|
||||
totalEnergyBurned: 0,
|
||||
totalDistance: 0,
|
||||
averageDuration: 0,
|
||||
workoutTypes: {} as Record<string, number>
|
||||
};
|
||||
|
||||
workouts.forEach(workout => {
|
||||
stats.totalDuration += workout.duration;
|
||||
stats.totalEnergyBurned += workout.totalEnergyBurned || 0;
|
||||
stats.totalDistance += workout.totalDistance || 0;
|
||||
|
||||
// 统计锻炼类型
|
||||
const typeString = workout.workoutActivityTypeString;
|
||||
stats.workoutTypes[typeString] = (stats.workoutTypes[typeString] || 0) + 1;
|
||||
});
|
||||
|
||||
if (stats.totalWorkouts > 0) {
|
||||
stats.averageDuration = Math.round(stats.totalDuration / stats.totalWorkouts);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// 格式化锻炼持续时间
|
||||
export function formatWorkoutDuration(durationInSeconds: number): string {
|
||||
const hours = Math.floor(durationInSeconds / 3600);
|
||||
const minutes = Math.floor((durationInSeconds % 3600) / 60);
|
||||
const seconds = durationInSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes}分钟`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分钟${seconds}秒`;
|
||||
} else {
|
||||
return `${seconds}秒`;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化锻炼距离
|
||||
export function formatWorkoutDistance(distanceInMeters: number): string {
|
||||
if (distanceInMeters >= 1000) {
|
||||
return `${(distanceInMeters / 1000).toFixed(2)}公里`;
|
||||
} else {
|
||||
return `${Math.round(distanceInMeters)}米`;
|
||||
}
|
||||
}
|
||||
|
||||
const WORKOUT_TYPE_LABELS: Record<string, string> = {
|
||||
running: '跑步',
|
||||
walking: '步行',
|
||||
cycling: '骑行',
|
||||
swimming: '游泳',
|
||||
yoga: '瑜伽',
|
||||
functionalstrengthtraining: '功能性力量训练',
|
||||
traditionalstrengthtraining: '传统力量训练',
|
||||
crosstraining: '交叉训练',
|
||||
mixedcardio: '混合有氧',
|
||||
highintensityintervaltraining: '高强度间歇训练',
|
||||
flexibility: '柔韧性训练',
|
||||
cooldown: '放松运动',
|
||||
pilates: '普拉提',
|
||||
dance: '舞蹈',
|
||||
danceinspiredtraining: '舞蹈训练',
|
||||
cardiodance: '有氧舞蹈',
|
||||
socialdance: '社交舞',
|
||||
swimbikerun: '铁人三项',
|
||||
transition: '项目转换',
|
||||
underwaterdiving: '水下潜水',
|
||||
pickleball: '匹克球',
|
||||
americanfootball: '美式橄榄球',
|
||||
badminton: '羽毛球',
|
||||
baseball: '棒球',
|
||||
basketball: '篮球',
|
||||
tennis: '网球',
|
||||
tabletennis: '乒乓球',
|
||||
functionalStrengthTraining: '功能性力量训练',
|
||||
other: '其他运动',
|
||||
};
|
||||
|
||||
function humanizeWorkoutTypeKey(raw: string | undefined): string {
|
||||
if (!raw) {
|
||||
return '其他运动';
|
||||
}
|
||||
|
||||
const cleaned = raw
|
||||
.replace(/^HKWorkoutActivityType/i, '')
|
||||
.replace(/[_\-]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!cleaned) {
|
||||
return '其他运动';
|
||||
}
|
||||
|
||||
const withSpaces = cleaned.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
||||
const words = withSpaces
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
// 获取锻炼类型的显示名称
|
||||
export function getWorkoutTypeDisplayName(workoutType: WorkoutActivityType | string): string {
|
||||
if (typeof workoutType === 'string') {
|
||||
const normalized = workoutType.replace(/\s+/g, '').toLowerCase();
|
||||
return WORKOUT_TYPE_LABELS[normalized] || humanizeWorkoutTypeKey(workoutType);
|
||||
}
|
||||
|
||||
switch (workoutType) {
|
||||
case WorkoutActivityType.Running:
|
||||
return '跑步';
|
||||
case WorkoutActivityType.Cycling:
|
||||
return '骑行';
|
||||
case WorkoutActivityType.Walking:
|
||||
return '步行';
|
||||
case WorkoutActivityType.Swimming:
|
||||
return '游泳';
|
||||
case WorkoutActivityType.Yoga:
|
||||
return '瑜伽';
|
||||
case WorkoutActivityType.FunctionalStrengthTraining:
|
||||
return '功能性力量训练';
|
||||
case WorkoutActivityType.TraditionalStrengthTraining:
|
||||
return '传统力量训练';
|
||||
case WorkoutActivityType.CrossTraining:
|
||||
return '交叉训练';
|
||||
case WorkoutActivityType.MixedCardio:
|
||||
return '混合有氧';
|
||||
case WorkoutActivityType.HighIntensityIntervalTraining:
|
||||
return '高强度间歇训练';
|
||||
case WorkoutActivityType.Flexibility:
|
||||
return '柔韧性训练';
|
||||
case WorkoutActivityType.Cooldown:
|
||||
return '放松运动';
|
||||
case WorkoutActivityType.Tennis:
|
||||
return '网球';
|
||||
case WorkoutActivityType.Other:
|
||||
return '其他运动';
|
||||
default:
|
||||
return humanizeWorkoutTypeKey(WorkoutActivityType[workoutType]);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试锻炼记录获取功能
|
||||
export async function testWorkoutDataFetch(): Promise<void> {
|
||||
console.log('=== 开始测试锻炼记录获取 ===');
|
||||
|
||||
try {
|
||||
// 确保权限
|
||||
const hasPermission = await ensureHealthPermissions();
|
||||
if (!hasPermission) {
|
||||
console.error('没有健康数据权限,无法测试锻炼记录');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('权限检查通过,开始获取锻炼记录...');
|
||||
|
||||
// 测试获取最近锻炼记录
|
||||
console.log('--- 测试获取最近锻炼记录 ---');
|
||||
const recentWorkouts = await fetchRecentWorkouts();
|
||||
console.log(`获取到 ${recentWorkouts.length} 条最近锻炼记录`);
|
||||
|
||||
recentWorkouts.forEach((workout, index) => {
|
||||
console.log(`锻炼 ${index + 1}:`, {
|
||||
类型: getWorkoutTypeDisplayName(workout.workoutActivityTypeString),
|
||||
持续时间: formatWorkoutDuration(workout.duration),
|
||||
能量消耗: workout.totalEnergyBurned ? `${workout.totalEnergyBurned}千卡` : '无',
|
||||
距离: workout.totalDistance ? formatWorkoutDistance(workout.totalDistance) : '无',
|
||||
开始时间: workout.startDate,
|
||||
数据来源: workout.source.name
|
||||
});
|
||||
});
|
||||
|
||||
// 测试统计功能
|
||||
if (recentWorkouts.length > 0) {
|
||||
console.log('--- 锻炼统计信息 ---');
|
||||
const stats = getWorkoutStatistics(recentWorkouts);
|
||||
console.log('统计结果:', {
|
||||
总锻炼次数: stats.totalWorkouts,
|
||||
总持续时间: formatWorkoutDuration(stats.totalDuration),
|
||||
总能量消耗: `${stats.totalEnergyBurned}千卡`,
|
||||
总距离: formatWorkoutDistance(stats.totalDistance),
|
||||
平均持续时间: formatWorkoutDuration(stats.averageDuration),
|
||||
锻炼类型分布: stats.workoutTypes
|
||||
});
|
||||
}
|
||||
|
||||
console.log('=== 锻炼记录测试完成 ===');
|
||||
} catch (error) {
|
||||
console.error('锻炼记录测试过程中出现错误:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取HRV数据并附带详细的状态信息
|
||||
export async function fetchHRVWithStatus(date: Date): Promise<{
|
||||
hrvData: HRVData | null;
|
||||
|
||||
Reference in New Issue
Block a user