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;