Files
digital-pilates/components/NutritionRadarCard.tsx
richarjiang b75a8991ac feat(auth): 添加登录验证到食物记录相关功能
- 在食物拍照、语音记录和营养成分分析功能中添加登录验证
- 使用 ensureLoggedIn 方法确保用户已登录后再调用服务端接口
- 使用 pushIfAuthedElseLogin 方法处理需要登录的页面导航
- 添加新的营养图标资源
- 在路由常量中添加 FOOD_CAMERA 路由定义
- 更新 Memory Bank 任务文档,记录登录验证和路由常量管理的实现模式
2025-10-16 17:45:52 +08:00

556 lines
16 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 { AnimatedNumber } from '@/components/AnimatedNumber';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useActiveCalories } from '@/hooks/useActiveCalories';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { triggerLightHaptic } from '@/utils/haptics';
import { calculateRemainingCalories } from '@/utils/nutrition';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export type NutritionRadarCardProps = {
selectedDate?: Date;
style?: object;
/** 动画重置令牌 */
resetToken?: number;
};
// 简化的圆环进度组件
const SimpleRingProgress = ({
remainingCalories,
totalAvailable
}: {
remainingCalories: number;
totalAvailable: number;
}) => {
const animatedProgress = useRef(new Animated.Value(0)).current;
const radius = 32;
const strokeWidth = 8; // 增加圆环厚度
const center = radius + strokeWidth;
const circumference = 2 * Math.PI * radius;
// 计算进度:已消耗 / 总可用,进度越高表示剩余越少
const consumedAmount = totalAvailable - remainingCalories;
const calorieProgress = totalAvailable > 0 ? Math.min((consumedAmount / totalAvailable) * 100, 100) : 0;
useEffect(() => {
Animated.timing(animatedProgress, {
toValue: calorieProgress,
duration: 600,
useNativeDriver: false,
}).start();
}, [calorieProgress]);
const strokeDashoffset = animatedProgress.interpolate({
inputRange: [0, 100],
outputRange: [circumference, 0],
extrapolate: 'clamp',
});
return (
<View style={{ alignItems: 'center' }}>
<Svg width={center * 2} height={center * 2}>
<Circle
cx={center}
cy={center}
r={radius}
stroke="#F0F0F0"
strokeWidth={strokeWidth}
fill="none"
/>
<AnimatedCircle
cx={center}
cy={center}
r={radius}
stroke={calorieProgress > 80 ? "#FF6B6B" : "#4ECDC4"}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={`${circumference}`}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
<View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, left: 0, right: 0, bottom: 0 }}>
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126' }}>
{Math.round(remainingCalories)}
</Text>
<Text style={{ fontSize: 8, color: '#9AA3AE' }}></Text>
</View>
</View>
);
};
export function NutritionRadarCard({
selectedDate,
style,
resetToken,
}: NutritionRadarCardProps) {
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [loading, setLoading] = useState(false);
const { pushIfAuthedElseLogin } = useAuthGuard();
const dispatch = useAppDispatch();
const dateKey = useMemo(() => {
return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
}, [selectedDate]);
// 使用专用的选择器获取营养数据和基础代谢
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(dateKey));
const basalMetabolism = useAppSelector(selectBasalMetabolismByDate(dateKey));
// 使用专用的hook获取运动消耗卡路里
const { activeCalories: effectiveActiveCalories, loading: activeCaloriesLoading } = useActiveCalories(selectedDate);
// 获取营养数据和基础代谢数据
useEffect(() => {
const loadNutritionCardData = async () => {
const targetDate = selectedDate || new Date();
try {
setLoading(true);
await Promise.all([
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
]);
} catch (error) {
console.error('NutritionRadarCard: 获取营养卡片数据失败:', error);
} finally {
setLoading(false);
}
};
loadNutritionCardData();
}, [selectedDate, dispatch]);
const nutritionStats = useMemo(() => {
return [
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
{ label: '纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
];
}, [nutritionSummary]);
// 计算还能吃的卡路里
const consumedCalories = nutritionSummary?.totalCalories || 0;
// 使用从HealthKit获取的数据如果没有则使用默认值
const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值
const remainingCalories = calculateRemainingCalories({
basalMetabolism: effectiveBasalMetabolism,
activeCalories: effectiveActiveCalories,
consumedCalories,
});
const handleNavigateToRecords = () => {
triggerLightHaptic();
router.push(ROUTES.NUTRITION_RECORDS);
};
return (
<TouchableOpacity style={[styles.card, style]} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.titleContainer}>
<Image
source={require('@/assets/images/icons/icon-healthy-diet.png')}
style={styles.titleIcon}
/>
<Text style={styles.cardTitle}></Text>
</View>
<Text style={styles.cardSubtitle}>
{loading ? '加载中...' : `更新: ${dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}`}
</Text>
</View>
<View style={styles.contentContainer}>
<View style={styles.radarContainer}>
<SimpleRingProgress
remainingCalories={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
totalAvailable={(loading || activeCaloriesLoading) ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
/>
</View>
<View style={styles.statsContainer}>
<View style={styles.statsBackground}>
{nutritionStats.map((stat) => (
<View key={stat.label} style={styles.statItem}>
<Text style={styles.statLabel}>{stat.label}</Text>
<Text style={styles.statValue}>{stat.value}</Text>
</View>
))}
</View>
</View>
</View>
{/* 卡路里计算区域 */}
<View style={styles.calorieSection}>
<View style={styles.calorieContent}>
<View style={styles.calculationRow}>
<Text style={styles.calorieSubtitle}></Text>
<View style={styles.remainingCaloriesContainer}>
<AnimatedNumber
value={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
resetToken={resetToken}
style={styles.mainValue}
format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calorieUnit}></Text>
</View>
<Text style={styles.calculationText}> = </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={loading ? 0 : effectiveBasalMetabolism}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calculationText}> + </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={activeCaloriesLoading ? 0 : effectiveActiveCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => activeCaloriesLoading ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calculationText}> - </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={loading ? 0 : consumedCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
</View>
</View>
</View>
{/* 添加食物选项 */}
<View style={styles.foodOptionsContainer}>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
router.push(`${ROUTES.FOOD_CAMERA}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-camera.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}>AI识别</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-food.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-broadcast.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
router.push(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-yingyang.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
marginTop: 12
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
cardTitle: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
},
cardSubtitle: {
fontSize: 10,
color: '#9AA3AE',
fontWeight: '600',
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
},
radarContainer: {
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
width: 78, // Fixed width to match ring chart size
height: 78, // Fixed height to match ring chart size
},
statsContainer: {
flex: 1,
marginLeft: 4
},
statsBackground: {
backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色
borderRadius: 12,
padding: 12,
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.06,
shadowRadius: 3,
elevation: 1,
// 添加边框增强毛玻璃效果
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.8)',
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
width: '48%',
marginBottom: 8,
},
statDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 8,
},
statLabel: {
fontSize: 10,
color: '#9AA3AE',
flex: 1,
},
statValue: {
fontSize: 12,
color: '#192126',
fontWeight: '600',
},
// 卡路里相关样式
calorieSection: {
marginTop: 6,
},
calorieTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
calorieIcon: {
fontSize: 16,
marginRight: 8,
},
calorieTitle: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
},
calorieContent: {
},
calorieSubtitle: {
fontSize: 10,
color: '#64748B',
fontWeight: '600',
marginRight: 4,
},
calculationRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 4,
},
mainValue: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
},
calculationText: {
fontSize: 10,
fontWeight: '600',
color: '#64748B',
},
calculationItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
calculationLabel: {
fontSize: 9,
color: '#64748B',
fontWeight: '500',
},
calculationValue: {
fontSize: 11,
fontWeight: '700',
color: '#192126',
},
remainingCaloriesContainer: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 2,
},
calorieUnit: {
fontSize: 10,
color: '#64748B',
fontWeight: '500',
},
mealsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
},
mealItem: {
alignItems: 'center',
flex: 1,
},
mealIconContainer: {
position: 'relative',
marginBottom: 6,
},
mealEmoji: {
fontSize: 24,
},
mealName: {
fontSize: 10,
color: '#64748B',
fontWeight: '600',
},
// 食物选项样式
foodOptionsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
paddingHorizontal: 4,
},
foodOptionItem: {
alignItems: 'center',
flex: 1,
paddingHorizontal: 2,
},
foodOptionIcon: {
width: 24,
height: 24,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 6,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
foodOptionEmoji: {
fontSize: 14,
},
foodOptionImage: {
width: 20,
height: 20,
resizeMode: 'contain',
},
foodOptionText: {
fontSize: 10,
fontWeight: '500',
color: '#192126',
textAlign: 'center',
},
});