Files
digital-pilates/components/WorkoutSummaryCard.tsx

372 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
import { AnimatedNumber } from '@/components/AnimatedNumber';
import {
addHealthPermissionListener,
checkHealthPermissionStatus,
ensureHealthPermissions,
fetchWorkoutsForDateRange,
getHealthPermissionStatus,
getWorkoutTypeDisplayName,
HealthPermissionStatus,
removeHealthPermissionListener,
WorkoutData
} from '@/utils/health';
import { logger } from '@/utils/logger';
interface WorkoutSummaryCardProps {
date: Date;
style?: ViewStyle;
}
interface WorkoutSummary {
totalCalories: number;
totalMinutes: number;
workouts: WorkoutData[];
lastWorkout: WorkoutData | null;
}
const DEFAULT_SUMMARY: WorkoutSummary = {
totalCalories: 0,
totalMinutes: 0,
workouts: [],
lastWorkout: null,
};
export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, style }) => {
const { t } = useTranslation();
const router = useRouter();
const [summary, setSummary] = useState<WorkoutSummary>(DEFAULT_SUMMARY);
const [isLoading, setIsLoading] = useState(false);
const [resetToken, setResetToken] = useState(0);
const isMountedRef = useRef(true);
const loadWorkoutData = useCallback(async (targetDate: Date) => {
setIsLoading(true);
try {
let permissionStatus = getHealthPermissionStatus();
// 如果当前状态未知或未授权,主动检查并尝试请求权限
if (permissionStatus !== HealthPermissionStatus.Authorized) {
permissionStatus = await checkHealthPermissionStatus(true);
}
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
if (!hasPermission) {
hasPermission = await ensureHealthPermissions();
}
if (!hasPermission) {
logger.warn('尚未获得HealthKit锻炼权限无法加载锻炼数据');
if (isMountedRef.current) {
setSummary(DEFAULT_SUMMARY);
}
return;
}
// 修改获取从过去30天到选中日期之间的运动记录
const startDate = dayjs(targetDate).subtract(30, 'day').startOf('day').toDate();
const endDate = dayjs(targetDate).endOf('day').toDate();
const workouts = await fetchWorkoutsForDateRange(startDate, endDate, 1);
// 筛选出选中日期及以前的运动记录,并按结束时间排序(最新在前)
const workoutsBeforeDate = workouts
.filter((workout) => {
// 确保锻炼记录在选中日期或之前
const workoutDate = dayjs(workout.startDate);
return workoutDate.isSameOrBefore(dayjs(targetDate), 'day');
})
// 依据结束时间排序,最新在前
.sort((a, b) => dayjs(b.endDate || b.startDate).valueOf() - dayjs(a.endDate || a.startDate).valueOf());
// 只获取最近的一次运动记录
const lastWorkout = workoutsBeforeDate.length > 0 ? workoutsBeforeDate[0] : null;
// 如果有最近一次运动记录,只使用这一条记录来计算总卡路里和总分钟数
const totalCalories = lastWorkout ? (lastWorkout.totalEnergyBurned || 0) : 0;
const totalMinutes = lastWorkout ? Math.round((lastWorkout.duration || 0) / 60) : 0;
// 只包含最近一次运动记录
const recentWorkouts = lastWorkout ? [lastWorkout] : [];
if (isMountedRef.current) {
setSummary({
totalCalories,
totalMinutes,
workouts: recentWorkouts,
lastWorkout,
});
setResetToken((token) => token + 1);
}
} catch (error) {
logger.error('加载锻炼数据失败', error);
if (isMountedRef.current) {
setSummary(DEFAULT_SUMMARY);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, []);
useEffect(() => {
isMountedRef.current = true;
loadWorkoutData(date);
return () => {
isMountedRef.current = false;
};
}, [date, loadWorkoutData]);
useEffect(() => {
const handlePermissionGranted = () => {
loadWorkoutData(date);
};
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
return () => {
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
};
}, [date, loadWorkoutData]);
const handlePress = useCallback(() => {
router.push('/workout/history');
}, [router]);
const cardContent = useMemo(() => {
const hasWorkouts = summary.workouts.length > 0;
const lastWorkout = summary.lastWorkout;
const label = lastWorkout
? getWorkoutTypeDisplayName(lastWorkout.workoutActivityType)
: t('statistics.components.workout.noData');
const time = lastWorkout
? `${dayjs(lastWorkout.endDate || lastWorkout.startDate).format('HH:mm')} ${t('statistics.components.workout.updated')}`
: t('statistics.components.workout.syncing');
let source = t('statistics.components.workout.sourceWaiting');
if (hasWorkouts) {
const sourceNames = summary.workouts
.map((workout) => workout.source?.name?.trim() || workout.source?.bundleIdentifier?.trim())
.filter((name): name is string => Boolean(name));
if (sourceNames.length) {
const uniqueNames = Array.from(new Set(sourceNames));
const displayNames = uniqueNames.slice(0, 2).join('、');
source = uniqueNames.length > 2
? t('statistics.components.workout.sourceFormatMultiple', { source: displayNames })
: t('statistics.components.workout.sourceFormat', { source: displayNames });
} else {
source = t('statistics.components.workout.sourceUnknown');
}
}
const seen = new Set<number>();
const uniqueBadges: WorkoutData[] = [];
for (const workout of summary.workouts) {
if (!seen.has(workout.workoutActivityType)) {
seen.add(workout.workoutActivityType);
uniqueBadges.push(workout);
}
if (uniqueBadges.length >= 3) {
break;
}
}
return {
label,
time,
source,
badges: uniqueBadges,
};
}, [summary, t]);
return (
<TouchableOpacity
activeOpacity={0.9}
style={[styles.container, style]}
onPress={handlePress}
>
<View style={styles.headerRow}>
<View style={styles.titleRow}>
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
<Text style={styles.titleText}>{t('statistics.components.workout.title')}</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<AnimatedNumber value={summary.totalMinutes} resetToken={resetToken} style={styles.metricValue} />
<Text style={styles.metricLabel}>{t('statistics.components.workout.minutes')}</Text>
</View>
<View style={styles.metricItem}>
<AnimatedNumber value={summary.totalCalories} resetToken={resetToken} style={styles.metricValue} />
<Text style={styles.metricLabel}>{t('statistics.components.workout.kcal')}</Text>
</View>
</View>
{summary.workouts.length > 0 && (
<View style={styles.detailsRow}>
<View style={styles.detailsText}>
<Text style={styles.lastWorkoutLabel}>{cardContent.label}</Text>
<Text style={styles.lastWorkoutTime}>{cardContent.time}</Text>
<Text style={styles.sourceText}>{cardContent.source}</Text>
</View>
<View style={styles.badgesRow}>
{isLoading && <ActivityIndicator size="small" color="#7A8FFF" />}
{!isLoading && cardContent.badges.length === 0 && (
<View style={styles.badgePlaceholder}>
<MaterialCommunityIcons name="sleep" size={16} color="#7A8FFF" />
</View>
)}
</View>
</View>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 18,
paddingHorizontal: 18,
paddingVertical: 16,
width: '100%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
titleIcon: {
width: 20,
height: 20,
marginRight: 8,
resizeMode: 'contain',
},
titleText: {
fontSize: 16,
color: '#1F2355',
fontWeight: '600',
fontFamily: 'AliBold',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 3,
},
addButtonText: {
fontSize: 20,
color: '#7A8FFF',
marginTop: -2,
fontFamily: 'AliBold',
},
metricsRow: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
marginBottom: 10,
},
metricItem: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 6,
flex: 1,
},
metricDivider: {
width: 1,
height: 28,
backgroundColor: '#EEF0FF',
marginHorizontal: 12,
},
metricValue: {
fontSize: 24,
fontWeight: '700',
color: '#1F2355',
fontFamily: 'AliBold',
},
metricLabel: {
fontSize: 12,
color: '#4A5677',
fontWeight: '500',
marginBottom: 2,
fontFamily: 'AliRegular',
},
detailsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
detailsText: {
flex: 1,
gap: 2,
},
lastWorkoutLabel: {
fontSize: 13,
color: '#1F2355',
fontWeight: '500',
fontFamily: 'AliRegular',
},
lastWorkoutTime: {
fontSize: 12,
color: '#7C85A3',
fontFamily: 'AliRegular',
},
sourceText: {
fontSize: 11,
color: '#9AA3C0',
fontFamily: 'AliRegular',
},
badgesRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
badge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E5E9FF',
alignItems: 'center',
justifyContent: 'center',
},
badgePlaceholder: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E5E9FF',
alignItems: 'center',
justifyContent: 'center',
},
});