diff --git a/.kilocode/rules/kilo-rule.md b/.kilocode/rules/kilo-rule.md new file mode 100644 index 0000000..4866c6b --- /dev/null +++ b/.kilocode/rules/kilo-rule.md @@ -0,0 +1,7 @@ +# kilo-rule.md +永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android + +## 指导原则 + +- 遇到比较复杂的页面,尽量使用可以复用的组件 +- 不要尝试使用 `npm run ios` 命令 diff --git a/app.json b/app.json index 226db94..e75e74d 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Out Live", "slug": "digital-pilates", - "version": "1.0.16", + "version": "1.0.17", "orientation": "portrait", "scheme": "digitalpilates", "userInterfaceStyle": "light", diff --git a/app/fitness-rings-detail.tsx b/app/fitness-rings-detail.tsx index 171a9a0..375cb1a 100644 --- a/app/fitness-rings-detail.tsx +++ b/app/fitness-rings-detail.tsx @@ -330,9 +330,11 @@ export default function FitnessRingsDetailScreen() { style={[ styles.chartBar, { + flex: 1, height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条 backgroundColor: value > 0 ? color : '#E5E5EA', - opacity: value > 0 ? 1 : 0.5 + opacity: value > 0 ? 1 : 0.5, + marginHorizontal: 0.5 } ]} /> @@ -340,10 +342,19 @@ export default function FitnessRingsDetailScreen() { })} - 00:00 - 06:00 - 12:00 - 18:00 + {chartData.map((_, index) => { + // 只在关键时间点显示标签:0点、6点、12点、18点 + if (index === 0 || index === 6 || index === 12 || index === 18) { + const hour = index; + return ( + + {hour.toString().padStart(2, '0')}:00 + + ); + } + // 对于不显示标签的小时,返回一个占位的View + return ; + })} ); @@ -731,23 +742,25 @@ const styles = StyleSheet.create({ alignItems: 'flex-end', height: 60, marginBottom: 8, - paddingHorizontal: 4, - justifyContent: 'space-between', + paddingHorizontal: 2, }, chartBar: { - width: 3, borderRadius: 1.5, - marginHorizontal: 0.5, }, chartLabels: { flexDirection: 'row', + paddingHorizontal: 2, justifyContent: 'space-between', - paddingHorizontal: 4, }, chartLabel: { - fontSize: 12, + fontSize: 10, color: '#8E8E93', fontWeight: '500', + textAlign: 'center', + flex: 6, // 给显示标签的元素更多空间 + }, + chartLabelSpacer: { + flex: 1, // 占位元素使用较少空间 }, // 锻炼信息样式 exerciseInfo: { diff --git a/app/workout/_layout.tsx b/app/workout/_layout.tsx deleted file mode 100644 index ad4b3a6..0000000 --- a/app/workout/_layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Stack } from 'expo-router'; - -export default function WorkoutLayout() { - return ( - - - - - - - ); -} diff --git a/app/workout/history.tsx b/app/workout/history.tsx index 5ac4df8..6112822 100644 --- a/app/workout/history.tsx +++ b/app/workout/history.tsx @@ -1,6 +1,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; +import isBetween from 'dayjs/plugin/isBetween'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useCallback, useMemo, useState } from 'react'; import { @@ -13,7 +14,9 @@ import { } from 'react-native'; import { HeaderBar } from '@/components/ui/HeaderBar'; +import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal'; +import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail'; import { addHealthPermissionListener, checkHealthPermissionStatus, @@ -138,6 +141,97 @@ const ICON_MAP: Partial 0) { + return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`; + } + + return `${totalMinutes}m`; +} + +// 扩展 dayjs 以支持 isBetween 插件 +dayjs.extend(isBetween); + +function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null { + const now = dayjs(); + const monthStart = now.startOf('month'); + const monthEnd = now.endOf('month'); + + const monthlyEntries = workouts.filter((workout) => { + const workoutDate = dayjs(workout.startDate || workout.endDate); + if (!workoutDate.isValid()) { + return false; + } + return workoutDate.isBetween(monthStart, monthEnd, 'day', '[]'); + }); + + if (monthlyEntries.length === 0) { + return null; + } + + const summaryMap = monthlyEntries.reduce>((acc, workout) => { + const type = workout.workoutActivityType; + if (type === undefined || type === null) { + return acc; + } + + const mapKey = String(type); + + if (!acc[mapKey]) { + acc[mapKey] = { + type, + duration: 0, + count: 0, + displayName: getWorkoutTypeDisplayName(type), + iconName: ICON_MAP[type as WorkoutActivityType] || 'run', + }; + } + + acc[mapKey].duration += workout.duration || 0; + acc[mapKey].count += 1; + return acc; + }, {}); + + const items = Object.values(summaryMap).sort((a, b) => b.duration - a.duration); + const totalDuration = monthlyEntries.reduce((sum, workout) => sum + (workout.duration || 0), 0); + + return { + items, + totalDuration, + totalCount: monthlyEntries.length, + monthStart: monthStart.format('YYYY-MM-DD'), + monthEnd: monthEnd.format('YYYY-MM-DD'), + snapshotDate: now.format('YYYY-MM-DD'), + }; +} + function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) { if (!totalCalories || !durationInSeconds) { return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' }; @@ -180,6 +274,14 @@ export default function WorkoutHistoryScreen() { const [sections, setSections] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [selectedWorkout, setSelectedWorkout] = useState(null); + const [isDetailVisible, setIsDetailVisible] = useState(false); + const [detailMetrics, setDetailMetrics] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [selectedIntensity, setSelectedIntensity] = useState(null); + const [monthOccurrenceText, setMonthOccurrenceText] = useState(null); + const [monthlyStats, setMonthlyStats] = useState(null); const loadHistory = useCallback(async () => { setIsLoading(true); @@ -198,6 +300,7 @@ export default function WorkoutHistoryScreen() { if (!hasPermission) { setSections([]); setError('尚未授予健康数据权限'); + setMonthlyStats(null); return; } @@ -206,11 +309,13 @@ export default function WorkoutHistoryScreen() { const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200); const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0); + setMonthlyStats(computeMonthlyStats(filteredWorkouts)); setSections(groupWorkouts(filteredWorkouts)); } catch (err) { console.error('加载锻炼历史失败:', err); setError('加载锻炼记录失败,请稍后再试'); setSections([]); + setMonthlyStats(null); } finally { setIsLoading(false); } @@ -233,12 +338,77 @@ export default function WorkoutHistoryScreen() { }; }, [loadHistory]); - const headerComponent = useMemo(() => ( - - 历史 - 最近一个月的锻炼记录 - - ), []); + const headerComponent = useMemo(() => { + const statsItems = monthlyStats?.items.slice(0, 3) ?? []; + const monthEndDay = monthlyStats + ? dayjs(monthlyStats.monthEnd).date() + : dayjs().endOf('month').date(); + const snapshotLabel = monthlyStats + ? dayjs(monthlyStats.snapshotDate).format('M月D日') + : dayjs().format('M月D日'); + const overviewText = monthlyStats + ? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}。` + : '本月还没有锻炼记录,动起来收集第一条吧!'; + const periodText = `统计周期:1日 - ${monthEndDay}日(本月)`; + const maxDuration = statsItems[0]?.duration || 1; + + return ( + + {/* 历史 + 最近一个月的锻炼记录 */} + + + {/* 统计 */} + + + 锻炼时间 + {periodText} + {overviewText} + + {statsItems.length > 0 ? ( + statsItems.map((item, index) => { + const palette = MONTHLY_STAT_COLORS[index % MONTHLY_STAT_COLORS.length]; + const ratio = Math.max(Math.min(item.duration / maxDuration, 1), 0.18); + return ( + + + + + + + X{item.count} + + + {formatDurationShort(item.duration)} + {item.displayName} + + + + + ); + }) + ) : ( + + + 本月还没有锻炼数据 + + )} + + + + + ); + }, [monthlyStats]); const emptyComponent = useMemo(() => ( @@ -248,6 +418,77 @@ export default function WorkoutHistoryScreen() { ), []); + const computeMonthlyOccurrenceText = useCallback((workout: WorkoutData): string | null => { + const workoutDate = dayjs(workout.startDate || workout.endDate); + if (!workoutDate.isValid() || sections.length === 0) { + return null; + } + + const sameMonthWorkouts = sections + .flatMap((section) => section.data) + .filter((entry) => { + const entryDate = dayjs(entry.startDate || entry.endDate); + return ( + entryDate.isValid() && + entryDate.isSame(workoutDate, 'month') && + entry.workoutActivityType === workout.workoutActivityType + ); + }); + + const ascending = sameMonthWorkouts.some((entry) => entry.id === workout.id) + ? sameMonthWorkouts + : [...sameMonthWorkouts, workout]; + + ascending.sort( + (a, b) => + dayjs(a.startDate || a.endDate).valueOf() - dayjs(b.startDate || b.endDate).valueOf() + ); + + const index = ascending.findIndex((entry) => entry.id === workout.id); + if (index === -1) { + return null; + } + + const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType); + return `这是你${workoutDate.format('M月')}的第 ${index + 1} 次${activityLabel}。`; + }, [sections]); + + const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => { + setDetailLoading(true); + setDetailError(null); + try { + const metrics = await getWorkoutDetailMetrics(workout); + setDetailMetrics(metrics); + } catch (err) { + console.error('加载锻炼详情失败:', err); + setDetailMetrics(null); + setDetailError('加载锻炼详情失败,请稍后再试'); + } finally { + setDetailLoading(false); + } + }, []); + + const handleWorkoutPress = useCallback((workout: WorkoutData) => { + const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0); + setSelectedIntensity(intensity); + setSelectedWorkout(workout); + setDetailMetrics(null); + setDetailError(null); + setMonthOccurrenceText(computeMonthlyOccurrenceText(workout)); + setIsDetailVisible(true); + loadWorkoutDetail(workout); + }, [computeMonthlyOccurrenceText, loadWorkoutDetail]); + + const handleRetryDetail = useCallback(() => { + if (selectedWorkout) { + loadWorkoutDetail(selectedWorkout); + } + }, [selectedWorkout, loadWorkoutDetail]); + + const handleCloseDetail = useCallback(() => { + setIsDetailVisible(false); + }, []); + const renderItem = useCallback(({ item }: { item: WorkoutData }) => { const calories = Math.round(item.totalEnergyBurned || 0); const minutes = Math.max(Math.round((item.duration || 0) / 60), 1); @@ -257,7 +498,11 @@ export default function WorkoutHistoryScreen() { const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType); return ( - { }}> + handleWorkoutPress(item)} + > @@ -275,7 +520,7 @@ export default function WorkoutHistoryScreen() { {/* */} ); - }, []); + }, [handleWorkoutPress]); const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => ( {section.title} @@ -315,6 +560,17 @@ export default function WorkoutHistoryScreen() { showsVerticalScrollIndicator={false} /> )} + ); } @@ -342,6 +598,103 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#677086', }, + monthlyStatsWrapper: { + }, + monthlyStatsTitle: { + fontSize: 20, + fontWeight: '700', + color: '#1F2355', + marginBottom: 14, + }, + monthlyStatsCardShell: { + borderRadius: 28, + shadowColor: '#5460E54D', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.12, + shadowRadius: 18, + elevation: 8, + }, + monthlyStatsCard: { + paddingHorizontal: 20, + paddingVertical: 22, + borderRadius: 28, + overflow: 'hidden', + gap: 12, + }, + statSectionLabel: { + fontSize: 15, + fontWeight: '600', + color: '#8289A9', + }, + statPeriodText: { + fontSize: 12, + color: '#8C95B0', + }, + statDescription: { + marginTop: 2, + fontSize: 13, + color: '#525A7A', + lineHeight: 18, + }, + summaryRowWrapper: { + marginTop: 12, + }, + summaryRowBackground: { + borderRadius: 24, + overflow: 'hidden', + position: 'relative', + }, + summaryRowFill: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + }, + summaryRowInner: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 18, + gap: 14, + }, + summaryBadge: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 14, + paddingVertical: 9, + borderRadius: 999, + gap: 6, + }, + summaryRowContent: { + flex: 1, + gap: 4, + }, + summaryCount: { + fontSize: 14, + fontWeight: '700', + color: '#2F2965', + }, + summaryDuration: { + fontSize: 16, + fontWeight: '700', + color: '#2F2965', + }, + summaryActivity: { + fontSize: 13, + fontWeight: '500', + color: '#565F7F', + }, + statEmptyState: { + marginTop: 14, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + statEmptyText: { + fontSize: 13, + color: '#7C85A3', + }, listContent: { paddingBottom: 40, }, diff --git a/app/workout/session/[id].tsx b/app/workout/session/[id].tsx deleted file mode 100644 index d270f79..0000000 --- a/app/workout/session/[id].tsx +++ /dev/null @@ -1,1115 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import Animated, { FadeInUp } from 'react-native-reanimated'; - -import { CircularRing } from '@/components/CircularRing'; -import { ThemedText } from '@/components/ThemedText'; -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { palette } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import type { WorkoutExercise, WorkoutSession } from '@/services/workoutsApi'; -import { workoutsApi } from '@/services/workoutsApi'; -import { - clearWorkoutError, - completeWorkoutExercise, - deleteWorkoutSession, - skipWorkoutExercise, - startWorkoutExercise, - startWorkoutSession -} from '@/store/workoutSlice'; -import { useFocusEffect } from '@react-navigation/native'; - -// ==================== 工具函数 ==================== - -// 计算两个时间之间的耗时(秒) -const calculateDuration = (startTime: string, endTime: string): number => { - const start = new Date(startTime); - const end = new Date(endTime); - return Math.floor((end.getTime() - start.getTime()) / 1000); -}; - -// 格式化耗时显示(分钟:秒) -const formatDuration = (seconds: number): string => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}分${remainingSeconds.toString().padStart(2, '0')}秒`; -}; - -// 获取动作的耗时信息 -const getExerciseDuration = (exercise: WorkoutExercise): { duration: number; formatted: string } | null => { - if (exercise.status === 'completed' && exercise.startedAt && exercise.completedAt) { - const duration = calculateDuration(exercise.startedAt, exercise.completedAt); - return { - duration, - formatted: formatDuration(duration) - }; - } - return null; -}; - -const GOAL_TEXT: Record = { - postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, - fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, - posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, - core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, - flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, - rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, - stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, -}; - -// 动态背景组件 -function DynamicBackground({ color }: { color: string }) { - return ( - - - - - - ); -} - -export default function WorkoutSessionDetailScreen() { - const router = useRouter(); - const dispatch = useAppDispatch(); - const params = useLocalSearchParams<{ id: string }>(); - const { exerciseLoading, error } = useAppSelector((s) => s.workout); - - const [session, setSession] = useState(null); - const [exercises, setExercises] = useState([]); - const [loading, setLoading] = useState(true); - - // 本地状态 - const [completionModal, setCompletionModal] = useState<{ - visible: boolean; - exercise: WorkoutExercise | null; - sets: number; - reps: number; - }>({ - visible: false, - exercise: null, - sets: 0, - reps: 0, - }); - - const sessionId = params.id; - - // 加载会话详情 - 每次页面展示时都拉取最新数据 - useFocusEffect( - useCallback(() => { - if (!sessionId) return; - - const loadSessionDetail = async () => { - try { - setLoading(true); - const [sessionDetail, sessionExercises] = await Promise.all([ - workoutsApi.getSessionDetail(sessionId), - workoutsApi.getSessionExercises(sessionId) - ]); - setSession(sessionDetail); - setExercises(sessionExercises); - } catch (error) { - console.error('加载会话详情失败:', error); - Alert.alert('加载失败', '无法加载训练会话详情'); - } finally { - setLoading(false); - } - }; - - loadSessionDetail(); - }, [sessionId]) - ); - - const goalConfig = session?.trainingPlan - ? (GOAL_TEXT[session.trainingPlan.goal] || { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' }) - : { title: session?.name, color: palette.primary, description: '开始你的训练之旅' }; - - // 错误处理 - useEffect(() => { - if (error) { - Alert.alert('错误', error, [ - { text: '确定', onPress: () => dispatch(clearWorkoutError()) } - ]); - } - }, [error, dispatch]); - - // 训练状态统计 - const workoutStats = useMemo(() => { - const exerciseItems = exercises.filter(ex => ex.itemType === 'exercise'); - return { - total: exerciseItems.length, - completed: exerciseItems.filter(ex => ex.status === 'completed').length, - inProgress: exerciseItems.filter(ex => ex.status === 'in_progress').length, - pending: exerciseItems.filter(ex => ex.status === 'pending').length, - skipped: exerciseItems.filter(ex => ex.status === 'skipped').length, - }; - }, [exercises]); - - const completionPercentage = workoutStats.total > 0 - ? Math.round((workoutStats.completed / workoutStats.total) * 100) - : 0; - - // 开始训练会话 - const handleStartWorkout = () => { - if (!session) return; - - Alert.alert( - '开始训练', - '准备好开始训练了吗?', - [ - { text: '取消', style: 'cancel' }, - { - text: '开始', - onPress: async () => { - try { - await dispatch(startWorkoutSession({ sessionId: session.id })).unwrap(); - // 重新加载会话详情 - const updatedSession = await workoutsApi.getSessionDetail(session.id); - setSession(updatedSession); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } catch (error) { - console.error('开始训练失败:', error); - } - } - } - ] - ); - }; - - // 开始动作 - const handleStartExercise = async (exercise: WorkoutExercise) => { - if (!session || exercise.status !== 'pending') return; - - try { - const updatedExercise = await dispatch(startWorkoutExercise({ - sessionId: session.id, - exerciseId: exercise.id - })).unwrap(); - - // 更新本地exercises列表 - setExercises(prev => prev.map(ex => ex.id === exercise.id ? updatedExercise : ex)); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - } catch (error) { - console.error('开始动作失败:', error); - } - }; - - // 显示完成动作模态框 - const handleShowCompleteModal = (exercise: WorkoutExercise) => { - setCompletionModal({ - visible: true, - exercise, - sets: exercise.completedSets || exercise.plannedSets || 0, - reps: exercise.completedReps || exercise.plannedReps || 0, - }); - }; - - // 完成动作 - const handleCompleteExercise = async () => { - const { exercise, sets, reps } = completionModal; - if (!session || !exercise) return; - - try { - const result = await dispatch(completeWorkoutExercise({ - sessionId: session.id, - exerciseId: exercise.id, - dto: { - completedSets: sets, - completedReps: reps, - } - })).unwrap(); - - // 更新本地exercises列表和session - setExercises(prev => prev.map(ex => ex.id === exercise.id ? result.exercise : ex)); - setSession(result.updatedSession); - - setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 }); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } catch (error) { - console.error('完成动作失败:', error); - } - }; - - // 跳过动作 - const handleSkipExercise = (exercise: WorkoutExercise) => { - if (!session) return; - - Alert.alert( - '跳过动作', - `确定要跳过"${exercise.name}"吗?`, - [ - { text: '取消', style: 'cancel' }, - { - text: '跳过', - style: 'destructive', - onPress: async () => { - try { - const result = await dispatch(skipWorkoutExercise({ - sessionId: session.id, - exerciseId: exercise.id - })).unwrap(); - - // 更新本地exercises列表和session - setExercises(prev => prev.map(ex => ex.id === exercise.id ? result.exercise : ex)); - setSession(result.updatedSession); - - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } catch (error) { - console.error('跳过动作失败:', error); - } - } - } - ] - ); - }; - - // 删除训练会话 - const handleDeleteSession = () => { - if (!session) return; - - Alert.alert( - '删除训练会话', - '确定要删除这个训练会话吗?删除后无法恢复。', - [ - { text: '取消', style: 'cancel' }, - { - text: '删除', - style: 'destructive', - onPress: async () => { - try { - await dispatch(deleteWorkoutSession(session.id)).unwrap(); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); - router.back(); - } catch (error) { - console.error('删除会话失败:', error); - } - } - } - ] - ); - }; - - // 获取动作状态文本和颜色 - const getExerciseStatusConfig = (exercise: WorkoutExercise) => { - switch (exercise.status) { - case 'completed': - return { text: '已完成', color: '#22C55E', backgroundColor: '#22C55E15' }; - case 'in_progress': - return { text: '进行中', color: '#F59E0B', backgroundColor: '#F59E0B15' }; - case 'skipped': - return { text: '已跳过', color: '#6B7280', backgroundColor: '#6B728015' }; - default: - return { text: '待开始', color: '#6B7280', backgroundColor: '#6B728015' }; - } - }; - - // 渲染动作卡片 - const renderExerciseItem = ({ item, index }: { item: WorkoutExercise; index: number }) => { - const statusConfig = getExerciseStatusConfig(item); - const isLoading = exerciseLoading === item.id; - - if (item.itemType === 'rest') { - return ( - - - - - - {item.name} - {item.restSec}秒 - - - ); - } - - if (item.itemType === 'note') { - return ( - - - - - - {item.name} - {item.note && {item.note}} - - - ); - } - - return ( - - - - {item.name} - {item.exercise && ( - {item.exercise.categoryName} - )} - - - - {statusConfig.text} - - - - - - {item.plannedSets && ( - - {item.plannedSets} 组 × {item.plannedReps || '-'} 次 - - )} - {item.plannedDurationSec && ( - - 持续 {item.plannedDurationSec} 秒 - - )} - - - {item.status === 'completed' && ( - - - - 实际完成: {item.completedSets || '-'} 组 × {item.completedReps || '-'} 次 - - {(() => { - const durationInfo = getExerciseDuration(item); - return durationInfo ? ( - - - - {durationInfo.formatted} - - - ) : null; - })()} - - - )} - - - {item.status === 'pending' && session?.status === 'in_progress' && ( - handleStartExercise(item)} - disabled={isLoading} - > - - {isLoading ? '开始中...' : '开始'} - - - )} - - {item.status === 'in_progress' && ( - <> - handleShowCompleteModal(item)} - disabled={isLoading} - > - - {isLoading ? '完成中...' : '完成'} - - - handleSkipExercise(item)} - disabled={isLoading} - > - 跳过 - - - )} - - {item.status === 'pending' && ( - handleSkipExercise(item)} - disabled={isLoading} - > - 跳过 - - )} - - - ); - }; - - if (loading) { - return ( - - router.back()} /> - - 加载中... - - - ); - } - - if (!session) { - return ( - - router.back()} /> - - - 训练会话不存在 - 该训练会话可能已被删除 - - - ); - } - - return ( - - {/* 动态背景 */} - - - - router.back()} - withSafeTop={false} - transparent={true} - tone="light" - right={ - session?.status === 'planned' ? ( - router.push(`/training-plan/schedule/select?sessionId=${session.id}`)} - disabled={loading} - > - - - ) : null - } - /> - - - {/* 训练计划信息头部 */} - - {/* 删除按钮 - 右上角 */} - - - - - - - {goalConfig.title} - - {session.name} - - {/* 进度统计文字 */} - {session.status !== 'planned' && ( - - {workoutStats.completed}/{workoutStats.total} 个动作已完成 - - )} - - - {/* 右侧区域:圆环进度或开始按钮 */} - {session.status === 'planned' ? ( - - - - ) : ( - - - - - {completionPercentage}% - - - - )} - - - {/* 训练完成提示 */} - {session.status === 'completed' && ( - - - 训练已完成! - - )} - - {/* 动作列表 */} - item.id} - renderItem={renderExerciseItem} - contentContainerStyle={styles.listContent} - showsVerticalScrollIndicator={false} - /> - - - - {/* 完成动作模态框 */} - setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} - > - setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} - > - e.stopPropagation()} - > - 完成动作 - {completionModal.exercise?.name} - - - - 完成组数 - - setCompletionModal(prev => ({ - ...prev, - sets: Math.max(0, prev.sets - 1) - }))} - > - - - - {completionModal.sets} - setCompletionModal(prev => ({ - ...prev, - sets: Math.min(20, prev.sets + 1) - }))} - > - + - - - - - - 每组次数 - - setCompletionModal(prev => ({ - ...prev, - reps: Math.max(0, prev.reps - 1) - }))} - > - - - - {completionModal.reps} - setCompletionModal(prev => ({ - ...prev, - reps: Math.min(50, prev.reps + 1) - }))} - > - + - - - - - - - 确认完成 - - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - }, - contentWrapper: { - flex: 1, - }, - content: { - flex: 1, - paddingHorizontal: 20, - }, - - // 动态背景 - backgroundOrb: { - position: 'absolute', - width: 300, - height: 300, - borderRadius: 150, - top: -150, - right: -100, - }, - backgroundOrb2: { - position: 'absolute', - width: 400, - height: 400, - borderRadius: 200, - bottom: -200, - left: -150, - }, - - // 计划信息头部 - planHeader: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 16, - marginBottom: 16, - }, - planColorIndicator: { - width: 4, - height: 40, - borderRadius: 2, - marginRight: 12, - }, - planInfo: { - flex: 1, - }, - planTitle: { - fontSize: 18, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - planDescription: { - fontSize: 13, - color: '#5E6468', - opacity: 0.8, - marginBottom: 4, - }, - planProgressStats: { - fontSize: 12, - color: '#6B7280', - marginTop: 4, - }, - - // 圆环进度容器 - circularProgressContainer: { - alignItems: 'center', - justifyContent: 'center', - marginRight: 32, - position: 'relative', - }, - circularProgressText: { - position: 'absolute', - alignItems: 'center', - justifyContent: 'center', - width: 60, - height: 60, - }, - circularProgressPercentage: { - fontSize: 14, - fontWeight: '800', - textAlign: 'center', - }, - - // 删除按钮 - deleteBtn: { - position: 'absolute', - top: 8, - right: 8, - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255, 255, 255, 0.9)', - zIndex: 10, - shadowColor: '#000', - shadowOpacity: 0.1, - shadowRadius: 4, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, - - // 开始训练按钮 - planStartBtn: { - width: 44, - height: 44, - borderRadius: 22, - alignItems: 'center', - justifyContent: 'center', - marginRight: 32, - }, - - // 完成提示 - completedBanner: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#F0FDF4', - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 12, - marginBottom: 16, - gap: 8, - }, - completedBannerText: { - color: '#22C55E', - fontSize: 16, - fontWeight: '700', - }, - - // 列表 - listContent: { - paddingBottom: 40, - }, - - // 动作卡片 - exerciseCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - elevation: 3, - }, - exerciseHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 8, - }, - exerciseInfo: { - flex: 1, - }, - exerciseName: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - exerciseCategory: { - fontSize: 12, - color: '#888F92', - }, - statusBadge: { - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - }, - statusText: { - fontSize: 12, - fontWeight: '600', - }, - exerciseDetails: { - marginBottom: 12, - }, - exerciseParams: { - fontSize: 14, - color: '#5E6468', - marginBottom: 2, - }, - completedInfo: { - backgroundColor: '#F0FDF4', - borderRadius: 8, - padding: 12, - marginBottom: 12, - }, - completedRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - completedText: { - fontSize: 12, - color: '#22C55E', - fontWeight: '600', - flex: 1, - }, - durationBadge: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 6, - paddingVertical: 3, - borderRadius: 6, - gap: 3, - }, - durationText: { - fontSize: 11, - fontWeight: '600', - }, - exerciseActions: { - flexDirection: 'row', - gap: 8, - }, - actionBtn: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignItems: 'center', - }, - startBtn: { - backgroundColor: '#22C55E', - }, - startBtnText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '700', - }, - completeBtn: { - backgroundColor: '#22C55E', - }, - completeBtnText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '700', - }, - skipBtn: { - backgroundColor: '#F3F4F6', - borderWidth: 1, - borderColor: '#E5E7EB', - }, - skipBtnText: { - color: '#6B7280', - fontSize: 14, - fontWeight: '600', - }, - - // 休息卡片 - restCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - borderLeftWidth: 4, - flexDirection: 'row', - alignItems: 'center', - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 2, - }, - restIconContainer: { - marginRight: 12, - }, - restContent: { - flex: 1, - }, - restTitle: { - fontSize: 16, - fontWeight: '700', - color: '#192126', - marginBottom: 4, - }, - restDuration: { - fontSize: 14, - color: '#5E6468', - }, - - // 备注卡片 - noteCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - borderLeftWidth: 4, - flexDirection: 'row', - alignItems: 'flex-start', - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 2, - }, - noteIconContainer: { - marginRight: 12, - marginTop: 2, - }, - noteContent: { - flex: 1, - }, - noteTitle: { - fontSize: 16, - fontWeight: '700', - color: '#192126', - marginBottom: 4, - }, - noteText: { - fontSize: 14, - color: '#5E6468', - lineHeight: 20, - }, - - // 空状态 - emptyContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'flex-start', - paddingTop: 40, - padding: 20, - }, - emptyTitle: { - fontSize: 18, - fontWeight: '700', - color: '#192126', - marginTop: 16, - marginBottom: 8, - }, - emptyText: { - fontSize: 14, - color: '#6B7280', - textAlign: 'center', - marginBottom: 24, - }, - - // 加载状态 - loadingContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - loadingText: { - fontSize: 16, - color: '#6B7280', - }, - - // 模态框 - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.35)', - alignItems: 'center', - justifyContent: 'flex-end', - }, - modalSheet: { - width: '100%', - backgroundColor: '#FFFFFF', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - paddingHorizontal: 16, - paddingTop: 14, - paddingBottom: 24, - }, - modalTitle: { - fontSize: 18, - fontWeight: '800', - marginBottom: 8, - color: '#192126', - textAlign: 'center', - }, - modalSubtitle: { - fontSize: 14, - color: '#6B7280', - textAlign: 'center', - marginBottom: 24, - }, - inputRow: { - flexDirection: 'row', - gap: 16, - marginBottom: 24, - }, - inputBox: { - flex: 1, - }, - inputLabel: { - fontSize: 14, - fontWeight: '600', - color: '#192126', - marginBottom: 12, - }, - counterRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: '#F3F4F6', - borderRadius: 8, - padding: 4, - }, - counterBtn: { - backgroundColor: '#FFFFFF', - width: 32, - height: 32, - borderRadius: 6, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOpacity: 0.05, - shadowRadius: 2, - shadowOffset: { width: 0, height: 1 }, - elevation: 1, - }, - counterBtnText: { - fontWeight: '800', - color: '#192126', - fontSize: 16, - }, - counterValue: { - fontWeight: '700', - color: '#192126', - fontSize: 16, - minWidth: 40, - textAlign: 'center', - }, - confirmBtn: { - paddingVertical: 16, - borderRadius: 12, - alignItems: 'center', - }, - confirmBtnText: { - color: '#FFFFFF', - fontWeight: '800', - fontSize: 16, - }, - - // 添加动作按钮 - addExerciseBtn: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOpacity: 0.1, - shadowRadius: 4, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, -}); diff --git a/app/workout/today.tsx b/app/workout/today.tsx deleted file mode 100644 index adae40e..0000000 --- a/app/workout/today.tsx +++ /dev/null @@ -1,1237 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import Animated, { FadeInUp } from 'react-native-reanimated'; - -import { useGlobalDialog } from '@/components/ui/DialogProvider'; -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { palette } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import type { WorkoutExercise } from '@/services/workoutsApi'; -import { - clearWorkoutError, - completeWorkoutExercise, - createWorkoutSession, - deleteWorkoutSession, - loadWorkoutSessions, - skipWorkoutExercise, - startWorkoutExercise, - startWorkoutSession -} from '@/store/workoutSlice'; -import dayjs from 'dayjs'; -import { ROUTES } from '@/constants/Routes'; - -// ==================== 工具函数 ==================== - -// 计算两个时间之间的耗时(秒) -const calculateDuration = (startTime: string, endTime: string): number => { - const start = dayjs(startTime); - const end = dayjs(endTime); - return Math.floor((end.valueOf() - start.valueOf()) / 1000); -}; - -// 格式化耗时显示(分钟:秒) -const formatDuration = (seconds: number): string => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}分${remainingSeconds.toString().padStart(2, '0')}秒`; -}; - -// 获取动作的耗时信息 -const getExerciseDuration = (exercise: WorkoutExercise): { duration: number; formatted: string } | null => { - if (exercise.status === 'completed' && exercise.startedAt && exercise.completedAt) { - const duration = calculateDuration(exercise.startedAt, exercise.completedAt); - return { - duration, - formatted: formatDuration(duration) - }; - } - return null; -}; - -const GOAL_TEXT: Record = { - postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, - fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, - posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, - core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, - flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, - rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, - stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, -}; - -// 动态背景组件 -function DynamicBackground({ color }: { color: string }) { - return ( - - - - - - ); -} - -export default function TodayWorkoutScreen() { - const router = useRouter(); - const dispatch = useAppDispatch(); - const { currentSession, exercises, sessions, sessionsPagination, loading, exerciseLoading, error } = useAppSelector((s) => s.workout); - const { showConfirm, showActionSheet } = useGlobalDialog(); - - const [refreshing, setRefreshing] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - - // 本地状态 - const [completionModal, setCompletionModal] = useState<{ - visible: boolean; - exercise: WorkoutExercise | null; - sets: number; - reps: number; - }>({ - visible: false, - exercise: null, - sets: 0, - reps: 0, - }); - - - - const goalConfig = currentSession?.trainingPlan - ? (GOAL_TEXT[currentSession.trainingPlan.goal] || { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' }) - : { title: '今日训练', color: palette.primary, description: '开始你的训练之旅' }; - - // 加载训练会话列表 - useEffect(() => { - dispatch(loadWorkoutSessions({ page: 1, limit: 10 })); - }, [dispatch]); - - // 刷新数据 - const handleRefresh = async () => { - setRefreshing(true); - try { - await dispatch(loadWorkoutSessions({ page: 1, limit: 10 })).unwrap(); - } catch (error) { - console.error('刷新失败:', error); - } finally { - setRefreshing(false); - } - }; - - // 加载更多数据 - const handleLoadMore = async () => { - if (loadingMore || loading || !sessionsPagination) return; - - const currentPage = sessionsPagination.page; - const totalPages = sessionsPagination.totalPages; - - if (currentPage >= totalPages) return; // 已经是最后一页 - - setLoadingMore(true); - try { - await dispatch(loadWorkoutSessions({ - page: currentPage + 1, - limit: 10, - append: true // 追加数据而不是替换 - })).unwrap(); - } catch (error) { - console.error('加载更多失败:', error); - } finally { - setLoadingMore(false); - } - }; - - // 错误处理 - useEffect(() => { - if (error) { - Alert.alert('错误', error, [ - { text: '确定', onPress: () => dispatch(clearWorkoutError()) } - ]); - } - }, [error, dispatch]); - - // 训练状态统计 - const workoutStats = useMemo(() => { - const exerciseItems = exercises.filter(ex => ex.itemType === 'exercise'); - return { - total: exerciseItems.length, - completed: exerciseItems.filter(ex => ex.status === 'completed').length, - inProgress: exerciseItems.filter(ex => ex.status === 'in_progress').length, - pending: exerciseItems.filter(ex => ex.status === 'pending').length, - skipped: exerciseItems.filter(ex => ex.status === 'skipped').length, - }; - }, [exercises]); - - const completionPercentage = workoutStats.total > 0 - ? Math.round((workoutStats.completed / workoutStats.total) * 100) - : 0; - - // 开始训练会话 - const handleStartWorkout = () => { - if (!currentSession) return; - - showConfirm( - { - title: '开始训练', - message: '准备好开始今日的训练了吗?', - icon: 'fitness-outline', - }, - () => { - dispatch(startWorkoutSession({ sessionId: currentSession.id })); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - ); - }; - - // 开始动作 - const handleStartExercise = (exercise: WorkoutExercise) => { - if (!currentSession || exercise.status !== 'pending') return; - - dispatch(startWorkoutExercise({ - sessionId: currentSession.id, - exerciseId: exercise.id - })); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - }; - - // 显示完成动作模态框 - const handleShowCompleteModal = (exercise: WorkoutExercise) => { - setCompletionModal({ - visible: true, - exercise, - sets: exercise.completedSets || exercise.plannedSets || 0, - reps: exercise.completedReps || exercise.plannedReps || 0, - }); - }; - - // 完成动作 - const handleCompleteExercise = () => { - const { exercise, sets, reps } = completionModal; - if (!currentSession || !exercise) return; - - dispatch(completeWorkoutExercise({ - sessionId: currentSession.id, - exerciseId: exercise.id, - dto: { - completedSets: sets, - completedReps: reps, - } - })); - - setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 }); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - }; - - // 跳过动作 - const handleSkipExercise = (exercise: WorkoutExercise) => { - if (!currentSession) return; - - showConfirm( - { - title: '跳过动作', - message: `确定要跳过"${exercise.name}"吗?`, - icon: 'play-skip-forward-outline', - destructive: true, - }, - () => { - dispatch(skipWorkoutExercise({ - sessionId: currentSession.id, - exerciseId: exercise.id - })); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - ); - }; - - // 创建新的训练会话 - const handleCreateSession = () => { - showActionSheet( - { - title: '新建训练', - subtitle: '选择创建方式开始你的训练', - }, - [ - { - id: 'custom', - title: '自定义训练', - subtitle: '创建一个空的训练,可以自由添加动作', - icon: 'create-outline', - iconColor: '#3B82F6', - onPress: () => { - // 创建空的自定义会话 - const sessionName = `训练 ${dayjs().format('YYYY年MM月DD日')}`; - dispatch(createWorkoutSession({ - name: sessionName, - scheduledDate: dayjs().format('YYYY-MM-DD') - })); - } - }, - { - id: 'from-plan', - title: '从训练计划导入', - subtitle: '基于现有训练计划创建训练', - icon: 'library-outline', - iconColor: '#10B981', - onPress: () => { - // 跳转到创建页面选择训练计划 - router.push(ROUTES.WORKOUT_CREATE_SESSION); - } - } - ] - ); - }; - - // 删除训练会话 - const handleDeleteSession = () => { - if (!currentSession) return; - - showConfirm( - { - title: '删除训练会话', - message: '确定要删除这个训练会话吗?删除后无法恢复。', - icon: 'trash-outline', - destructive: true, - }, - () => { - dispatch(deleteWorkoutSession(currentSession.id)); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); - // 删除成功后清空当前会话 - // 不需要返回上一页,因为现在显示的是会话列表 - } - ); - }; - - // 获取动作状态文本和颜色 - const getExerciseStatusConfig = (exercise: WorkoutExercise) => { - switch (exercise.status) { - case 'completed': - return { text: '已完成', color: '#22C55E', backgroundColor: '#22C55E15' }; - case 'in_progress': - return { text: '进行中', color: '#F59E0B', backgroundColor: '#F59E0B15' }; - case 'skipped': - return { text: '已跳过', color: '#6B7280', backgroundColor: '#6B728015' }; - default: - return { text: '待开始', color: '#6B7280', backgroundColor: '#6B728015' }; - } - }; - - // 渲染动作卡片 - const renderExerciseItem = ({ item, index }: { item: WorkoutExercise; index: number }) => { - const statusConfig = getExerciseStatusConfig(item); - const isLoading = exerciseLoading === item.id; - - if (item.itemType === 'rest') { - return ( - - - - - - {item.name} - {item.restSec}秒 - - - ); - } - - if (item.itemType === 'note') { - return ( - - - - - - {item.name} - {item.note && {item.note}} - - - ); - } - - return ( - - - - {item.name} - {item.exercise && ( - {item.exercise.categoryName} - )} - - - - {statusConfig.text} - - - - - - {item.plannedSets && ( - - {item.plannedSets} 组 × {item.plannedReps || '-'} 次 - - )} - {item.plannedDurationSec && ( - - 持续 {item.plannedDurationSec} 秒 - - )} - - - {item.status === 'completed' && ( - - - - 实际完成: {item.completedSets || '-'} 组 × {item.completedReps || '-'} 次 - - {(() => { - const durationInfo = getExerciseDuration(item); - return durationInfo ? ( - - - - {durationInfo.formatted} - - - ) : null; - })()} - - - )} - - - {item.status === 'pending' && currentSession?.status === 'in_progress' && ( - handleStartExercise(item)} - disabled={isLoading} - > - - {isLoading ? '开始中...' : '开始'} - - - )} - - {item.status === 'in_progress' && ( - <> - handleShowCompleteModal(item)} - disabled={isLoading} - > - - {isLoading ? '完成中...' : '完成'} - - - handleSkipExercise(item)} - disabled={isLoading} - > - 跳过 - - - )} - - {item.status === 'pending' && ( - handleSkipExercise(item)} - disabled={isLoading} - > - 跳过 - - )} - - - ); - }; - - // 渲染训练会话卡片 - const renderSessionItem = ({ item, index }: { item: any; index: number }) => { - const goalConfig = item.trainingPlan?.goal - ? (GOAL_TEXT[item.trainingPlan.goal] || { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' }) - : { title: '训练会话', color: palette.primary, description: '开始你的训练之旅' }; - - const exerciseCount = item.exercises?.length || 0; - const completedCount = item.exercises?.filter((ex: any) => ex.status === 'completed').length || 0; - - return ( - - { - router.push(`${ROUTES.WORKOUT_SESSION}/${item.id}`); - }} - activeOpacity={0.9} - > - - - {item.name} - {item.trainingPlan && ( - {item.trainingPlan.name} - )} - - 创建时间 {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')} - - - 计划时间 {dayjs(item.scheduledDate ).format('YYYY-MM-DD HH:mm:ss')} - - - - - {item.status === 'completed' ? ( - - 已完成 - - ) : item.status === 'in_progress' ? ( - - 进行中 - - ) : ( - - 待开始 - - )} - - - - {exerciseCount > 0 && ( - - - {completedCount}/{exerciseCount} 个动作已完成 - - - 0 ? (completedCount / exerciseCount) * 100 : 0}%`, - backgroundColor: goalConfig.color - } - ]} - /> - - - )} - - - ); - }; - - if (loading && sessions.length === 0) { - return ( - - router.back()} /> - - 加载中... - - - ); - } - - if (sessions.length === 0 && !loading) { - return ( - - router.back()} /> - - - 暂无训练记录 - 开始你的第一次训练吧 - - 新建训练会话 - - - - ); - } - - return ( - - {/* 动态背景 */} - - - - router.back()} - withSafeTop={false} - transparent={true} - tone="light" - right={ - - - - } - /> - - - {/* 会话列表 */} - item.id} - renderItem={renderSessionItem} - contentContainerStyle={styles.listContent} - showsVerticalScrollIndicator={false} - refreshing={refreshing} - onRefresh={handleRefresh} - onEndReached={handleLoadMore} - onEndReachedThreshold={0.1} - ListFooterComponent={ - loadingMore ? ( - - 加载更多... - - ) : sessionsPagination && sessionsPagination.page >= sessionsPagination.totalPages ? ( - - 已加载全部数据 - - ) : null - } - /> - - - - {/* 完成动作模态框 */} - setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} - > - setCompletionModal({ visible: false, exercise: null, sets: 0, reps: 0 })} - > - e.stopPropagation()} - > - 完成动作 - {completionModal.exercise?.name} - - - - 完成组数 - - setCompletionModal(prev => ({ - ...prev, - sets: Math.max(0, prev.sets - 1) - }))} - > - - - - {completionModal.sets} - setCompletionModal(prev => ({ - ...prev, - sets: Math.min(20, prev.sets + 1) - }))} - > - + - - - - - - 每组次数 - - setCompletionModal(prev => ({ - ...prev, - reps: Math.max(0, prev.reps - 1) - }))} - > - - - - {completionModal.reps} - setCompletionModal(prev => ({ - ...prev, - reps: Math.min(50, prev.reps + 1) - }))} - > - + - - - - - - - 确认完成 - - - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - }, - contentWrapper: { - flex: 1, - }, - content: { - flex: 1, - paddingHorizontal: 20, - }, - - // 动态背景 - backgroundOrb: { - position: 'absolute', - width: 300, - height: 300, - borderRadius: 150, - top: -150, - right: -100, - }, - backgroundOrb2: { - position: 'absolute', - width: 400, - height: 400, - borderRadius: 200, - bottom: -200, - left: -150, - }, - - // 计划信息头部 - planHeader: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 16, - marginBottom: 16, - }, - planColorIndicator: { - width: 4, - height: 40, - borderRadius: 2, - marginRight: 12, - }, - planInfo: { - flex: 1, - }, - planTitle: { - fontSize: 18, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - planDescription: { - fontSize: 13, - color: '#5E6468', - opacity: 0.8, - marginBottom: 4, - }, - planProgressStats: { - fontSize: 12, - color: '#6B7280', - marginTop: 4, - }, - - // 圆环进度容器 - circularProgressContainer: { - alignItems: 'center', - justifyContent: 'center', - marginRight: 32, - position: 'relative', - }, - circularProgressText: { - position: 'absolute', - alignItems: 'center', - justifyContent: 'center', - width: 60, - height: 60, - }, - circularProgressPercentage: { - fontSize: 14, - fontWeight: '800', - textAlign: 'center', - }, - - // 删除按钮 - deleteBtn: { - position: 'absolute', - top: 8, - right: 8, - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255, 255, 255, 0.9)', - zIndex: 10, - shadowColor: '#000', - shadowOpacity: 0.1, - shadowRadius: 4, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, - - // 开始训练按钮 - planStartBtn: { - width: 44, - height: 44, - borderRadius: 22, - alignItems: 'center', - justifyContent: 'center', - marginRight: 32, - }, - - // 完成提示 - completedBanner: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#F0FDF4', - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 12, - marginBottom: 16, - gap: 8, - }, - completedBannerText: { - color: '#22C55E', - fontSize: 16, - fontWeight: '700', - }, - - // 列表 - listContent: { - paddingBottom: 40, - }, - - // 动作卡片 - exerciseCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - elevation: 3, - }, - exerciseHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 8, - }, - exerciseInfo: { - flex: 1, - }, - exerciseName: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - exerciseCategory: { - fontSize: 12, - color: '#888F92', - }, - statusBadge: { - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - }, - statusText: { - fontSize: 12, - fontWeight: '600', - }, - exerciseDetails: { - marginBottom: 12, - }, - exerciseParams: { - fontSize: 14, - color: '#5E6468', - marginBottom: 2, - }, - completedInfo: { - backgroundColor: '#F0FDF4', - borderRadius: 8, - }, - completedRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - completedText: { - fontSize: 12, - color: '#22C55E', - fontWeight: '600', - flex: 1, - }, - durationBadge: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 6, - paddingVertical: 3, - borderRadius: 6, - gap: 3, - }, - durationText: { - fontSize: 11, - fontWeight: '600', - }, - exerciseActions: { - flexDirection: 'row', - gap: 8, - }, - actionBtn: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignItems: 'center', - }, - startBtn: { - backgroundColor: '#22C55E', - }, - startBtnText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '700', - }, - completeBtn: { - backgroundColor: '#22C55E', - }, - completeBtnText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '700', - }, - skipBtn: { - backgroundColor: '#F3F4F6', - borderWidth: 1, - borderColor: '#E5E7EB', - }, - skipBtnText: { - color: '#6B7280', - fontSize: 14, - fontWeight: '600', - }, - - // 休息卡片 - restCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - borderLeftWidth: 4, - flexDirection: 'row', - alignItems: 'center', - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 2, - }, - restIconContainer: { - marginRight: 12, - }, - restContent: { - flex: 1, - }, - restTitle: { - fontSize: 16, - fontWeight: '700', - color: '#192126', - marginBottom: 4, - }, - restDuration: { - fontSize: 14, - color: '#5E6468', - }, - - // 备注卡片 - noteCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - borderLeftWidth: 4, - flexDirection: 'row', - alignItems: 'flex-start', - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 2, - }, - noteIconContainer: { - marginRight: 12, - marginTop: 2, - }, - noteContent: { - flex: 1, - }, - noteTitle: { - fontSize: 16, - fontWeight: '700', - color: '#192126', - marginBottom: 4, - }, - noteText: { - fontSize: 14, - color: '#5E6468', - lineHeight: 20, - }, - - // 空状态 - emptyContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'flex-start', - paddingTop: 40, - padding: 20, - }, - emptyTitle: { - fontSize: 18, - fontWeight: '700', - color: '#192126', - marginTop: 16, - marginBottom: 8, - }, - emptyText: { - fontSize: 14, - color: '#6B7280', - textAlign: 'center', - marginBottom: 24, - }, - createSessionBtn: { - backgroundColor: '#22C55E', - paddingVertical: 12, - paddingHorizontal: 24, - borderRadius: 8, - }, - createSessionBtnText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '700', - }, - - // 加载状态 - loadingContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - loadingText: { - fontSize: 16, - color: '#6B7280', - }, - - // 模态框 - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.35)', - alignItems: 'center', - justifyContent: 'flex-end', - }, - modalSheet: { - width: '100%', - backgroundColor: '#FFFFFF', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - paddingHorizontal: 16, - paddingTop: 14, - paddingBottom: 24, - }, - modalTitle: { - fontSize: 18, - fontWeight: '800', - marginBottom: 8, - color: '#192126', - textAlign: 'center', - }, - modalSubtitle: { - fontSize: 14, - color: '#6B7280', - textAlign: 'center', - marginBottom: 24, - }, - inputRow: { - flexDirection: 'row', - gap: 16, - marginBottom: 24, - }, - inputBox: { - flex: 1, - }, - inputLabel: { - fontSize: 14, - fontWeight: '600', - color: '#192126', - marginBottom: 12, - }, - counterRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: '#F3F4F6', - borderRadius: 8, - padding: 4, - }, - counterBtn: { - backgroundColor: '#FFFFFF', - width: 32, - height: 32, - borderRadius: 6, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOpacity: 0.05, - shadowRadius: 2, - shadowOffset: { width: 0, height: 1 }, - elevation: 1, - }, - counterBtnText: { - fontWeight: '800', - color: '#192126', - fontSize: 16, - }, - counterValue: { - fontWeight: '700', - color: '#192126', - fontSize: 16, - minWidth: 40, - textAlign: 'center', - }, - confirmBtn: { - paddingVertical: 16, - borderRadius: 12, - alignItems: 'center', - }, - confirmBtnText: { - color: '#FFFFFF', - fontWeight: '800', - fontSize: 16, - }, - - // 添加会话按钮 - addSessionBtn: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOpacity: 0.1, - shadowRadius: 4, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, - - // 会话卡片 - sessionCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - marginBottom: 12, - borderLeftWidth: 4, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - elevation: 3, - }, - sessionCardContent: { - padding: 16, - }, - sessionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 12, - }, - sessionInfo: { - flex: 1, - }, - sessionName: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - sessionPlan: { - fontSize: 12, - color: '#888F92', - marginBottom: 2, - }, - sessionDate: { - fontSize: 11, - color: '#6B7280', - }, - sessionStats: { - alignItems: 'flex-end', - }, - sessionProgress: { - marginTop: 8, - }, - progressText: { - fontSize: 12, - color: '#6B7280', - marginTop: 6 - }, - progressBar: { - height: 4, - backgroundColor: '#F3F4F6', - borderRadius: 2, - overflow: 'hidden', - }, - progressFill: { - height: '100%', - borderRadius: 2, - }, - - // 加载更多 - loadMoreContainer: { - paddingVertical: 16, - alignItems: 'center', - }, - loadMoreText: { - fontSize: 12, - color: '#6B7280', - }, -}); diff --git a/components/workout/WorkoutDetailModal.tsx b/components/workout/WorkoutDetailModal.tsx new file mode 100644 index 0000000..b9460da --- /dev/null +++ b/components/workout/WorkoutDetailModal.tsx @@ -0,0 +1,881 @@ +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Dimensions, + Modal, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; + +import { + HeartRateZoneStat, + WorkoutDetailMetrics, +} from '@/services/workoutDetail'; +import { + getWorkoutTypeDisplayName, + WorkoutActivityType, + WorkoutData, +} from '@/utils/health'; + +export interface IntensityBadge { + label: string; + color: string; + background: string; +} + +interface WorkoutDetailModalProps { + visible: boolean; + onClose: () => void; + workout: WorkoutData | null; + metrics: WorkoutDetailMetrics | null; + loading: boolean; + intensityBadge?: IntensityBadge; + monthOccurrenceText?: string; + onRetry?: () => void; + errorMessage?: string | null; +} + +const SCREEN_HEIGHT = Dimensions.get('window').height; +const SHEET_MAX_HEIGHT = SCREEN_HEIGHT * 0.9; + +const HEART_RATE_CHART_MAX_POINTS = 120; + +export function WorkoutDetailModal({ + visible, + onClose, + workout, + metrics, + loading, + intensityBadge, + monthOccurrenceText, + onRetry, + errorMessage, +}: WorkoutDetailModalProps) { + const animation = useRef(new Animated.Value(visible ? 1 : 0)).current; + const [isMounted, setIsMounted] = useState(visible); + const [showIntensityInfo, setShowIntensityInfo] = useState(false); + + useEffect(() => { + if (visible) { + setIsMounted(true); + Animated.timing(animation, { + toValue: 1, + duration: 280, + useNativeDriver: true, + }).start(); + } else { + Animated.timing(animation, { + toValue: 0, + duration: 240, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) { + setIsMounted(false); + } + }); + + setShowIntensityInfo(false); + } + }, [visible, animation]); + + const translateY = animation.interpolate({ + inputRange: [0, 1], + outputRange: [SHEET_MAX_HEIGHT, 0], + }); + + const backdropOpacity = animation.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + + const activityName = workout + ? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType) + : ''; + + const dateInfo = useMemo(() => { + if (!workout) { + return { title: '', subtitle: '' }; + } + + const date = dayjs(workout.startDate || workout.endDate); + if (!date.isValid()) { + return { title: '', subtitle: '' }; + } + + return { + title: date.format('M月D日'), + subtitle: date.format('YYYY年M月D日 dddd HH:mm'), + }; + }, [workout]); + + const heartRateChart = useMemo(() => { + if (!metrics?.heartRateSeries?.length) { + return null; + } + + const sortedSeries = metrics.heartRateSeries; + const trimmed = trimHeartRateSeries(sortedSeries); + + const labels = trimmed.map((point, index) => { + if ( + index === 0 || + index === trimmed.length - 1 || + index === Math.floor(trimmed.length / 2) + ) { + return dayjs(point.timestamp).format('HH:mm'); + } + return ''; + }); + + const data = trimmed.map((point) => Math.round(point.value)); + + return { + labels, + data, + }; + }, [metrics?.heartRateSeries]); + + const handleBackdropPress = () => { + if (!loading) { + onClose(); + } + }; + + if (!isMounted) { + return null; + } + + return ( + + + + + + + + + + + + + + + + + + + + {dateInfo.title} + {dateInfo.subtitle} + + + + + + + + + + + + + {activityName} + {intensityBadge ? ( + + + {intensityBadge.label} + + + ) : null} + + + {dayjs(workout?.startDate || workout?.endDate).format('YYYY年M月D日 dddd HH:mm')} + + + {loading ? ( + + + 正在加载锻炼详情... + + ) : metrics ? ( + <> + + + 体能训练时间 + {metrics.durationLabel} + + + 运动热量 + + {metrics.calories != null ? `${metrics.calories} 千卡` : '--'} + + + + + + + 运动强度 + setShowIntensityInfo(true)} + style={styles.metricInfoButton} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + + + {formatMetsValue(metrics.mets)} + + + + 平均心率 + + {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'} + + + + {monthOccurrenceText ? ( + {monthOccurrenceText} + ) : null} + + ) : ( + + + {errorMessage || '未能获取到完整的锻炼详情'} + + {onRetry ? ( + + 重新加载 + + ) : null} + + )} + + + + + 心率范围 + + + + {loading ? ( + + + + ) : metrics ? ( + <> + + + 平均心率 + + {metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'} + + + + 最高心率 + + {metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'} + + + + 最低心率 + + {metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'} + + + + + {heartRateChart ? ( + LineChart ? ( + + {/* @ts-ignore - react-native-chart-kit types are outdated */} + '#5C55FF', + strokeWidth: 2, + }, + ], + }} + width={Dimensions.get('window').width - 72} + height={220} + fromZero={false} + yAxisSuffix="次/分" + withInnerLines + bezier + chartConfig={{ + backgroundColor: '#FFFFFF', + backgroundGradientFrom: '#FFFFFF', + backgroundGradientTo: '#FFFFFF', + decimalPlaces: 0, + color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`, + labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`, + propsForDots: { + r: '3', + strokeWidth: '2', + stroke: '#FFFFFF', + }, + propsForBackgroundLines: { + strokeDasharray: '3,3', + stroke: '#E3E6F4', + strokeWidth: 1, + }, + fillShadowGradientFromOpacity: 0.1, + fillShadowGradientToOpacity: 0.02, + }} + style={styles.chartStyle} + /> + + ) : ( + + + 图表组件不可用,无法展示心率曲线 + + ) + ) : ( + + + 暂无心率采样数据 + + )} + + ) : ( + + + {errorMessage || '未获取到心率数据'} + + + )} + + + + + 心率训练区间 + + + {loading ? ( + + + + ) : metrics ? ( + metrics.heartRateZones.map(renderHeartRateZone) + ) : ( + 暂无区间统计 + )} + + + + + + {showIntensityInfo ? ( + setShowIntensityInfo(false)} + > + setShowIntensityInfo(false)}> + + { }}> + + + 什么是运动强度? + + 运动强度是你完成一项任务所用的能量估算,是衡量锻炼和其他日常活动能耗强度的指标,单位为 MET(千卡/(千克·小时))。 + + + 因为每个人的代谢状况不同,MET 以身体的静息能耗作为参考,便于衡量不同活动的强度。 + + + 例如:散步(约 3 km/h)相当于 2 METs,意味着它需要消耗静息状态 2 倍的能量。 + + + 注:当设备未提供 METs 值时,系统会根据您的卡路里消耗和锻炼时长自动计算(使用70公斤估算体重)。 + + + 运动强度计算公式 + METs = 活动能耗(千卡/小时) ÷ 静息能耗(1 千卡/小时) + + + + + {'< 3'} + 低强度活动 + + + 3 - 6 + 中强度活动 + + + {'≥ 6'} + 高强度活动 + + + + + + + + ) : null} + + + ); +} + +// 格式化 METs 值显示 +function formatMetsValue(mets: number | null): string { + if (mets == null) { + return '—'; + } + + // 保留一位小数 + const formattedMets = mets.toFixed(1); + return `${formattedMets} METs`; +} + +function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) { + if (series.length <= HEART_RATE_CHART_MAX_POINTS) { + return series; + } + + const step = Math.ceil(series.length / HEART_RATE_CHART_MAX_POINTS); + const reduced = series.filter((_, index) => index % step === 0); + + if (reduced[reduced.length - 1] !== series[series.length - 1]) { + reduced.push(series[series.length - 1]); + } + + return reduced; +} + +function renderHeartRateZone(zone: HeartRateZoneStat) { + return ( + + + + + + {zone.label} + + {zone.durationMinutes} 分钟 · {zone.rangeText} + + + + ); +} + +// Lazy import to avoid circular dependency +let LineChart: any; + +try { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + LineChart = require('react-native-chart-kit').LineChart; +} catch (error) { + console.warn('未安装 react-native-chart-kit,心率图表将不会显示:', error); +} + +const styles = StyleSheet.create({ + modalContainer: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'transparent', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#10122599', + }, + sheetContainer: { + maxHeight: SHEET_MAX_HEIGHT, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 32, + borderTopRightRadius: 32, + overflow: 'hidden', + }, + gradientBackground: { + ...StyleSheet.absoluteFillObject, + }, + handleWrapper: { + alignItems: 'center', + paddingTop: 16, + paddingBottom: 8, + }, + handle: { + width: 42, + height: 5, + borderRadius: 3, + backgroundColor: '#D5D9EB', + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingBottom: 12, + }, + headerIconButton: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + }, + headerTitleWrapper: { + flex: 1, + alignItems: 'center', + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: '#1E2148', + }, + headerSubtitle: { + marginTop: 4, + fontSize: 12, + color: '#7E86A7', + }, + heroIconWrapper: { + position: 'absolute', + right: -20, + top: 60, + }, + contentContainer: { + paddingBottom: 40, + paddingHorizontal: 24, + paddingTop: 8, + }, + summaryCard: { + backgroundColor: '#FFFFFF', + borderRadius: 28, + padding: 20, + marginBottom: 22, + shadowColor: '#646CFF33', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.18, + shadowRadius: 22, + elevation: 8, + }, + summaryHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + activityName: { + fontSize: 24, + fontWeight: '700', + color: '#1E2148', + }, + intensityPill: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 999, + }, + intensityPillText: { + fontSize: 12, + fontWeight: '600', + }, + summarySubtitle: { + marginTop: 8, + fontSize: 13, + color: '#848BA9', + }, + metricsRow: { + flexDirection: 'row', + marginTop: 20, + gap: 12, + }, + metricItem: { + flex: 1, + borderRadius: 18, + backgroundColor: '#F5F6FF', + paddingVertical: 14, + paddingHorizontal: 12, + }, + metricTitle: { + fontSize: 12, + color: '#7A81A3', + marginBottom: 6, + }, + metricTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginBottom: 6, + }, + metricInfoButton: { + padding: 2, + }, + metricValue: { + fontSize: 18, + fontWeight: '700', + color: '#1E2148', + }, + monthOccurrenceText: { + marginTop: 16, + fontSize: 13, + color: '#4B4F75', + }, + loadingBlock: { + marginTop: 32, + alignItems: 'center', + gap: 10, + }, + loadingLabel: { + fontSize: 13, + color: '#7E86A7', + }, + errorBlock: { + marginTop: 24, + alignItems: 'center', + gap: 12, + }, + errorText: { + fontSize: 13, + color: '#F65858', + }, + retryButton: { + paddingHorizontal: 18, + paddingVertical: 8, + backgroundColor: '#5C55FF', + borderRadius: 16, + }, + retryButtonText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 13, + }, + section: { + backgroundColor: '#FFFFFF', + borderRadius: 24, + padding: 20, + marginBottom: 20, + shadowColor: '#10122514', + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.08, + shadowRadius: 20, + elevation: 4, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: '#1E2148', + }, + sectionLoading: { + paddingVertical: 40, + alignItems: 'center', + }, + sectionError: { + alignItems: 'center', + paddingVertical: 18, + }, + errorTextSmall: { + fontSize: 12, + color: '#7E86A7', + }, + heartRateSummaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 18, + }, + heartRateStat: { + flex: 1, + alignItems: 'center', + }, + statLabel: { + fontSize: 12, + color: '#7E86A7', + marginBottom: 4, + }, + statValue: { + fontSize: 18, + fontWeight: '700', + color: '#1E2148', + }, + chartWrapper: { + alignItems: 'center', + }, + chartStyle: { + marginLeft: -10, + }, + chartEmpty: { + paddingVertical: 32, + alignItems: 'center', + gap: 8, + }, + chartEmptyText: { + fontSize: 13, + color: '#9CA3C6', + }, + zoneRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 14, + gap: 12, + }, + zoneBar: { + width: 110, + height: 12, + borderRadius: 999, + overflow: 'hidden', + }, + zoneBarFill: { + height: '100%', + borderRadius: 999, + }, + zoneInfo: { + flex: 1, + }, + zoneLabel: { + fontSize: 14, + fontWeight: '600', + color: '#1E2148', + }, + zoneMeta: { + marginTop: 4, + fontSize: 12, + color: '#7E86A7', + }, + homeIndicatorSpacer: { + height: 28, + }, + infoBackdrop: { + flex: 1, + backgroundColor: '#0F122080', + justifyContent: 'flex-end', + }, + intensityInfoSheet: { + margin: 20, + marginBottom: 34, + backgroundColor: '#FFFFFF', + borderRadius: 28, + paddingHorizontal: 24, + paddingTop: 20, + paddingBottom: 28, + shadowColor: '#1F265933', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.25, + shadowRadius: 24, + }, + intensityHandle: { + alignSelf: 'center', + width: 44, + height: 4, + borderRadius: 999, + backgroundColor: '#E1E4F3', + marginBottom: 16, + }, + intensityInfoTitle: { + fontSize: 20, + fontWeight: '700', + color: '#1E2148', + marginBottom: 12, + }, + intensityInfoText: { + fontSize: 13, + color: '#4C5074', + lineHeight: 20, + marginBottom: 10, + }, + intensityFormula: { + marginTop: 12, + marginBottom: 18, + backgroundColor: '#F4F6FE', + borderRadius: 18, + paddingVertical: 14, + paddingHorizontal: 16, + }, + intensityFormulaLabel: { + fontSize: 12, + color: '#7E86A7', + marginBottom: 6, + }, + intensityFormulaValue: { + fontSize: 14, + fontWeight: '600', + color: '#1F2355', + lineHeight: 20, + }, + intensityLegend: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#E3E6F4', + paddingTop: 16, + gap: 14, + }, + intensityLegendRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + intensityLegendRange: { + fontSize: 16, + fontWeight: '600', + color: '#1E2148', + }, + intensityLegendLabel: { + fontSize: 14, + fontWeight: '600', + }, + intensityLow: { + color: '#5C84FF', + }, + intensityMedium: { + color: '#2CCAA0', + }, + intensityHigh: { + color: '#FF6767', + }, + headerSpacer: { + width: 40, + height: 40, + }, +}); + diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 02ded79..94bc391 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -25,7 +25,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.16 + 1.0.17 CFBundleSignature ???? CFBundleURLTypes @@ -78,8 +78,6 @@ UIBackgroundModes processing - fetch - remote-notification UILaunchStoryboardName SplashScreen diff --git a/package.json b/package.json index 750fc8d..ab971a8 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,8 @@ "version": "1.0.2", "scripts": { "start": "expo start", - "reset-project": "node ./scripts/reset-project.js", - "android": "expo run:android", "ios": "expo run:ios", "ios-device": "expo run:ios --device", - "web": "expo start --web", "lint": "expo lint" }, "dependencies": { diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts index 5512fc3..29dfc46 100644 --- a/services/backgroundTaskManager.ts +++ b/services/backgroundTaskManager.ts @@ -274,9 +274,7 @@ export class BackgroundTaskManager { log.info('[BackgroundTask] 任务未注册, 开始注册...'); // 注册后台任务 - await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, { - minimumInterval: 15 * 2, - }); + await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER); this.isInitialized = true; diff --git a/services/workoutDetail.ts b/services/workoutDetail.ts new file mode 100644 index 0000000..6373542 --- /dev/null +++ b/services/workoutDetail.ts @@ -0,0 +1,279 @@ +import dayjs from 'dayjs'; + +import { + fetchHeartRateSamplesForRange, + HeartRateSample, + WorkoutData, +} from '@/utils/health'; + +export interface WorkoutHeartRatePoint { + timestamp: string; + value: number; +} + +export interface HeartRateZoneStat { + key: string; + label: string; + color: string; + rangeText: string; + durationMinutes: number; + percentage: number; +} + +export interface WorkoutDetailMetrics { + durationLabel: string; + durationSeconds: number; + calories: number | null; + mets: number | null; + averageHeartRate: number | null; + maximumHeartRate: number | null; + minimumHeartRate: number | null; + heartRateSeries: WorkoutHeartRatePoint[]; + heartRateZones: HeartRateZoneStat[]; +} + +const DEFAULT_SAMPLE_GAP_SECONDS = 5; + +const HEART_RATE_ZONES = [ + { + key: 'warmup', + label: '热身放松', + color: '#9FC7FF', + rangeText: '<100次/分', + min: 0, + max: 100, + }, + { + key: 'fatburn', + label: '燃烧脂肪', + color: '#5ED7B0', + rangeText: '100-119次/分', + min: 100, + max: 120, + }, + { + key: 'aerobic', + label: '有氧运动', + color: '#FFB74D', + rangeText: '120-149次/分', + min: 120, + max: 150, + }, + { + key: 'anaerobic', + label: '无氧运动', + color: '#FF826E', + rangeText: '150-169次/分', + min: 150, + max: 170, + }, + { + key: 'max', + label: '身体极限', + color: '#F2A4D8', + rangeText: '≥170次/分', + min: 170, + max: Infinity, + }, +]; + +export async function getWorkoutDetailMetrics( + workout: WorkoutData +): Promise { + const start = dayjs(workout.startDate || workout.endDate); + const end = dayjs(workout.endDate || workout.startDate); + + const safeStart = start.isValid() ? start : dayjs(); + const safeEnd = end.isValid() ? end : safeStart.add(workout.duration || 0, 'second'); + + const heartRateSamples = await fetchHeartRateSamplesForRange( + safeStart.toDate(), + safeEnd.toDate() + ); + + const heartRateSeries = normalizeHeartRateSeries(heartRateSamples); + const { + average: averageHeartRate, + max: maximumHeartRate, + min: minimumHeartRate, + } = calculateHeartRateStats(heartRateSeries, workout); + + const heartRateZones = calculateHeartRateZones(heartRateSeries); + const durationSeconds = Math.max(Math.round(workout.duration || 0), 0); + const durationLabel = formatDuration(durationSeconds); + const calories = workout.totalEnergyBurned != null + ? Math.round(workout.totalEnergyBurned) + : null; + const mets = extractMetsFromMetadata(workout.metadata) || calculateMetsFromWorkoutData(workout); + + return { + durationLabel, + durationSeconds, + calories, + mets, + averageHeartRate, + maximumHeartRate, + minimumHeartRate, + heartRateSeries, + heartRateZones, + }; +} + +function formatDuration(durationSeconds: number): string { + const hours = Math.floor(durationSeconds / 3600); + const minutes = Math.floor((durationSeconds % 3600) / 60); + const seconds = durationSeconds % 60; + + const parts = [hours, minutes, seconds].map((unit) => + unit.toString().padStart(2, '0') + ); + + return parts.join(':'); +} + +function normalizeHeartRateSeries(samples: HeartRateSample[]): WorkoutHeartRatePoint[] { + return samples + .map((sample) => ({ + timestamp: sample.endDate || sample.startDate, + value: Number(sample.value), + })) + .filter((point) => dayjs(point.timestamp).isValid() && Number.isFinite(point.value)) + .sort((a, b) => dayjs(a.timestamp).valueOf() - dayjs(b.timestamp).valueOf()); +} + +function calculateHeartRateStats( + series: WorkoutHeartRatePoint[], + workout: WorkoutData +) { + if (!series.length) { + const fallback = workout.averageHeartRate ?? null; + return { + average: fallback, + max: fallback, + min: fallback, + }; + } + + const values = series.map((point) => point.value); + const sum = values.reduce((acc, value) => acc + value, 0); + + return { + average: Math.round(sum / values.length), + max: Math.round(Math.max(...values)), + min: Math.round(Math.min(...values)), + }; +} + +function calculateHeartRateZones(series: WorkoutHeartRatePoint[]): HeartRateZoneStat[] { + if (!series.length) { + return HEART_RATE_ZONES.map((zone) => ({ + key: zone.key, + label: zone.label, + color: zone.color, + rangeText: zone.rangeText, + durationMinutes: 0, + percentage: 0, + })); + } + + const durations: Record = HEART_RATE_ZONES.reduce((acc, zone) => { + acc[zone.key] = 0; + return acc; + }, {} as Record); + + for (let i = 0; i < series.length; i++) { + const current = series[i]; + const next = series[i + 1]; + + const currentTime = dayjs(current.timestamp); + let nextTime = next ? dayjs(next.timestamp) : currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second'); + + if (!nextTime.isValid() || nextTime.isBefore(currentTime)) { + nextTime = currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second'); + } + + const durationSeconds = Math.max(nextTime.diff(currentTime, 'second'), DEFAULT_SAMPLE_GAP_SECONDS); + const zone = getZoneForValue(current.value); + durations[zone.key] += durationSeconds; + } + + const totalSeconds = Object.values(durations).reduce((acc, value) => acc + value, 0) || 1; + + return HEART_RATE_ZONES.map((zone) => { + const zoneSeconds = durations[zone.key] || 0; + const minutes = Math.round(zoneSeconds / 60); + const percentage = Math.round((zoneSeconds / totalSeconds) * 100); + + return { + key: zone.key, + label: zone.label, + color: zone.color, + rangeText: zone.rangeText, + durationMinutes: minutes, + percentage, + }; + }); +} + +function getZoneForValue(value: number) { + return ( + HEART_RATE_ZONES.find((zone) => value >= zone.min && value < zone.max) || + HEART_RATE_ZONES[HEART_RATE_ZONES.length - 1] + ); +} + +function extractMetsFromMetadata(metadata: Record): number | null { + if (!metadata) { + return null; + } + + const candidates = [ + metadata.HKAverageMETs, + metadata.averageMETs, + metadata.mets, + metadata.METs, + ]; + + for (const candidate of candidates) { + if (candidate !== undefined && candidate !== null && Number.isFinite(Number(candidate))) { + return Math.round(Number(candidate) * 10) / 10; + } + } + + return null; +} + +function calculateMetsFromWorkoutData(workout: WorkoutData): number | null { + // 如果没有卡路里消耗或持续时间数据,无法计算 METs + if (!workout.totalEnergyBurned || !workout.duration || workout.duration <= 0) { + return null; + } + + // 计算活动能耗(千卡/小时) + const durationInHours = workout.duration / 3600; // 将秒转换为小时 + const activeEnergyBurnedPerHour = workout.totalEnergyBurned / durationInHours; + + // 使用估算的平均体重(70公斤)来计算 METs + // METs = 活动能量消耗(千卡/小时) ÷ 体重(千克) + const estimatedWeightKg = 70; // 成年人平均估算体重 + const mets = activeEnergyBurnedPerHour / estimatedWeightKg; + + // 验证计算结果的合理性 + // 一般成年人的静息代谢率约为 1 MET,日常活动通常在 1-12 METs 范围内 + // 高强度运动可能超过 12 METs,但很少超过 20 METs + if (mets < 0.5 || mets > 25) { + console.warn('计算出的 METs 值可能不合理:', { + mets, + totalEnergyBurned: workout.totalEnergyBurned, + duration: workout.duration, + durationInHours, + activeEnergyBurnedPerHour, + estimatedWeightKg + }); + // 即使值可能不合理,也返回计算结果,但记录警告 + } + + // 保留一位小数 + return Math.round(mets * 10) / 10; +} + diff --git a/utils/health.ts b/utils/health.ts index d65be41..f6b4c54 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -26,6 +26,18 @@ export interface WorkoutData { metadata: Record; } +export interface HeartRateSample { + id: string; + startDate: string; + endDate: string; + value: number; + source?: { + name: string; + bundleIdentifier: string; + }; + metadata?: Record; +} + // 锻炼记录查询选项 export interface WorkoutOptions extends HealthDataOptions { limit?: number; // 默认10条 @@ -770,6 +782,44 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise } } +export async function fetchHeartRateSamplesForRange( + startDate: Date, + endDate: Date, + limit: number = 2000 +): Promise { + try { + const options = { + startDate: dayjs(startDate).toISOString(), + endDate: dayjs(endDate).toISOString(), + limit, + }; + + const result = await HealthKitManager.getHeartRateSamples(options); + + if (result && Array.isArray(result.data)) { + const samples: HeartRateSample[] = result.data + .filter((sample: any) => sample && typeof sample.value === 'number' && !Number.isNaN(sample.value)) + .map((sample: any) => ({ + id: sample.id, + startDate: sample.startDate, + endDate: sample.endDate, + value: Number(sample.value), + source: sample.source, + metadata: sample.metadata, + })); + + logSuccess('锻炼心率采样', { count: samples.length, startDate: options.startDate, endDate: options.endDate }); + return samples; + } + + logWarning('锻炼心率采样', '为空或格式错误'); + return []; + } catch (error) { + logError('锻炼心率采样', error); + return []; + } +} + async function fetchHeartRate(options: HealthDataOptions): Promise { try { const result = await HealthKitManager.getHeartRateSamples(options);