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 { useLocalSearchParams } from 'expo-router'; import React, { useCallback, useMemo, useState } from 'react'; import { ActivityIndicator, SectionList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal'; import { useI18n } from '@/hooks/useI18n'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail'; import { addHealthPermissionListener, checkHealthPermissionStatus, ensureHealthPermissions, fetchWorkoutsForDateRange, getHealthPermissionStatus, getWorkoutTypeDisplayName, HealthPermissionStatus, removeHealthPermissionListener, WorkoutActivityType, WorkoutData, } from '@/utils/health'; type WorkoutSection = { title: string; data: WorkoutData[]; }; const ICON_MAP: Partial> = { // 球类运动 [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', }; type ActivitySummary = { type: WorkoutActivityType; duration: number; count: number; displayName: string; iconName: keyof typeof MaterialCommunityIcons.glyphMap; }; type MonthlyStatsInfo = { items: ActivitySummary[]; totalDuration: number; totalCount: number; monthStart: string; monthEnd: string; snapshotDate: string; }; const MONTHLY_STAT_COLORS = [ { background: '#FDE9F4', pill: '#F8CDE2', bar: '#F9CFE3', icon: '#2F2965', label: '#5A648C' }, { background: '#FFF3D6', pill: '#FFE3A4', bar: '#FFE0A6', icon: '#2F2965', label: '#5A648C' }, { background: '#E3F5F3', pill: '#CBEAE4', bar: '#D7EEE8', icon: '#2F2965', label: '#5A648C' }, ]; function formatDurationShort(durationInSeconds: number): string { const totalMinutes = Math.max(Math.round(durationInSeconds / 60), 1); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours > 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(t: (key: string, options?: any) => string, totalCalories?: number, durationInSeconds?: number): { label: string; color: string; background: string } { if (!totalCalories || !durationInSeconds) { return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' }; } const minutes = Math.max(durationInSeconds / 60, 1); const caloriesPerMinute = totalCalories / minutes; if (caloriesPerMinute >= 9) { return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' }; } if (caloriesPerMinute >= 5) { return { label: t('workoutHistory.intensity.medium'), color: '#0EAF71', background: '#E4F6EF' }; } return { label: t('workoutHistory.intensity.low'), color: '#5966FF', background: '#E7EBFF' }; } function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] { const grouped = workouts.reduce>((acc, workout) => { const dateKey = dayjs(workout.startDate || workout.endDate).format('YYYY-MM-DD'); if (!acc[dateKey]) { acc[dateKey] = []; } acc[dateKey].push(workout); return acc; }, {}); return Object.keys(grouped) .sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf()) .map((dateKey) => ({ title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式 data: grouped[dateKey] .sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()), })); } export default function WorkoutHistoryScreen() { const { t } = useI18n(); const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>(); 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 [pendingWorkoutId, setPendingWorkoutId] = useState(null); const safeAreaTop = useSafeAreaTop(); React.useEffect(() => { if (!workoutIdParam) { return; } const idParam = Array.isArray(workoutIdParam) ? workoutIdParam[0] : workoutIdParam; if (idParam) { setPendingWorkoutId(idParam); } }, [workoutIdParam]); 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(t('workoutHistory.error.permissionDenied')); setMonthlyStats(null); 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); setMonthlyStats(computeMonthlyStats(filteredWorkouts)); setSections(groupWorkouts(filteredWorkouts)); } catch (err) { console.error('Failed to load workout history:', err); setError(t('workoutHistory.error.loadFailed')); setSections([]); setMonthlyStats(null); } finally { setIsLoading(false); } }, []); useFocusEffect( useCallback(() => { loadHistory(); }, [loadHistory]) ); React.useEffect(() => { const handlePermissionGranted = () => { loadHistory(); }; addHealthPermissionListener('permissionGranted', handlePermissionGranted); return () => { removeHealthPermissionListener('permissionGranted', handlePermissionGranted); }; }, [loadHistory]); 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 ? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) }) : t('workoutHistory.monthlyStats.overviewEmpty'); const periodText = t('workoutHistory.monthlyStats.periodText', { day: monthEndDay }); const maxDuration = statsItems[0]?.duration || 1; return ( {/* 历史 最近一个月的锻炼记录 */} {/* 统计 */} {t('workoutHistory.monthlyStats.title')} {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} ); }) ) : ( {t('workoutHistory.monthlyStats.emptyData')} )} ); }, [monthlyStats]); const emptyComponent = useMemo(() => ( {t('workoutHistory.empty.title')} {t('workoutHistory.empty.subtitle')} ), []); 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 t('workoutHistory.monthOccurrence', { month: workoutDate.format('M月'), index: index + 1, activity: activityLabel }); }, [sections]); const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => { setDetailLoading(true); setDetailError(null); try { const metrics = await getWorkoutDetailMetrics(workout); setDetailMetrics(metrics); } catch (err) { console.error('Failed to load workout details:', err); setDetailMetrics(null); setDetailError(t('workoutHistory.error.detailLoadFailed')); } finally { setDetailLoading(false); } }, []); const handleWorkoutPress = useCallback((workout: WorkoutData) => { const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0); setSelectedIntensity(intensity); setSelectedWorkout(workout); setDetailMetrics(null); setDetailError(null); setMonthOccurrenceText(computeMonthlyOccurrenceText(workout)); setIsDetailVisible(true); loadWorkoutDetail(workout); }, [computeMonthlyOccurrenceText, loadWorkoutDetail]); React.useEffect(() => { if (!pendingWorkoutId || isLoading) { return; } const allWorkouts = sections.flatMap((section) => section.data); const targetWorkout = allWorkouts.find((workout) => workout.id === pendingWorkoutId); if (targetWorkout) { handleWorkoutPress(targetWorkout); } // 清理待处理状态,避免重复触发 setPendingWorkoutId(null); }, [pendingWorkoutId, isLoading, sections, handleWorkoutPress]); 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); const intensity = getIntensityBadge(t, 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 ( handleWorkoutPress(item)} > {t('workoutHistory.historyCard.calories', { calories, minutes })} {intensity.label} {t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })} {/* */} ); }, [handleWorkoutPress]); const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => ( {section.title} ), []); return ( {isLoading ? ( {t('workoutHistory.loading')} ) : ( item.id} renderItem={renderItem} renderSectionHeader={renderSectionHeader} ListHeaderComponent={headerComponent} ListEmptyComponent={error ? ( {error} {t('workoutHistory.retry')} ) : emptyComponent} contentContainerStyle={styles.listContent} stickySectionHeadersEnabled={false} showsVerticalScrollIndicator={false} /> )} ); } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: 'transparent', }, sectionList: { flex: 1, }, headerContainer: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 16, }, headerTitle: { fontSize: 26, fontWeight: '700', color: '#1F2355', marginBottom: 6, }, headerSubtitle: { fontSize: 14, color: '#677086', }, 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, }, 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', }, });