Files
digital-pilates/components/WorkoutSummaryCard.tsx
richarjiang ea22901553 feat(background-task): 完善iOS后台任务系统并优化断食通知和UI体验
- 修复iOS后台任务注册时机问题,确保任务能正常触发
- 添加后台任务调试辅助工具和完整测试指南
- 优化断食通知系统,增加防抖机制避免频繁重调度
- 改进断食自动续订逻辑,使用固定时间而非相对时间计算
- 优化统计页面布局,添加身体指标section标题
- 增强饮水详情页面视觉效果,改进卡片样式和配色
- 添加用户反馈入口到个人设置页面
- 完善锻炼摘要卡片条件渲染逻辑
- 增强日志记录和错误处理机制

这些改进显著提升了应用的稳定性、性能和用户体验,特别是在iOS后台任务执行和断食功能方面。
2025-11-05 11:23:33 +08:00

361 lines
10 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 { 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>
{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',
},
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',
},
});