feat(workout): 新增锻炼历史记录功能与健康数据集成

- 新增锻炼历史页面,展示最近一个月的锻炼记录详情
- 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据
- 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息
- 完善锻炼数据处理工具,包含统计分析和格式化功能
- 优化后台任务,随机选择挑战发送鼓励通知
- 版本升级至1.0.16
This commit is contained in:
2025-10-02 22:13:59 +08:00
parent 303c36025b
commit 79ddd41a49
13 changed files with 1437 additions and 34 deletions

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Out Live", "name": "Out Live",
"slug": "digital-pilates", "slug": "digital-pilates",
"version": "1.0.15", "version": "1.0.16",
"orientation": "portrait", "orientation": "portrait",
"scheme": "digitalpilates", "scheme": "digitalpilates",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",

View File

@@ -10,6 +10,7 @@ import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter'; import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard'; import WaterIntakeCard from '@/components/WaterIntakeCard';
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard'; import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -400,6 +401,11 @@ export default function ExploreScreen() {
<WeightHistoryCard /> <WeightHistoryCard />
<WorkoutSummaryCard
date={currentSelectedDate}
style={styles.workoutCardOverride}
/>
{/* 真正瀑布流布局 */} {/* 真正瀑布流布局 */}
<View style={styles.masonryContainer}> <View style={styles.masonryContainer}>
{/* 左列 */} {/* 左列 */}
@@ -422,7 +428,6 @@ export default function ExploreScreen() {
</FloatingCard> </FloatingCard>
<FloatingCard style={styles.masonryCard}> <FloatingCard style={styles.masonryCard}>
<StressMeter <StressMeter
curDate={currentSelectedDate} curDate={currentSelectedDate}
@@ -817,6 +822,9 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
height: '100%', // 填充整个masonryCard height: '100%', // 填充整个masonryCard
}, },
workoutCardOverride: {
marginTop: 16,
},
waterCardOverride: { waterCardOverride: {
margin: -16, // 抵消 masonryCard 的 padding margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16, borderRadius: 16,

View File

@@ -17,6 +17,13 @@ export default function WorkoutLayout() {
presentation: 'modal', presentation: 'modal',
}} }}
/> />
<Stack.Screen
name="history"
options={{
headerShown: false,
presentation: 'card',
}}
/>
<Stack.Screen <Stack.Screen
name="session/[id]" name="session/[id]"
options={{ options={{

447
app/workout/history.tsx Normal file
View File

@@ -0,0 +1,447 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
SectionList,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import {
addHealthPermissionListener,
checkHealthPermissionStatus,
ensureHealthPermissions,
fetchWorkoutsForDateRange,
getHealthPermissionStatus,
getWorkoutTypeDisplayName,
HealthPermissionStatus,
removeHealthPermissionListener,
WorkoutActivityType,
WorkoutData,
} from '@/utils/health';
type WorkoutSection = {
title: string;
data: WorkoutData[];
};
const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
// 球类运动
[WorkoutActivityType.AmericanFootball]: 'football',
[WorkoutActivityType.Archery]: 'target',
[WorkoutActivityType.AustralianFootball]: 'football',
[WorkoutActivityType.Badminton]: 'tennis',
[WorkoutActivityType.Baseball]: 'baseball',
[WorkoutActivityType.Basketball]: 'basketball',
[WorkoutActivityType.Bowling]: 'bowling',
[WorkoutActivityType.Boxing]: 'boxing-glove',
[WorkoutActivityType.Cricket]: 'cricket',
[WorkoutActivityType.Fencing]: 'sword',
[WorkoutActivityType.Golf]: 'golf',
[WorkoutActivityType.Handball]: 'basketball',
[WorkoutActivityType.Hockey]: 'hockey-sticks',
[WorkoutActivityType.Lacrosse]: 'tennis',
[WorkoutActivityType.Racquetball]: 'tennis',
[WorkoutActivityType.Soccer]: 'soccer',
[WorkoutActivityType.Softball]: 'baseball',
[WorkoutActivityType.Squash]: 'tennis',
[WorkoutActivityType.TableTennis]: 'table-tennis',
[WorkoutActivityType.Tennis]: 'tennis',
[WorkoutActivityType.Volleyball]: 'volleyball',
[WorkoutActivityType.WaterPolo]: 'swim',
[WorkoutActivityType.Pickleball]: 'tennis',
// 水上运动
[WorkoutActivityType.Swimming]: 'swim',
[WorkoutActivityType.Sailing]: 'sail-boat',
[WorkoutActivityType.SurfingSports]: 'waves',
[WorkoutActivityType.WaterFitness]: 'swim',
[WorkoutActivityType.WaterSports]: 'swim',
[WorkoutActivityType.UnderwaterDiving]: 'swim',
// 跑步和步行
[WorkoutActivityType.Running]: 'run',
[WorkoutActivityType.Walking]: 'walk',
[WorkoutActivityType.Hiking]: 'hiking',
[WorkoutActivityType.StairClimbing]: 'stairs',
[WorkoutActivityType.Stairs]: 'stairs',
// 骑行
[WorkoutActivityType.Cycling]: 'bike',
[WorkoutActivityType.HandCycling]: 'bike',
// 滑雪和滑冰
[WorkoutActivityType.CrossCountrySkiing]: 'ski',
[WorkoutActivityType.DownhillSkiing]: 'ski',
[WorkoutActivityType.Snowboarding]: 'snowboard',
[WorkoutActivityType.SkatingSports]: 'skateboarding',
[WorkoutActivityType.SnowSports]: 'ski',
// 力量训练
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
[WorkoutActivityType.CrossTraining]: 'arm-flex',
[WorkoutActivityType.CoreTraining]: 'arm-flex',
// 有氧运动
[WorkoutActivityType.Elliptical]: 'bike',
[WorkoutActivityType.Rowing]: 'rowing',
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
[WorkoutActivityType.MixedMetabolicCardioTraining]: 'heart-pulse',
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
[WorkoutActivityType.JumpRope]: 'skip-forward',
[WorkoutActivityType.StepTraining]: 'stairs',
// 舞蹈和身心训练
[WorkoutActivityType.Dance]: 'music',
[WorkoutActivityType.DanceInspiredTraining]: 'music',
[WorkoutActivityType.CardioDance]: 'music',
[WorkoutActivityType.SocialDance]: 'music',
[WorkoutActivityType.Yoga]: 'meditation',
[WorkoutActivityType.MindAndBody]: 'meditation',
[WorkoutActivityType.TaiChi]: 'meditation',
[WorkoutActivityType.Pilates]: 'meditation',
[WorkoutActivityType.Barre]: 'meditation',
[WorkoutActivityType.Flexibility]: 'meditation',
[WorkoutActivityType.Cooldown]: 'meditation',
[WorkoutActivityType.PreparationAndRecovery]: 'meditation',
// 户外运动
[WorkoutActivityType.Climbing]: 'hiking',
[WorkoutActivityType.EquestrianSports]: 'horse',
[WorkoutActivityType.Fishing]: 'target',
[WorkoutActivityType.Hunting]: 'target',
[WorkoutActivityType.PaddleSports]: 'rowing',
// 综合运动
[WorkoutActivityType.SwimBikeRun]: 'run-fast',
[WorkoutActivityType.Transition]: 'swap-horizontal-variant',
[WorkoutActivityType.Play]: 'gamepad-variant',
[WorkoutActivityType.FitnessGaming]: 'gamepad-variant',
[WorkoutActivityType.DiscSports]: 'target',
// 其他
[WorkoutActivityType.Other]: 'arm-flex',
[WorkoutActivityType.MartialArts]: 'karate',
[WorkoutActivityType.Kickboxing]: 'boxing-glove',
[WorkoutActivityType.Gymnastics]: 'human',
[WorkoutActivityType.TrackAndField]: 'run-fast',
[WorkoutActivityType.WheelchairWalkPace]: 'wheelchair',
[WorkoutActivityType.WheelchairRunPace]: 'wheelchair',
[WorkoutActivityType.Curling]: 'target',
};
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
}
const minutes = Math.max(durationInSeconds / 60, 1);
const caloriesPerMinute = totalCalories / minutes;
if (caloriesPerMinute >= 9) {
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
}
if (caloriesPerMinute >= 5) {
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' };
}
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' };
}
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
const grouped = workouts.reduce<Record<string, WorkoutData[]>>((acc, workout) => {
const dateKey = dayjs(workout.startDate || workout.endDate).format('YYYY-MM-DD');
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(workout);
return acc;
}, {});
return Object.keys(grouped)
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
.map((dateKey) => ({
title: dayjs(dateKey).format('M月D日'),
data: grouped[dateKey]
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
}));
}
export default function WorkoutHistoryScreen() {
const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadHistory = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
let permissionStatus = getHealthPermissionStatus();
if (permissionStatus !== HealthPermissionStatus.Authorized) {
permissionStatus = await checkHealthPermissionStatus(true);
}
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
if (!hasPermission) {
hasPermission = await ensureHealthPermissions();
}
if (!hasPermission) {
setSections([]);
setError('尚未授予健康数据权限');
return;
}
const end = dayjs();
const start = end.subtract(1, 'month');
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
setSections(groupWorkouts(filteredWorkouts));
} catch (err) {
console.error('加载锻炼历史失败:', err);
setError('加载锻炼记录失败,请稍后再试');
setSections([]);
} finally {
setIsLoading(false);
}
}, []);
useFocusEffect(
useCallback(() => {
loadHistory();
}, [loadHistory])
);
React.useEffect(() => {
const handlePermissionGranted = () => {
loadHistory();
};
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
return () => {
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
};
}, [loadHistory]);
const headerComponent = useMemo(() => (
<View style={styles.headerContainer}>
<Text style={styles.headerTitle}></Text>
<Text style={styles.headerSubtitle}></Text>
</View>
), []);
const emptyComponent = useMemo(() => (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubText}></Text>
</View>
), []);
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0);
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0);
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
const time = dayjs(item.startDate || item.endDate).format('HH:mm');
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
return (
<TouchableOpacity style={styles.historyCard} activeOpacity={0.85} onPress={() => { }}>
<View style={styles.cardIconWrapper}>
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
</View>
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text style={styles.cardTitle}>{calories} · {minutes}</Text>
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
</View>
</View>
<Text style={styles.cardSubtitle}>{activityLabel}{time}</Text>
</View>
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
</TouchableOpacity>
);
}, []);
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
<Text style={styles.sectionHeader}>{section.title}</Text>
), []);
return (
<View style={styles.safeArea}>
<LinearGradient
colors={["#F3F5FF", "#FFFFFF"]}
style={StyleSheet.absoluteFill}
/>
<HeaderBar title="锻炼历史" variant="minimal" transparent={true} />
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#5C55FF" />
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<SectionList
style={styles.sectionList}
sections={sections}
keyExtractor={(item) => item.id}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
ListHeaderComponent={headerComponent}
ListEmptyComponent={error ? (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
<Text style={styles.retryText}></Text>
</TouchableOpacity>
</View>
) : emptyComponent}
contentContainerStyle={styles.listContent}
stickySectionHeadersEnabled={false}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: 'transparent',
},
sectionList: {
flex: 1,
},
headerContainer: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 16,
},
headerTitle: {
fontSize: 26,
fontWeight: '700',
color: '#1F2355',
marginBottom: 6,
},
headerSubtitle: {
fontSize: 14,
color: '#677086',
},
listContent: {
paddingBottom: 40,
},
sectionHeader: {
fontSize: 14,
color: '#8087A2',
fontWeight: '600',
marginTop: 18,
paddingHorizontal: 20,
},
historyCard: {
marginTop: 12,
marginHorizontal: 16,
paddingVertical: 18,
paddingHorizontal: 18,
backgroundColor: '#FFFFFF',
borderRadius: 26,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#5460E54D',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.1,
shadowRadius: 16,
elevation: 6,
},
cardIconWrapper: {
width: 46,
height: 46,
borderRadius: 23,
backgroundColor: '#EEF0FF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
},
cardContent: {
flex: 1,
},
cardTitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
color: '#1F2355',
flexShrink: 1,
},
intensityBadge: {
marginLeft: 8,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
intensityText: {
fontSize: 12,
fontWeight: '600',
},
cardSubtitle: {
marginTop: 8,
fontSize: 13,
color: '#6B7693',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
loadingText: {
fontSize: 14,
color: '#596182',
},
emptyContainer: {
marginTop: 60,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
gap: 12,
},
emptyText: {
fontSize: 16,
fontWeight: '600',
color: '#596182',
},
emptySubText: {
fontSize: 13,
color: '#8F96AF',
textAlign: 'center',
lineHeight: 18,
},
retryButton: {
marginTop: 8,
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: '#5C55FF',
},
retryText: {
color: '#FFFFFF',
fontSize: 13,
fontWeight: '600',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,376 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
import { AnimatedNumber } from '@/components/AnimatedNumber';
import {
addHealthPermissionListener,
checkHealthPermissionStatus,
ensureHealthPermissions,
fetchWorkoutsForDateRange,
getHealthPermissionStatus,
getWorkoutTypeDisplayName,
HealthPermissionStatus,
removeHealthPermissionListener,
WorkoutActivityType,
WorkoutData,
} from '@/utils/health';
import { logger } from '@/utils/logger';
interface WorkoutSummaryCardProps {
date: Date;
style?: ViewStyle;
}
interface WorkoutSummary {
totalCalories: number;
totalMinutes: number;
workouts: WorkoutData[];
lastWorkout: WorkoutData | null;
}
const DEFAULT_SUMMARY: WorkoutSummary = {
totalCalories: 0,
totalMinutes: 0,
workouts: [],
lastWorkout: null,
};
const iconByWorkoutType: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
[WorkoutActivityType.Running]: 'run',
[WorkoutActivityType.Walking]: 'walk',
[WorkoutActivityType.Cycling]: 'bike',
[WorkoutActivityType.Swimming]: 'swim',
[WorkoutActivityType.Yoga]: 'meditation',
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
[WorkoutActivityType.CrossTraining]: 'arm-flex',
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
[WorkoutActivityType.Flexibility]: 'meditation',
[WorkoutActivityType.Cooldown]: 'meditation',
[WorkoutActivityType.Other]: 'arm-flex',
};
export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, style }) => {
const router = useRouter();
const [summary, setSummary] = useState<WorkoutSummary>(DEFAULT_SUMMARY);
const [isLoading, setIsLoading] = useState(false);
const [resetToken, setResetToken] = useState(0);
const isMountedRef = useRef(true);
const loadWorkoutData = useCallback(async (targetDate: Date) => {
setIsLoading(true);
try {
let permissionStatus = getHealthPermissionStatus();
// 如果当前状态未知或未授权,主动检查并尝试请求权限
if (permissionStatus !== HealthPermissionStatus.Authorized) {
permissionStatus = await checkHealthPermissionStatus(true);
}
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
if (!hasPermission) {
hasPermission = await ensureHealthPermissions();
}
if (!hasPermission) {
logger.warn('尚未获得HealthKit锻炼权限无法加载锻炼数据');
if (isMountedRef.current) {
setSummary(DEFAULT_SUMMARY);
}
return;
}
const startDate = dayjs(targetDate).startOf('day').toDate();
const endDate = dayjs(targetDate).endOf('day').toDate();
const workouts = await fetchWorkoutsForDateRange(startDate, endDate, 50);
console.log('workouts', workouts);
const workoutsInRange = workouts
.filter((workout) => {
// 额外防护:确保锻炼记录确实落在当天
const workoutDate = dayjs(workout.startDate);
return workoutDate.isSame(dayjs(targetDate), 'day');
})
// 依据结束时间排序,最新在前
.sort((a, b) => dayjs(b.endDate || b.startDate).valueOf() - dayjs(a.endDate || a.startDate).valueOf());
const totalCalories = workoutsInRange.reduce((total, workout) => total + (workout.totalEnergyBurned || 0), 0);
const totalMinutes = Math.round(
workoutsInRange.reduce((total, workout) => total + (workout.duration || 0), 0) / 60
);
const lastWorkout = workoutsInRange.length > 0 ? workoutsInRange[0] : null;
if (isMountedRef.current) {
setSummary({
totalCalories,
totalMinutes,
workouts: workoutsInRange,
lastWorkout,
});
setResetToken((token) => token + 1);
}
} catch (error) {
logger.error('加载锻炼数据失败', error);
if (isMountedRef.current) {
setSummary(DEFAULT_SUMMARY);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, []);
useEffect(() => {
isMountedRef.current = true;
loadWorkoutData(date);
return () => {
isMountedRef.current = false;
};
}, [date, loadWorkoutData]);
useEffect(() => {
const handlePermissionGranted = () => {
loadWorkoutData(date);
};
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
return () => {
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
};
}, [date, loadWorkoutData]);
const handlePress = useCallback(() => {
router.push('/workout/history');
}, [router]);
const handleAddPress = useCallback(() => {
router.push('/workout/create-session');
}, [router]);
const cardContent = useMemo(() => {
const hasWorkouts = summary.workouts.length > 0;
const lastWorkout = summary.lastWorkout;
const label = lastWorkout
? getWorkoutTypeDisplayName(lastWorkout.workoutActivityType)
: '尚无锻炼数据';
const time = lastWorkout
? `${dayjs(lastWorkout.endDate || lastWorkout.startDate).format('HH:mm')} 更新`
: '等待同步';
let source = '来源:等待同步';
if (hasWorkouts) {
const sourceNames = summary.workouts
.map((workout) => workout.source?.name?.trim() || workout.source?.bundleIdentifier?.trim())
.filter((name): name is string => Boolean(name));
if (sourceNames.length) {
const uniqueNames = Array.from(new Set(sourceNames));
const displayNames = uniqueNames.slice(0, 2).join('、');
source = uniqueNames.length > 2 ? `来源:${displayNames}` : `来源:${displayNames}`;
} else {
source = '来源:未知';
}
}
const seen = new Set<number>();
const uniqueBadges: WorkoutData[] = [];
for (const workout of summary.workouts) {
if (!seen.has(workout.workoutActivityType)) {
seen.add(workout.workoutActivityType);
uniqueBadges.push(workout);
}
if (uniqueBadges.length >= 3) {
break;
}
}
return {
label,
time,
source,
badges: uniqueBadges,
};
}, [summary]);
return (
<TouchableOpacity
activeOpacity={0.9}
style={[styles.container, style]}
onPress={handlePress}
>
<View style={styles.headerRow}>
<View style={styles.titleRow}>
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
<Text style={styles.titleText}></Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<AnimatedNumber value={summary.totalMinutes} resetToken={resetToken} style={styles.metricValue} />
<Text style={styles.metricLabel}></Text>
</View>
<View style={styles.metricItem}>
<AnimatedNumber value={summary.totalCalories} resetToken={resetToken} style={styles.metricValue} />
<Text style={styles.metricLabel}></Text>
</View>
</View>
<View style={styles.detailsRow}>
<View style={styles.detailsText}>
<Text style={styles.lastWorkoutLabel}>{cardContent.label}</Text>
<Text style={styles.lastWorkoutTime}>{cardContent.time}</Text>
<Text style={styles.sourceText}>{cardContent.source}</Text>
</View>
<View style={styles.badgesRow}>
{isLoading && <ActivityIndicator size="small" color="#7A8FFF" />}
{!isLoading && cardContent.badges.length === 0 && (
<View style={styles.badgePlaceholder}>
<MaterialCommunityIcons name="sleep" size={16} color="#7A8FFF" />
</View>
)}
</View>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 18,
paddingHorizontal: 18,
paddingVertical: 16,
width: '100%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
titleIcon: {
width: 20,
height: 20,
marginRight: 8,
resizeMode: 'contain',
},
titleText: {
fontSize: 16,
color: '#1F2355',
fontWeight: '600',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 3,
},
addButtonText: {
fontSize: 20,
color: '#7A8FFF',
marginTop: -2,
},
metricsRow: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
marginBottom: 10,
},
metricItem: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 6,
flex: 1,
},
metricDivider: {
width: 1,
height: 28,
backgroundColor: '#EEF0FF',
marginHorizontal: 12,
},
metricValue: {
fontSize: 24,
fontWeight: '700',
color: '#1F2355',
},
metricLabel: {
fontSize: 12,
color: '#4A5677',
fontWeight: '500',
marginBottom: 2,
},
detailsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
detailsText: {
flex: 1,
gap: 2,
},
lastWorkoutLabel: {
fontSize: 13,
color: '#1F2355',
fontWeight: '500',
},
lastWorkoutTime: {
fontSize: 12,
color: '#7C85A3',
},
sourceText: {
fontSize: 11,
color: '#9AA3C0',
},
badgesRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
badge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E5E9FF',
alignItems: 'center',
justifyContent: 'center',
},
badgePlaceholder: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E5E9FF',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -308,7 +308,6 @@ const styles = StyleSheet.create({
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderRadius: 22, borderRadius: 22,
padding: 16, padding: 16,
marginBottom: 4,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1, shadowOpacity: 0.1,

View File

@@ -10,28 +10,28 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; }; 32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 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 */; }; 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 */; }; 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; }; 79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; }; 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 */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; }; 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OutLive/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OutLive/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = "<group>"; };
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; }; 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; }; 79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = "<group>"; }; F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -42,7 +42,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */, AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -67,21 +67,11 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */, 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7B63456AB81271603E0039A3 /* Pods */ = {
isa = PBXGroup;
children = (
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */,
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = { 80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -107,7 +97,7 @@
83CBBA001A601CBA00E9B192 /* Products */, 83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */, 2D16E6871FA4F8E400B85C8A /* Frameworks */,
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */, 80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */,
7B63456AB81271603E0039A3 /* Pods */, D049F514815CB726258DD27E /* Pods */,
); );
indentWidth = 2; indentWidth = 2;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -139,6 +129,16 @@
name = OutLive; name = OutLive;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D049F514815CB726258DD27E /* Pods */ = {
isa = PBXGroup;
children = (
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */,
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -146,14 +146,14 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */;
buildPhases = ( buildPhases = (
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */, 1EB539808FAFD6C62AD21A7F /* [CP] Check Pods Manifest.lock */,
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */, FED23F24D8115FB0D63DF986 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */, 13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */, 13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */, 8F598744CAFFA386101BCC07 /* [CP] Embed Pods Frameworks */,
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */, 54EB3ED8CF242B308A7FE01E /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -227,7 +227,7 @@
shellPath = /bin/sh; 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"; 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; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( 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"; 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; showEnvVarsInLog = 0;
}; };
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */ = { 54EB3ED8CF242B308A7FE01E /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -301,7 +301,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */ = { 8F598744CAFFA386101BCC07 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -367,7 +367,7 @@
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = { 13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */; baseConfigurationReference = 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
@@ -404,7 +404,7 @@
}; };
13B07F951A680F5B00A75B9A /* Release */ = { 13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */; baseConfigurationReference = 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;

View File

@@ -77,4 +77,9 @@ RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter) rejecter:(RCTPromiseRejectBlock)rejecter)
// Workout Data Methods
RCT_EXTERN_METHOD(getRecentWorkouts:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end @end

View File

@@ -37,9 +37,14 @@ class HealthKitManager: NSObject, RCTBridgeModule {
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)! static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!
static let activitySummary = HKObjectType.activitySummaryType() static let activitySummary = HKObjectType.activitySummaryType()
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)! static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
static let workout = HKObjectType.workoutType()
static var all: Set<HKObjectType> { static var all: Set<HKObjectType> {
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater] return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
}
static var workoutType: HKWorkoutType {
return HKObjectType.workoutType()
} }
} }
@@ -557,7 +562,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: startDateComponents, end: endDateComponents) let 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 { DispatchQueue.main.async {
if let error = error { if let error = error {
rejecter("QUERY_ERROR", "Failed to query activity summary: \(error.localizedDescription)", error) rejecter("QUERY_ERROR", "Failed to query activity summary: \(error.localizedDescription)", error)
@@ -673,7 +678,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
let result: [String: Any] = [ let result: [String: Any] = [
"data": hrvData, "data": hrvData,
"count": hrvData.count, "count": hrvData.count,
"bestQualityValue": bestQualityValue, "bestQualityValue": bestQualityValue ?? NSNull(),
"startDate": self?.dateToISOString(startDate) ?? "", "startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? "" "endDate": self?.dateToISOString(endDate) ?? ""
] ]
@@ -1577,4 +1582,125 @@ class HealthKitManager: NSObject, RCTBridgeModule {
healthStore.execute(query) 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 } // end class

View File

@@ -25,7 +25,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.15</string> <string>1.0.16</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -125,6 +125,8 @@ async function executeChallengeReminderTask(): Promise<void> {
const todayKey = new Date().toISOString().slice(0, 10); const todayKey = new Date().toISOString().slice(0, 10);
// 筛选出需要发送通知的挑战(未签到且今天未发送过通知)
const eligibleChallenges = [];
for (const challenge of joinedChallenges) { for (const challenge of joinedChallenges) {
const progress = challenge.progress; const progress = challenge.progress;
if (!progress || progress.checkedInToday) { if (!progress || progress.checkedInToday) {
@@ -137,17 +139,30 @@ async function executeChallengeReminderTask(): Promise<void> {
continue; continue;
} }
eligibleChallenges.push(challenge);
}
// 如果有符合条件的挑战,随机选择一个发送通知
if (eligibleChallenges.length > 0) {
const randomIndex = Math.floor(Math.random() * eligibleChallenges.length);
const selectedChallenge = eligibleChallenges[randomIndex];
try { try {
await ChallengeNotificationHelpers.sendEncouragementNotification({ await ChallengeNotificationHelpers.sendEncouragementNotification({
userName, userName,
challengeTitle: challenge.title, challengeTitle: selectedChallenge.title,
challengeId: challenge.id, challengeId: selectedChallenge.id,
}); });
const storageKey = `@challenge_encouragement_sent:${selectedChallenge.id}`;
await AsyncStorage.setItem(storageKey, todayKey); await AsyncStorage.setItem(storageKey, todayKey);
console.log(`已随机选择并发送挑战鼓励通知: ${selectedChallenge.title}`);
} catch (notificationError) { } catch (notificationError) {
console.error('发送挑战鼓励通知失败:', notificationError); console.error('发送挑战鼓励通知失败:', notificationError);
} }
} else {
console.log('没有符合条件的挑战需要发送鼓励通知');
} }
console.log('挑战鼓励提醒后台任务完成'); console.log('挑战鼓励提醒后台任务完成');

View File

@@ -5,8 +5,120 @@ import { SimpleEventEmitter } from './SimpleEventEmitter';
type HealthDataOptions = { type HealthDataOptions = {
startDate: string; startDate: string;
endDate: string; endDate: string;
limit?: number;
}; };
// 锻炼数据类型定义
export interface WorkoutData {
id: string;
startDate: string;
endDate: string;
duration: number; // 秒
workoutActivityType: number;
workoutActivityTypeString: string;
totalEnergyBurned?: number; // 千卡
totalDistance?: number; // 米
averageHeartRate?: number;
source: {
name: string;
bundleIdentifier: string;
};
metadata: Record<string, any>;
}
// 锻炼记录查询选项
export interface WorkoutOptions extends HealthDataOptions {
limit?: number; // 默认10条
}
// 锻炼活动类型枚举
export enum WorkoutActivityType {
AmericanFootball = 1,
Archery = 2,
AustralianFootball = 3,
Badminton = 4,
Baseball = 5,
Basketball = 6,
Bowling = 7,
Boxing = 8,
Climbing = 9,
Cricket = 10,
CrossTraining = 11,
Curling = 12,
Cycling = 13,
Dance = 14,
DanceInspiredTraining = 15,
Elliptical = 16,
EquestrianSports = 17,
Fencing = 18,
Fishing = 19,
FunctionalStrengthTraining = 20,
Golf = 21,
Gymnastics = 22,
Handball = 23,
Hiking = 24,
Hockey = 25,
Hunting = 26,
Lacrosse = 27,
MartialArts = 28,
MindAndBody = 29,
MixedMetabolicCardioTraining = 30,
PaddleSports = 31,
Play = 32,
PreparationAndRecovery = 33,
Racquetball = 34,
Rowing = 35,
Rugby = 36,
Running = 37,
Sailing = 38,
SkatingSports = 39,
SnowSports = 40,
Soccer = 41,
Softball = 42,
Squash = 43,
StairClimbing = 44,
SurfingSports = 45,
Swimming = 46,
TableTennis = 47,
Tennis = 48,
TrackAndField = 49,
TraditionalStrengthTraining = 50,
Volleyball = 51,
Walking = 52,
WaterFitness = 53,
WaterPolo = 54,
WaterSports = 55,
Wrestling = 56,
Yoga = 57,
Barre = 58,
CoreTraining = 59,
CrossCountrySkiing = 60,
DownhillSkiing = 61,
Flexibility = 62,
HighIntensityIntervalTraining = 63,
JumpRope = 64,
Kickboxing = 65,
Pilates = 66,
Snowboarding = 67,
Stairs = 68,
StepTraining = 69,
WheelchairWalkPace = 70,
WheelchairRunPace = 71,
TaiChi = 72,
MixedCardio = 73,
HandCycling = 74,
DiscSports = 75,
FitnessGaming = 76,
CardioDance = 77,
SocialDance = 78,
Pickleball = 79,
Cooldown = 80,
SwimBikeRun = 82,
Transition = 83,
UnderwaterDiving = 84,
Other = 3000
}
// React Native bridge to native HealthKitManager // React Native bridge to native HealthKitManager
const { HealthKitManager } = NativeModules; const { HealthKitManager } = NativeModules;
@@ -1317,6 +1429,314 @@ export async function fetchSmartHRVData(date: Date): Promise<HRVData | null> {
} }
} }
// === 锻炼记录相关方法 ===
// 获取最近锻炼记录
export async function fetchRecentWorkouts(options?: Partial<WorkoutOptions>): Promise<WorkoutData[]> {
try {
console.log('开始获取最近锻炼记录...', options);
// 设置默认选项
const defaultOptions: WorkoutOptions = {
startDate: dayjs().subtract(30, 'day').startOf('day').toISOString(),
endDate: dayjs().endOf('day').toISOString(),
limit: 10
};
const finalOptions = { ...defaultOptions, ...options };
const result = await HealthKitManager.getRecentWorkouts(finalOptions);
if (result && result.data && Array.isArray(result.data)) {
logSuccess('锻炼记录', result);
// 验证和处理返回的数据
const validatedWorkouts: WorkoutData[] = result.data
.filter((workout: any) => {
// 基本数据验证
return workout &&
workout.id &&
workout.startDate &&
workout.endDate &&
workout.duration !== undefined;
})
.map((workout: any) => ({
id: workout.id,
startDate: workout.startDate,
endDate: workout.endDate,
duration: workout.duration,
workoutActivityType: workout.workoutActivityType || 0,
workoutActivityTypeString: workout.workoutActivityTypeString || 'unknown',
totalEnergyBurned: workout.totalEnergyBurned,
totalDistance: workout.totalDistance,
averageHeartRate: workout.averageHeartRate,
source: {
name: workout.source?.name || 'Unknown',
bundleIdentifier: workout.source?.bundleIdentifier || ''
},
metadata: workout.metadata || {}
}));
console.log(`成功获取 ${validatedWorkouts.length} 条锻炼记录`);
return validatedWorkouts;
} else {
logWarning('锻炼记录', '为空或格式错误');
return [];
}
} catch (error) {
logError('锻炼记录', error);
return [];
}
}
// 获取指定日期范围内的锻炼记录
export async function fetchWorkoutsForDateRange(
startDate: Date,
endDate: Date,
limit: number = 10
): Promise<WorkoutData[]> {
const options: WorkoutOptions = {
startDate: dayjs(startDate).startOf('day').toISOString(),
endDate: dayjs(endDate).endOf('day').toISOString(),
limit
};
return fetchRecentWorkouts(options);
}
// 获取今日锻炼记录
export async function fetchTodayWorkouts(): Promise<WorkoutData[]> {
const today = dayjs();
return fetchWorkoutsForDateRange(today.toDate(), today.toDate(), 20);
}
// 获取本周锻炼记录
export async function fetchThisWeekWorkouts(): Promise<WorkoutData[]> {
const today = dayjs();
const startOfWeek = today.startOf('week');
return fetchWorkoutsForDateRange(startOfWeek.toDate(), today.toDate(), 50);
}
// 获取本月锻炼记录
export async function fetchThisMonthWorkouts(): Promise<WorkoutData[]> {
const today = dayjs();
const startOfMonth = today.startOf('month');
return fetchWorkoutsForDateRange(startOfMonth.toDate(), today.toDate(), 100);
}
// 根据锻炼类型筛选锻炼记录
export function filterWorkoutsByType(
workouts: WorkoutData[],
workoutType: WorkoutActivityType
): WorkoutData[] {
return workouts.filter(workout => workout.workoutActivityType === workoutType);
}
// 获取锻炼统计信息
export function getWorkoutStatistics(workouts: WorkoutData[]): {
totalWorkouts: number;
totalDuration: number; // 秒
totalEnergyBurned: number; // 千卡
totalDistance: number; // 米
averageDuration: number; // 秒
workoutTypes: Record<string, number>; // 各类型锻炼次数
} {
const stats = {
totalWorkouts: workouts.length,
totalDuration: 0,
totalEnergyBurned: 0,
totalDistance: 0,
averageDuration: 0,
workoutTypes: {} as Record<string, number>
};
workouts.forEach(workout => {
stats.totalDuration += workout.duration;
stats.totalEnergyBurned += workout.totalEnergyBurned || 0;
stats.totalDistance += workout.totalDistance || 0;
// 统计锻炼类型
const typeString = workout.workoutActivityTypeString;
stats.workoutTypes[typeString] = (stats.workoutTypes[typeString] || 0) + 1;
});
if (stats.totalWorkouts > 0) {
stats.averageDuration = Math.round(stats.totalDuration / stats.totalWorkouts);
}
return stats;
}
// 格式化锻炼持续时间
export function formatWorkoutDuration(durationInSeconds: number): string {
const hours = Math.floor(durationInSeconds / 3600);
const minutes = Math.floor((durationInSeconds % 3600) / 60);
const seconds = durationInSeconds % 60;
if (hours > 0) {
return `${hours}小时${minutes}分钟`;
} else if (minutes > 0) {
return `${minutes}分钟${seconds}`;
} else {
return `${seconds}`;
}
}
// 格式化锻炼距离
export function formatWorkoutDistance(distanceInMeters: number): string {
if (distanceInMeters >= 1000) {
return `${(distanceInMeters / 1000).toFixed(2)}公里`;
} else {
return `${Math.round(distanceInMeters)}`;
}
}
const WORKOUT_TYPE_LABELS: Record<string, string> = {
running: '跑步',
walking: '步行',
cycling: '骑行',
swimming: '游泳',
yoga: '瑜伽',
functionalstrengthtraining: '功能性力量训练',
traditionalstrengthtraining: '传统力量训练',
crosstraining: '交叉训练',
mixedcardio: '混合有氧',
highintensityintervaltraining: '高强度间歇训练',
flexibility: '柔韧性训练',
cooldown: '放松运动',
pilates: '普拉提',
dance: '舞蹈',
danceinspiredtraining: '舞蹈训练',
cardiodance: '有氧舞蹈',
socialdance: '社交舞',
swimbikerun: '铁人三项',
transition: '项目转换',
underwaterdiving: '水下潜水',
pickleball: '匹克球',
americanfootball: '美式橄榄球',
badminton: '羽毛球',
baseball: '棒球',
basketball: '篮球',
tennis: '网球',
tabletennis: '乒乓球',
functionalStrengthTraining: '功能性力量训练',
other: '其他运动',
};
function humanizeWorkoutTypeKey(raw: string | undefined): string {
if (!raw) {
return '其他运动';
}
const cleaned = raw
.replace(/^HKWorkoutActivityType/i, '')
.replace(/[_\-]+/g, ' ')
.trim();
if (!cleaned) {
return '其他运动';
}
const withSpaces = cleaned.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
const words = withSpaces
.split(/\s+/)
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
return words.join(' ');
}
// 获取锻炼类型的显示名称
export function getWorkoutTypeDisplayName(workoutType: WorkoutActivityType | string): string {
if (typeof workoutType === 'string') {
const normalized = workoutType.replace(/\s+/g, '').toLowerCase();
return WORKOUT_TYPE_LABELS[normalized] || humanizeWorkoutTypeKey(workoutType);
}
switch (workoutType) {
case WorkoutActivityType.Running:
return '跑步';
case WorkoutActivityType.Cycling:
return '骑行';
case WorkoutActivityType.Walking:
return '步行';
case WorkoutActivityType.Swimming:
return '游泳';
case WorkoutActivityType.Yoga:
return '瑜伽';
case WorkoutActivityType.FunctionalStrengthTraining:
return '功能性力量训练';
case WorkoutActivityType.TraditionalStrengthTraining:
return '传统力量训练';
case WorkoutActivityType.CrossTraining:
return '交叉训练';
case WorkoutActivityType.MixedCardio:
return '混合有氧';
case WorkoutActivityType.HighIntensityIntervalTraining:
return '高强度间歇训练';
case WorkoutActivityType.Flexibility:
return '柔韧性训练';
case WorkoutActivityType.Cooldown:
return '放松运动';
case WorkoutActivityType.Tennis:
return '网球';
case WorkoutActivityType.Other:
return '其他运动';
default:
return humanizeWorkoutTypeKey(WorkoutActivityType[workoutType]);
}
}
// 测试锻炼记录获取功能
export async function testWorkoutDataFetch(): Promise<void> {
console.log('=== 开始测试锻炼记录获取 ===');
try {
// 确保权限
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
console.error('没有健康数据权限,无法测试锻炼记录');
return;
}
console.log('权限检查通过,开始获取锻炼记录...');
// 测试获取最近锻炼记录
console.log('--- 测试获取最近锻炼记录 ---');
const recentWorkouts = await fetchRecentWorkouts();
console.log(`获取到 ${recentWorkouts.length} 条最近锻炼记录`);
recentWorkouts.forEach((workout, index) => {
console.log(`锻炼 ${index + 1}:`, {
类型: getWorkoutTypeDisplayName(workout.workoutActivityTypeString),
持续时间: formatWorkoutDuration(workout.duration),
能量消耗: workout.totalEnergyBurned ? `${workout.totalEnergyBurned}千卡` : '无',
距离: workout.totalDistance ? formatWorkoutDistance(workout.totalDistance) : '无',
开始时间: workout.startDate,
数据来源: workout.source.name
});
});
// 测试统计功能
if (recentWorkouts.length > 0) {
console.log('--- 锻炼统计信息 ---');
const stats = getWorkoutStatistics(recentWorkouts);
console.log('统计结果:', {
总锻炼次数: stats.totalWorkouts,
总持续时间: formatWorkoutDuration(stats.totalDuration),
: `${stats.totalEnergyBurned}千卡`,
总距离: formatWorkoutDistance(stats.totalDistance),
平均持续时间: formatWorkoutDuration(stats.averageDuration),
锻炼类型分布: stats.workoutTypes
});
}
console.log('=== 锻炼记录测试完成 ===');
} catch (error) {
console.error('锻炼记录测试过程中出现错误:', error);
}
}
// 获取HRV数据并附带详细的状态信息 // 获取HRV数据并附带详细的状态信息
export async function fetchHRVWithStatus(date: Date): Promise<{ export async function fetchHRVWithStatus(date: Date): Promise<{
hrvData: HRVData | null; hrvData: HRVData | null;