diff --git a/app.json b/app.json index 754cf57..226db94 100644 --- a/app.json +++ b/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", diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 5bbb8f1..81d0dce 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -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() { + + {/* 真正瀑布流布局 */} {/* 左列 */} @@ -422,7 +428,6 @@ export default function ExploreScreen() { - + > = { + // 球类运动 + [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>((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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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(() => ( + + 历史 + 最近一个月的锻炼记录 + + ), []); + + const emptyComponent = useMemo(() => ( + + + 暂无锻炼记录 + 完成一次锻炼后即可在此查看详细历史 + + ), []); + + 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 ( + { }}> + + + + + + + {calories}千卡 · {minutes}分钟 + + {intensity.label} + + + {activityLabel},{time} + + + {/* */} + + ); + }, []); + + const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => ( + {section.title} + ), []); + + return ( + + + + {isLoading ? ( + + + 正在加载锻炼记录... + + ) : ( + item.id} + renderItem={renderItem} + renderSectionHeader={renderSectionHeader} + ListHeaderComponent={headerComponent} + ListEmptyComponent={error ? ( + + + {error} + + 重试 + + + ) : emptyComponent} + contentContainerStyle={styles.listContent} + stickySectionHeadersEnabled={false} + showsVerticalScrollIndicator={false} + /> + )} + + ); +} + +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', + }, +}); diff --git a/assets/images/icons/icon-fitness.png b/assets/images/icons/icon-fitness.png new file mode 100644 index 0000000..bf76480 Binary files /dev/null and b/assets/images/icons/icon-fitness.png differ diff --git a/components/WorkoutSummaryCard.tsx b/components/WorkoutSummaryCard.tsx new file mode 100644 index 0000000..c4c17c9 --- /dev/null +++ b/components/WorkoutSummaryCard.tsx @@ -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> = { + [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 = ({ date, style }) => { + const router = useRouter(); + const [summary, setSummary] = useState(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(); + 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 ( + + + + + 健身 + + + + + + + 分钟 + + + + 千卡 + + + + + + {cardContent.label} + {cardContent.time} + {cardContent.source} + + + + {isLoading && } + {!isLoading && cardContent.badges.length === 0 && ( + + + + )} + + + + ); +}; + +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', + }, +}); diff --git a/components/weight/WeightHistoryCard.tsx b/components/weight/WeightHistoryCard.tsx index 8b80057..a7ce895 100644 --- a/components/weight/WeightHistoryCard.tsx +++ b/components/weight/WeightHistoryCard.tsx @@ -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, diff --git a/ios/OutLive.xcodeproj/project.pbxproj b/ios/OutLive.xcodeproj/project.pbxproj index 2bb6f2b..c582a9a 100644 --- a/ios/OutLive.xcodeproj/project.pbxproj +++ b/ios/OutLive.xcodeproj/project.pbxproj @@ -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 = ""; }; + 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 = ""; }; 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 = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = ""; }; 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = ""; }; - 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 = ""; }; + 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 = ""; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = ""; }; + 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 = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = ""; }; B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; - 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 = ""; }; F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = ""; }; @@ -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 = ""; }; - 7B63456AB81271603E0039A3 /* Pods */ = { - isa = PBXGroup; - children = ( - 4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */, - 0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; 80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = { isa = PBXGroup; children = ( @@ -107,7 +97,7 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */, - 7B63456AB81271603E0039A3 /* Pods */, + D049F514815CB726258DD27E /* Pods */, ); indentWidth = 2; sourceTree = ""; @@ -139,6 +129,16 @@ name = OutLive; sourceTree = ""; }; + D049F514815CB726258DD27E /* Pods */ = { + isa = PBXGroup; + children = ( + 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */, + 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* 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; diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m index b9f4339..b467506 100644 --- a/ios/OutLive/HealthKitManager.m +++ b/ios/OutLive/HealthKitManager.m @@ -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 \ No newline at end of file diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index e1d74fb..a587636 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -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 { - 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 diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index cab4d11..02ded79 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -25,7 +25,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.15 + 1.0.16 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts index fb2d0a2..5512fc3 100644 --- a/services/backgroundTaskManager.ts +++ b/services/backgroundTaskManager.ts @@ -125,6 +125,8 @@ async function executeChallengeReminderTask(): Promise { 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 { 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('挑战鼓励提醒后台任务完成'); diff --git a/utils/health.ts b/utils/health.ts index 63d4162..d65be41 100644 --- a/utils/health.ts +++ b/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; +} + +// 锻炼记录查询选项 +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 { } } +// === 锻炼记录相关方法 === + +// 获取最近锻炼记录 +export async function fetchRecentWorkouts(options?: Partial): Promise { + 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 { + const options: WorkoutOptions = { + startDate: dayjs(startDate).startOf('day').toISOString(), + endDate: dayjs(endDate).endOf('day').toISOString(), + limit + }; + + return fetchRecentWorkouts(options); +} + +// 获取今日锻炼记录 +export async function fetchTodayWorkouts(): Promise { + const today = dayjs(); + return fetchWorkoutsForDateRange(today.toDate(), today.toDate(), 20); +} + +// 获取本周锻炼记录 +export async function fetchThisWeekWorkouts(): Promise { + const today = dayjs(); + const startOfWeek = today.startOf('week'); + return fetchWorkoutsForDateRange(startOfWeek.toDate(), today.toDate(), 50); +} + +// 获取本月锻炼记录 +export async function fetchThisMonthWorkouts(): Promise { + 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; // 各类型锻炼次数 +} { + const stats = { + totalWorkouts: workouts.length, + totalDuration: 0, + totalEnergyBurned: 0, + totalDistance: 0, + averageDuration: 0, + workoutTypes: {} as Record + }; + + 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 = { + 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 { + 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;