359 lines
9.9 KiB
TypeScript
359 lines
9.9 KiB
TypeScript
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||
import dayjs from 'dayjs';
|
||
import { Image } from 'expo-image';
|
||
import { useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||
|
||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||
import {
|
||
addHealthPermissionListener,
|
||
checkHealthPermissionStatus,
|
||
ensureHealthPermissions,
|
||
fetchWorkoutsForDateRange,
|
||
getHealthPermissionStatus,
|
||
getWorkoutTypeDisplayName,
|
||
HealthPermissionStatus,
|
||
removeHealthPermissionListener,
|
||
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 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)
|
||
: '尚无锻炼数据';
|
||
|
||
const time = lastWorkout
|
||
? `${dayjs(lastWorkout.endDate || lastWorkout.startDate).format('HH:mm')} 更新`
|
||
: '等待同步';
|
||
|
||
let source = '来源:等待同步';
|
||
if (hasWorkouts) {
|
||
const sourceNames = summary.workouts
|
||
.map((workout) => workout.source?.name?.trim() || workout.source?.bundleIdentifier?.trim())
|
||
.filter((name): name is string => Boolean(name));
|
||
|
||
if (sourceNames.length) {
|
||
const uniqueNames = Array.from(new Set(sourceNames));
|
||
const displayNames = uniqueNames.slice(0, 2).join('、');
|
||
source = uniqueNames.length > 2 ? `来源:${displayNames} 等` : `来源:${displayNames}`;
|
||
} else {
|
||
source = '来源:未知';
|
||
}
|
||
}
|
||
|
||
const seen = new Set<number>();
|
||
const uniqueBadges: WorkoutData[] = [];
|
||
for (const workout of summary.workouts) {
|
||
if (!seen.has(workout.workoutActivityType)) {
|
||
seen.add(workout.workoutActivityType);
|
||
uniqueBadges.push(workout);
|
||
}
|
||
if (uniqueBadges.length >= 3) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return {
|
||
label,
|
||
time,
|
||
source,
|
||
badges: uniqueBadges,
|
||
};
|
||
}, [summary]);
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
activeOpacity={0.9}
|
||
style={[styles.container, style]}
|
||
onPress={handlePress}
|
||
>
|
||
<View style={styles.headerRow}>
|
||
<View style={styles.titleRow}>
|
||
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
|
||
<Text style={styles.titleText}>近期锻炼</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.metricsRow}>
|
||
<View style={styles.metricItem}>
|
||
<AnimatedNumber value={summary.totalMinutes} resetToken={resetToken} style={styles.metricValue} />
|
||
<Text style={styles.metricLabel}>分钟</Text>
|
||
</View>
|
||
<View style={styles.metricItem}>
|
||
<AnimatedNumber value={summary.totalCalories} resetToken={resetToken} style={styles.metricValue} />
|
||
<Text style={styles.metricLabel}>千卡</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.detailsRow}>
|
||
<View style={styles.detailsText}>
|
||
<Text style={styles.lastWorkoutLabel}>{cardContent.label}</Text>
|
||
<Text style={styles.lastWorkoutTime}>{cardContent.time}</Text>
|
||
<Text style={styles.sourceText}>{cardContent.source}</Text>
|
||
</View>
|
||
|
||
<View style={styles.badgesRow}>
|
||
{isLoading && <ActivityIndicator size="small" color="#7A8FFF" />}
|
||
{!isLoading && cardContent.badges.length === 0 && (
|
||
<View style={styles.badgePlaceholder}>
|
||
<MaterialCommunityIcons name="sleep" size={16} color="#7A8FFF" />
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 18,
|
||
paddingHorizontal: 18,
|
||
paddingVertical: 16,
|
||
width: '100%',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 8,
|
||
elevation: 3,
|
||
},
|
||
headerRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
titleRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
titleIcon: {
|
||
width: 20,
|
||
height: 20,
|
||
marginRight: 8,
|
||
resizeMode: 'contain',
|
||
},
|
||
titleText: {
|
||
fontSize: 16,
|
||
color: '#1F2355',
|
||
fontWeight: '600',
|
||
},
|
||
addButton: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
backgroundColor: '#FFFFFF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 4,
|
||
elevation: 3,
|
||
},
|
||
addButtonText: {
|
||
fontSize: 20,
|
||
color: '#7A8FFF',
|
||
marginTop: -2,
|
||
},
|
||
metricsRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-end',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 10,
|
||
},
|
||
metricItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-end',
|
||
gap: 6,
|
||
flex: 1,
|
||
},
|
||
metricDivider: {
|
||
width: 1,
|
||
height: 28,
|
||
backgroundColor: '#EEF0FF',
|
||
marginHorizontal: 12,
|
||
},
|
||
metricValue: {
|
||
fontSize: 24,
|
||
fontWeight: '700',
|
||
color: '#1F2355',
|
||
},
|
||
metricLabel: {
|
||
fontSize: 12,
|
||
color: '#4A5677',
|
||
fontWeight: '500',
|
||
marginBottom: 2,
|
||
},
|
||
detailsRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
gap: 12,
|
||
},
|
||
detailsText: {
|
||
flex: 1,
|
||
gap: 2,
|
||
},
|
||
lastWorkoutLabel: {
|
||
fontSize: 13,
|
||
color: '#1F2355',
|
||
fontWeight: '500',
|
||
},
|
||
lastWorkoutTime: {
|
||
fontSize: 12,
|
||
color: '#7C85A3',
|
||
},
|
||
sourceText: {
|
||
fontSize: 11,
|
||
color: '#9AA3C0',
|
||
},
|
||
badgesRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
badge: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
backgroundColor: '#E5E9FF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
badgePlaceholder: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
backgroundColor: '#E5E9FF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
});
|