From 79ddd41a496b38d75ce68e3c295597d97d125a9f Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 2 Oct 2025 22:13:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(workout):=20=E6=96=B0=E5=A2=9E=E9=94=BB?= =?UTF-8?q?=E7=82=BC=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=B8=8E=E5=81=A5=E5=BA=B7=E6=95=B0=E6=8D=AE=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增锻炼历史页面,展示最近一个月的锻炼记录详情 - 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据 - 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息 - 完善锻炼数据处理工具,包含统计分析和格式化功能 - 优化后台任务,随机选择挑战发送鼓励通知 - 版本升级至1.0.16 --- app.json | 2 +- app/(tabs)/statistics.tsx | 10 +- app/workout/_layout.tsx | 7 + app/workout/history.tsx | 447 ++++++++++++++++++++++++ assets/images/icons/icon-fitness.png | Bin 0 -> 25233 bytes components/WorkoutSummaryCard.tsx | 376 ++++++++++++++++++++ components/weight/WeightHistoryCard.tsx | 1 - ios/OutLive.xcodeproj/project.pbxproj | 50 +-- ios/OutLive/HealthKitManager.m | 5 + ios/OutLive/HealthKitManager.swift | 132 ++++++- ios/OutLive/Info.plist | 2 +- services/backgroundTaskManager.ts | 19 +- utils/health.ts | 420 ++++++++++++++++++++++ 13 files changed, 1437 insertions(+), 34 deletions(-) create mode 100644 app/workout/history.tsx create mode 100644 assets/images/icons/icon-fitness.png create mode 100644 components/WorkoutSummaryCard.tsx 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 0000000000000000000000000000000000000000..bf764803430802ef37663be3379184db342c684e GIT binary patch literal 25233 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 = { + [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;