- 添加VIP服务权限检查hook,支持免费使用次数限制 - 为食物识别功能添加登录验证和VIP权限检查 - 优化RevenueCat用户标识同步逻辑 - 修复会员购买状态检查的类型安全问题 - 为营养成分分析添加登录验证
556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
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();
|
||
pushIfAuthedElseLogin(`${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',
|
||
},
|
||
});
|