feat(workout): 新增锻炼历史记录功能与健康数据集成
- 新增锻炼历史页面,展示最近一个月的锻炼记录详情 - 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据 - 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息 - 完善锻炼数据处理工具,包含统计分析和格式化功能 - 优化后台任务,随机选择挑战发送鼓励通知 - 版本升级至1.0.16
This commit is contained in:
2
app.json
2
app.json
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
447
app/workout/history.tsx
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
SectionList,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addHealthPermissionListener,
|
||||||
|
checkHealthPermissionStatus,
|
||||||
|
ensureHealthPermissions,
|
||||||
|
fetchWorkoutsForDateRange,
|
||||||
|
getHealthPermissionStatus,
|
||||||
|
getWorkoutTypeDisplayName,
|
||||||
|
HealthPermissionStatus,
|
||||||
|
removeHealthPermissionListener,
|
||||||
|
WorkoutActivityType,
|
||||||
|
WorkoutData,
|
||||||
|
} from '@/utils/health';
|
||||||
|
|
||||||
|
type WorkoutSection = {
|
||||||
|
title: string;
|
||||||
|
data: WorkoutData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
|
||||||
|
// 球类运动
|
||||||
|
[WorkoutActivityType.AmericanFootball]: 'football',
|
||||||
|
[WorkoutActivityType.Archery]: 'target',
|
||||||
|
[WorkoutActivityType.AustralianFootball]: 'football',
|
||||||
|
[WorkoutActivityType.Badminton]: 'tennis',
|
||||||
|
[WorkoutActivityType.Baseball]: 'baseball',
|
||||||
|
[WorkoutActivityType.Basketball]: 'basketball',
|
||||||
|
[WorkoutActivityType.Bowling]: 'bowling',
|
||||||
|
[WorkoutActivityType.Boxing]: 'boxing-glove',
|
||||||
|
[WorkoutActivityType.Cricket]: 'cricket',
|
||||||
|
[WorkoutActivityType.Fencing]: 'sword',
|
||||||
|
[WorkoutActivityType.Golf]: 'golf',
|
||||||
|
[WorkoutActivityType.Handball]: 'basketball',
|
||||||
|
[WorkoutActivityType.Hockey]: 'hockey-sticks',
|
||||||
|
[WorkoutActivityType.Lacrosse]: 'tennis',
|
||||||
|
[WorkoutActivityType.Racquetball]: 'tennis',
|
||||||
|
[WorkoutActivityType.Soccer]: 'soccer',
|
||||||
|
[WorkoutActivityType.Softball]: 'baseball',
|
||||||
|
[WorkoutActivityType.Squash]: 'tennis',
|
||||||
|
[WorkoutActivityType.TableTennis]: 'table-tennis',
|
||||||
|
[WorkoutActivityType.Tennis]: 'tennis',
|
||||||
|
[WorkoutActivityType.Volleyball]: 'volleyball',
|
||||||
|
[WorkoutActivityType.WaterPolo]: 'swim',
|
||||||
|
[WorkoutActivityType.Pickleball]: 'tennis',
|
||||||
|
|
||||||
|
// 水上运动
|
||||||
|
[WorkoutActivityType.Swimming]: 'swim',
|
||||||
|
[WorkoutActivityType.Sailing]: 'sail-boat',
|
||||||
|
[WorkoutActivityType.SurfingSports]: 'waves',
|
||||||
|
[WorkoutActivityType.WaterFitness]: 'swim',
|
||||||
|
[WorkoutActivityType.WaterSports]: 'swim',
|
||||||
|
[WorkoutActivityType.UnderwaterDiving]: 'swim',
|
||||||
|
|
||||||
|
// 跑步和步行
|
||||||
|
[WorkoutActivityType.Running]: 'run',
|
||||||
|
[WorkoutActivityType.Walking]: 'walk',
|
||||||
|
[WorkoutActivityType.Hiking]: 'hiking',
|
||||||
|
[WorkoutActivityType.StairClimbing]: 'stairs',
|
||||||
|
[WorkoutActivityType.Stairs]: 'stairs',
|
||||||
|
|
||||||
|
// 骑行
|
||||||
|
[WorkoutActivityType.Cycling]: 'bike',
|
||||||
|
[WorkoutActivityType.HandCycling]: 'bike',
|
||||||
|
|
||||||
|
// 滑雪和滑冰
|
||||||
|
[WorkoutActivityType.CrossCountrySkiing]: 'ski',
|
||||||
|
[WorkoutActivityType.DownhillSkiing]: 'ski',
|
||||||
|
[WorkoutActivityType.Snowboarding]: 'snowboard',
|
||||||
|
[WorkoutActivityType.SkatingSports]: 'skateboarding',
|
||||||
|
[WorkoutActivityType.SnowSports]: 'ski',
|
||||||
|
|
||||||
|
// 力量训练
|
||||||
|
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
|
||||||
|
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
|
||||||
|
[WorkoutActivityType.CrossTraining]: 'arm-flex',
|
||||||
|
[WorkoutActivityType.CoreTraining]: 'arm-flex',
|
||||||
|
|
||||||
|
// 有氧运动
|
||||||
|
[WorkoutActivityType.Elliptical]: 'bike',
|
||||||
|
[WorkoutActivityType.Rowing]: 'rowing',
|
||||||
|
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
|
||||||
|
[WorkoutActivityType.MixedMetabolicCardioTraining]: 'heart-pulse',
|
||||||
|
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
|
||||||
|
[WorkoutActivityType.JumpRope]: 'skip-forward',
|
||||||
|
[WorkoutActivityType.StepTraining]: 'stairs',
|
||||||
|
|
||||||
|
// 舞蹈和身心训练
|
||||||
|
[WorkoutActivityType.Dance]: 'music',
|
||||||
|
[WorkoutActivityType.DanceInspiredTraining]: 'music',
|
||||||
|
[WorkoutActivityType.CardioDance]: 'music',
|
||||||
|
[WorkoutActivityType.SocialDance]: 'music',
|
||||||
|
[WorkoutActivityType.Yoga]: 'meditation',
|
||||||
|
[WorkoutActivityType.MindAndBody]: 'meditation',
|
||||||
|
[WorkoutActivityType.TaiChi]: 'meditation',
|
||||||
|
[WorkoutActivityType.Pilates]: 'meditation',
|
||||||
|
[WorkoutActivityType.Barre]: 'meditation',
|
||||||
|
[WorkoutActivityType.Flexibility]: 'meditation',
|
||||||
|
[WorkoutActivityType.Cooldown]: 'meditation',
|
||||||
|
[WorkoutActivityType.PreparationAndRecovery]: 'meditation',
|
||||||
|
|
||||||
|
// 户外运动
|
||||||
|
[WorkoutActivityType.Climbing]: 'hiking',
|
||||||
|
[WorkoutActivityType.EquestrianSports]: 'horse',
|
||||||
|
[WorkoutActivityType.Fishing]: 'target',
|
||||||
|
[WorkoutActivityType.Hunting]: 'target',
|
||||||
|
[WorkoutActivityType.PaddleSports]: 'rowing',
|
||||||
|
|
||||||
|
// 综合运动
|
||||||
|
[WorkoutActivityType.SwimBikeRun]: 'run-fast',
|
||||||
|
[WorkoutActivityType.Transition]: 'swap-horizontal-variant',
|
||||||
|
[WorkoutActivityType.Play]: 'gamepad-variant',
|
||||||
|
[WorkoutActivityType.FitnessGaming]: 'gamepad-variant',
|
||||||
|
[WorkoutActivityType.DiscSports]: 'target',
|
||||||
|
|
||||||
|
// 其他
|
||||||
|
[WorkoutActivityType.Other]: 'arm-flex',
|
||||||
|
[WorkoutActivityType.MartialArts]: 'karate',
|
||||||
|
[WorkoutActivityType.Kickboxing]: 'boxing-glove',
|
||||||
|
[WorkoutActivityType.Gymnastics]: 'human',
|
||||||
|
[WorkoutActivityType.TrackAndField]: 'run-fast',
|
||||||
|
[WorkoutActivityType.WheelchairWalkPace]: 'wheelchair',
|
||||||
|
[WorkoutActivityType.WheelchairRunPace]: 'wheelchair',
|
||||||
|
[WorkoutActivityType.Curling]: 'target',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
|
||||||
|
if (!totalCalories || !durationInSeconds) {
|
||||||
|
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.max(durationInSeconds / 60, 1);
|
||||||
|
const caloriesPerMinute = totalCalories / minutes;
|
||||||
|
|
||||||
|
if (caloriesPerMinute >= 9) {
|
||||||
|
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caloriesPerMinute >= 5) {
|
||||||
|
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
|
||||||
|
const grouped = workouts.reduce<Record<string, WorkoutData[]>>((acc, workout) => {
|
||||||
|
const dateKey = dayjs(workout.startDate || workout.endDate).format('YYYY-MM-DD');
|
||||||
|
if (!acc[dateKey]) {
|
||||||
|
acc[dateKey] = [];
|
||||||
|
}
|
||||||
|
acc[dateKey].push(workout);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return Object.keys(grouped)
|
||||||
|
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
|
||||||
|
.map((dateKey) => ({
|
||||||
|
title: dayjs(dateKey).format('M月D日'),
|
||||||
|
data: grouped[dateKey]
|
||||||
|
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkoutHistoryScreen() {
|
||||||
|
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let permissionStatus = getHealthPermissionStatus();
|
||||||
|
if (permissionStatus !== HealthPermissionStatus.Authorized) {
|
||||||
|
permissionStatus = await checkHealthPermissionStatus(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
|
||||||
|
if (!hasPermission) {
|
||||||
|
hasPermission = await ensureHealthPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
setSections([]);
|
||||||
|
setError('尚未授予健康数据权限');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = dayjs();
|
||||||
|
const start = end.subtract(1, 'month');
|
||||||
|
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
|
||||||
|
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
|
||||||
|
|
||||||
|
setSections(groupWorkouts(filteredWorkouts));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载锻炼历史失败:', err);
|
||||||
|
setError('加载锻炼记录失败,请稍后再试');
|
||||||
|
setSections([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadHistory();
|
||||||
|
}, [loadHistory])
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handlePermissionGranted = () => {
|
||||||
|
loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
||||||
|
return () => {
|
||||||
|
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
||||||
|
};
|
||||||
|
}, [loadHistory]);
|
||||||
|
|
||||||
|
const headerComponent = useMemo(() => (
|
||||||
|
<View style={styles.headerContainer}>
|
||||||
|
<Text style={styles.headerTitle}>历史</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>最近一个月的锻炼记录</Text>
|
||||||
|
</View>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const emptyComponent = useMemo(() => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
|
||||||
|
<Text style={styles.emptyText}>暂无锻炼记录</Text>
|
||||||
|
<Text style={styles.emptySubText}>完成一次锻炼后即可在此查看详细历史</Text>
|
||||||
|
</View>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
|
||||||
|
const calories = Math.round(item.totalEnergyBurned || 0);
|
||||||
|
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
|
||||||
|
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0);
|
||||||
|
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
|
||||||
|
const time = dayjs(item.startDate || item.endDate).format('HH:mm');
|
||||||
|
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.historyCard} activeOpacity={0.85} onPress={() => { }}>
|
||||||
|
<View style={styles.cardIconWrapper}>
|
||||||
|
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={styles.cardTitle}>{calories}千卡 · {minutes}分钟</Text>
|
||||||
|
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
|
||||||
|
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cardSubtitle}>{activityLabel},{time}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
|
||||||
|
<Text style={styles.sectionHeader}>{section.title}</Text>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.safeArea}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#F3F5FF", "#FFFFFF"]}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<HeaderBar title="锻炼历史" variant="minimal" transparent={true} />
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#5C55FF" />
|
||||||
|
<Text style={styles.loadingText}>正在加载锻炼记录...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<SectionList
|
||||||
|
style={styles.sectionList}
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderItem}
|
||||||
|
renderSectionHeader={renderSectionHeader}
|
||||||
|
ListHeaderComponent={headerComponent}
|
||||||
|
ListEmptyComponent={error ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
|
||||||
|
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
|
||||||
|
<Text style={styles.retryText}>重试</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : emptyComponent}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
stickySectionHeadersEnabled={false}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
sectionList: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerContainer: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2355',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#677086',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#8087A2',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 18,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
historyCard: {
|
||||||
|
marginTop: 12,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 26,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#5460E54D',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
cardIconWrapper: {
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 23,
|
||||||
|
backgroundColor: '#EEF0FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 14,
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cardTitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2355',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
intensityBadge: {
|
||||||
|
marginLeft: 8,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
intensityText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#6B7693',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#596182',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
marginTop: 60,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#596182',
|
||||||
|
},
|
||||||
|
emptySubText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#8F96AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 8,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#5C55FF',
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
assets/images/icons/icon-fitness.png
Normal file
BIN
assets/images/icons/icon-fitness.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
376
components/WorkoutSummaryCard.tsx
Normal file
376
components/WorkoutSummaryCard.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||||
|
import {
|
||||||
|
addHealthPermissionListener,
|
||||||
|
checkHealthPermissionStatus,
|
||||||
|
ensureHealthPermissions,
|
||||||
|
fetchWorkoutsForDateRange,
|
||||||
|
getHealthPermissionStatus,
|
||||||
|
getWorkoutTypeDisplayName,
|
||||||
|
HealthPermissionStatus,
|
||||||
|
removeHealthPermissionListener,
|
||||||
|
WorkoutActivityType,
|
||||||
|
WorkoutData,
|
||||||
|
} from '@/utils/health';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
|
||||||
|
interface WorkoutSummaryCardProps {
|
||||||
|
date: Date;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkoutSummary {
|
||||||
|
totalCalories: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
workouts: WorkoutData[];
|
||||||
|
lastWorkout: WorkoutData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SUMMARY: WorkoutSummary = {
|
||||||
|
totalCalories: 0,
|
||||||
|
totalMinutes: 0,
|
||||||
|
workouts: [],
|
||||||
|
lastWorkout: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconByWorkoutType: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
|
||||||
|
[WorkoutActivityType.Running]: 'run',
|
||||||
|
[WorkoutActivityType.Walking]: 'walk',
|
||||||
|
[WorkoutActivityType.Cycling]: 'bike',
|
||||||
|
[WorkoutActivityType.Swimming]: 'swim',
|
||||||
|
[WorkoutActivityType.Yoga]: 'meditation',
|
||||||
|
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
|
||||||
|
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
|
||||||
|
[WorkoutActivityType.CrossTraining]: 'arm-flex',
|
||||||
|
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
|
||||||
|
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
|
||||||
|
[WorkoutActivityType.Flexibility]: 'meditation',
|
||||||
|
[WorkoutActivityType.Cooldown]: 'meditation',
|
||||||
|
[WorkoutActivityType.Other]: 'arm-flex',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, style }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [summary, setSummary] = useState<WorkoutSummary>(DEFAULT_SUMMARY);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [resetToken, setResetToken] = useState(0);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
const loadWorkoutData = useCallback(async (targetDate: Date) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
let permissionStatus = getHealthPermissionStatus();
|
||||||
|
|
||||||
|
// 如果当前状态未知或未授权,主动检查并尝试请求权限
|
||||||
|
if (permissionStatus !== HealthPermissionStatus.Authorized) {
|
||||||
|
permissionStatus = await checkHealthPermissionStatus(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
hasPermission = await ensureHealthPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
logger.warn('尚未获得HealthKit锻炼权限,无法加载锻炼数据');
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setSummary(DEFAULT_SUMMARY);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = dayjs(targetDate).startOf('day').toDate();
|
||||||
|
const endDate = dayjs(targetDate).endOf('day').toDate();
|
||||||
|
const workouts = await fetchWorkoutsForDateRange(startDate, endDate, 50);
|
||||||
|
|
||||||
|
console.log('workouts', workouts);
|
||||||
|
|
||||||
|
|
||||||
|
const workoutsInRange = workouts
|
||||||
|
.filter((workout) => {
|
||||||
|
// 额外防护:确保锻炼记录确实落在当天
|
||||||
|
const workoutDate = dayjs(workout.startDate);
|
||||||
|
return workoutDate.isSame(dayjs(targetDate), 'day');
|
||||||
|
})
|
||||||
|
// 依据结束时间排序,最新在前
|
||||||
|
.sort((a, b) => dayjs(b.endDate || b.startDate).valueOf() - dayjs(a.endDate || a.startDate).valueOf());
|
||||||
|
|
||||||
|
const totalCalories = workoutsInRange.reduce((total, workout) => total + (workout.totalEnergyBurned || 0), 0);
|
||||||
|
const totalMinutes = Math.round(
|
||||||
|
workoutsInRange.reduce((total, workout) => total + (workout.duration || 0), 0) / 60
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastWorkout = workoutsInRange.length > 0 ? workoutsInRange[0] : null;
|
||||||
|
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setSummary({
|
||||||
|
totalCalories,
|
||||||
|
totalMinutes,
|
||||||
|
workouts: workoutsInRange,
|
||||||
|
lastWorkout,
|
||||||
|
});
|
||||||
|
setResetToken((token) => token + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载锻炼数据失败', error);
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setSummary(DEFAULT_SUMMARY);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
loadWorkoutData(date);
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [date, loadWorkoutData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePermissionGranted = () => {
|
||||||
|
loadWorkoutData(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
||||||
|
return () => {
|
||||||
|
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
||||||
|
};
|
||||||
|
}, [date, loadWorkoutData]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
router.push('/workout/history');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleAddPress = useCallback(() => {
|
||||||
|
router.push('/workout/create-session');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const cardContent = useMemo(() => {
|
||||||
|
const hasWorkouts = summary.workouts.length > 0;
|
||||||
|
const lastWorkout = summary.lastWorkout;
|
||||||
|
|
||||||
|
const label = lastWorkout
|
||||||
|
? getWorkoutTypeDisplayName(lastWorkout.workoutActivityType)
|
||||||
|
: '尚无锻炼数据';
|
||||||
|
|
||||||
|
const time = lastWorkout
|
||||||
|
? `${dayjs(lastWorkout.endDate || lastWorkout.startDate).format('HH:mm')} 更新`
|
||||||
|
: '等待同步';
|
||||||
|
|
||||||
|
let source = '来源:等待同步';
|
||||||
|
if (hasWorkouts) {
|
||||||
|
const sourceNames = summary.workouts
|
||||||
|
.map((workout) => workout.source?.name?.trim() || workout.source?.bundleIdentifier?.trim())
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
|
||||||
|
if (sourceNames.length) {
|
||||||
|
const uniqueNames = Array.from(new Set(sourceNames));
|
||||||
|
const displayNames = uniqueNames.slice(0, 2).join('、');
|
||||||
|
source = uniqueNames.length > 2 ? `来源:${displayNames} 等` : `来源:${displayNames}`;
|
||||||
|
} else {
|
||||||
|
source = '来源:未知';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const uniqueBadges: WorkoutData[] = [];
|
||||||
|
for (const workout of summary.workouts) {
|
||||||
|
if (!seen.has(workout.workoutActivityType)) {
|
||||||
|
seen.add(workout.workoutActivityType);
|
||||||
|
uniqueBadges.push(workout);
|
||||||
|
}
|
||||||
|
if (uniqueBadges.length >= 3) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
time,
|
||||||
|
source,
|
||||||
|
badges: uniqueBadges,
|
||||||
|
};
|
||||||
|
}, [summary]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
style={[styles.container, style]}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
|
||||||
|
<Text style={styles.titleText}>健身</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<AnimatedNumber value={summary.totalMinutes} resetToken={resetToken} style={styles.metricValue} />
|
||||||
|
<Text style={styles.metricLabel}>分钟</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<AnimatedNumber value={summary.totalCalories} resetToken={resetToken} style={styles.metricValue} />
|
||||||
|
<Text style={styles.metricLabel}>千卡</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailsRow}>
|
||||||
|
<View style={styles.detailsText}>
|
||||||
|
<Text style={styles.lastWorkoutLabel}>{cardContent.label}</Text>
|
||||||
|
<Text style={styles.lastWorkoutTime}>{cardContent.time}</Text>
|
||||||
|
<Text style={styles.sourceText}>{cardContent.source}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.badgesRow}>
|
||||||
|
{isLoading && <ActivityIndicator size="small" color="#7A8FFF" />}
|
||||||
|
{!isLoading && cardContent.badges.length === 0 && (
|
||||||
|
<View style={styles.badgePlaceholder}>
|
||||||
|
<MaterialCommunityIcons name="sleep" size={16} color="#7A8FFF" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 16,
|
||||||
|
width: '100%',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
titleIcon: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
marginRight: 8,
|
||||||
|
resizeMode: 'contain',
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#1F2355',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#7A8FFF',
|
||||||
|
marginTop: -2,
|
||||||
|
},
|
||||||
|
metricsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
metricItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 6,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
metricDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: '#EEF0FF',
|
||||||
|
marginHorizontal: 12,
|
||||||
|
},
|
||||||
|
metricValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2355',
|
||||||
|
},
|
||||||
|
metricLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#4A5677',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
detailsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
detailsText: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
lastWorkoutLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#1F2355',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
lastWorkoutTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7C85A3',
|
||||||
|
},
|
||||||
|
sourceText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#9AA3C0',
|
||||||
|
},
|
||||||
|
badgesRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#E5E9FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
badgePlaceholder: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#E5E9FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -308,7 +308,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#FFFFFF',
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('挑战鼓励提醒后台任务完成');
|
||||||
|
|||||||
420
utils/health.ts
420
utils/health.ts
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user