feat: 支持营养圆环

This commit is contained in:
richarjiang
2025-08-31 16:30:08 +08:00
parent 4bb0576d92
commit fe634ba258
8 changed files with 400 additions and 158 deletions

View File

@@ -16,7 +16,7 @@ export default function TabLayout() {
return (
<Tabs
initialRouteName="coach"
initialRouteName="statistics"
screenOptions={({ route }) => {
const routeName = route.name;
const isSelected = (routeName === 'explore' && pathname === ROUTES.TAB_EXPLORE) ||

View File

@@ -1,4 +1,3 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
@@ -332,7 +331,7 @@ export default function ExploreScreen() {
{/* 营养摄入雷达图卡片 */}
<NutritionRadarCard
nutritionSummary={nutritionSummary}
burnedCalories={basalMetabolism || 0}
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
calorieDeficit={0}
resetToken={animToken}
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
@@ -355,28 +354,6 @@ export default function ExploreScreen() {
/>
</FloatingCard>
<FloatingCard style={styles.masonryCard} delay={500}>
<Text style={styles.cardTitle}></Text>
<View style={{
flexDirection: 'row',
alignItems: 'flex-end',
marginTop: 20
}}>
{activeCalories != null ? (
<AnimatedNumber
value={activeCalories}
resetToken={animToken}
style={styles.caloriesValue}
format={(v) => `${Math.round(v)}`}
/>
) : (
<Text style={styles.caloriesValue}></Text>
)}
<Text style={styles.caloriesUnit}></Text>
</View>
</FloatingCard>
<FloatingCard style={styles.masonryCard}>
<StepsCard
stepCount={stepCount}
@@ -392,6 +369,15 @@ export default function ExploreScreen() {
hrvValue={hrvValue}
/>
</FloatingCard>
{/* 心率卡片 */}
<FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
heartRate={heartRate}
/>
</FloatingCard>
</View>
{/* 右列 */}
@@ -439,14 +425,6 @@ export default function ExploreScreen() {
/>
</FloatingCard>
{/* 心率卡片 */}
<FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
heartRate={heartRate}
/>
</FloatingCard>
</View>
</View>

View File

@@ -26,7 +26,7 @@ export default function SplashScreen() {
// router.replace('/onboarding');
// }
// setIsLoading(false);
router.replace(ROUTES.TAB_COACH);
router.replace(ROUTES.TAB_STATISTICS);
} catch (error) {
console.error('检查引导状态失败:', error);
// 如果出现错误,默认显示引导页面

View File

@@ -1,18 +1,22 @@
import { CalorieRingChart } from '@/components/CalorieRingChart';
import { DateSelector } from '@/components/DateSelector';
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { DietRecord, deleteDietRecord, getDietRecords } from '@/services/dietRecords';
import { selectHealthDataByDate } from '@/store/healthSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
FlatList,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@@ -24,12 +28,26 @@ type ViewMode = 'daily' | 'all';
export default function NutritionRecordsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
// 日期相关状态 - 使用与统计页面相同的日期逻辑
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 获取当前选中日期
const getCurrentSelectedDate = () => {
return days[selectedIndex]?.date?.toDate() ?? new Date();
};
const currentSelectedDate = getCurrentSelectedDate();
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
// 从 Redux 获取数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
const userProfile = useAppSelector((state) => state.user.profile);
// 视图模式:按天查看 vs 全部查看
const [viewMode, setViewMode] = useState<ViewMode>('daily');
@@ -40,41 +58,6 @@ export default function NutritionRecordsScreen() {
const [hasMoreData, setHasMoreData] = useState(true);
const [page, setPage] = useState(1);
// 日期滚动相关
const daysScrollRef = useRef<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 60; // 48px width + 12px marginRight = 60px total per item
const DAY_PILL_SPACING = 0; // spacing is included in the width above
// 日期滚动控制
const scrollToIndex = (index: number, animated = true) => {
if (scrollWidth <= 0) return;
const itemOffset = index * DAY_PILL_WIDTH;
const scrollViewCenterX = scrollWidth / 2;
const itemCenterX = DAY_PILL_WIDTH / 2;
const centerOffset = Math.max(0, itemOffset - scrollViewCenterX + itemCenterX);
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
};
// 初始化时滚动到选中位置
useEffect(() => {
if (scrollWidth > 0) {
// 延迟滚动以确保ScrollView已经完全渲染
setTimeout(() => {
scrollToIndex(selectedIndex, false);
}, 100);
}
}, [scrollWidth]);
// 选中日期变化时滚动
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
}, [selectedIndex]);
// 加载记录数据
const loadRecords = async (isRefresh = false, loadMore = false) => {
try {
@@ -126,10 +109,50 @@ export default function NutritionRecordsScreen() {
loadRecords();
}, [selectedIndex, viewMode]);
// 当选中日期变化时获取营养数据
useEffect(() => {
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
}
}, [selectedIndex, viewMode, currentSelectedDate, dispatch]);
const onRefresh = () => {
loadRecords(true);
};
// 计算营养目标
const calculateNutritionGoals = () => {
const weight = parseFloat(userProfile?.weight || '70'); // 默认70kg
const height = parseFloat(userProfile?.height || '170'); // 默认170cm
const age = userProfile?.birthDate ?
dayjs().diff(dayjs(userProfile.birthDate), 'year') : 25; // 默认25岁
const isWoman = userProfile?.gender === 'female';
// 基础代谢率计算Mifflin-St Jeor Equation
let bmr;
if (isWoman) {
bmr = 10 * weight + 6.25 * height - 5 * age - 161;
} else {
bmr = 10 * weight + 6.25 * height - 5 * age + 5;
}
// 总热量需求(假设轻度活动)
const totalCalories = bmr * 1.375;
// 计算营养素目标
const proteinGoal = weight * 1.6; // 1.6g/kg
const fatGoal = totalCalories * 0.25 / 9; // 25%来自脂肪9卡/克
const carbsGoal = (totalCalories - proteinGoal * 4 - fatGoal * 9) / 4; // 剩余来自碳水
return {
proteinGoal: Math.round(proteinGoal * 10) / 10,
fatGoal: Math.round(fatGoal * 10) / 10,
carbsGoal: Math.round(carbsGoal * 10) / 10,
};
};
const nutritionGoals = calculateNutritionGoals();
const loadMoreRecords = () => {
if (hasMoreData && !loading && !refreshing) {
loadRecords(false, true);
@@ -254,6 +277,20 @@ export default function NutritionRecordsScreen() {
{renderViewModeToggle()}
{renderDateSelector()}
{/* Calorie Ring Chart */}
<CalorieRingChart
metabolism={healthData?.basalEnergyBurned || 1482}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
goal={userProfile?.dailyCaloriesGoal || 200}
protein={nutritionSummary?.totalProtein || 0}
fat={nutritionSummary?.totalFat || 0}
carbs={nutritionSummary?.totalCarbohydrate || 0}
proteinGoal={nutritionGoals.proteinGoal}
fatGoal={nutritionGoals.fatGoal}
carbsGoal={nutritionGoals.carbsGoal}
/>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colorTokens.primary} />

View File

@@ -0,0 +1,300 @@
import { ThemedText } from '@/components/ThemedText';
import { useThemeColor } from '@/hooks/useThemeColor';
import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet, View } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export type CalorieRingChartProps = {
metabolism: number;
exercise: number;
consumed: number;
goal: number;
protein: number;
fat: number;
carbs: number;
proteinGoal: number;
fatGoal: number;
carbsGoal: number;
};
export function CalorieRingChart({
metabolism,
exercise,
consumed,
goal,
protein,
fat,
carbs,
proteinGoal,
fatGoal,
carbsGoal,
}: CalorieRingChartProps) {
const surfaceColor = useThemeColor({}, 'surface');
const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary');
// 动画值
const animatedProgress = useRef(new Animated.Value(0)).current;
// 计算还能吃多少卡路里
const remainingCalories = metabolism + exercise - consumed - goal;
const canEat = Math.max(0, remainingCalories);
// 计算进度百分比 (用于圆环显示)
const totalAvailable = metabolism + exercise - goal;
const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0;
// 圆环参数 - 更小的圆环以适应布局
const radius = 62;
const strokeWidth = 6;
const center = radius + strokeWidth;
const circumference = 2 * Math.PI * radius;
const strokeDasharray = circumference;
// 动画效果
useEffect(() => {
Animated.timing(animatedProgress, {
toValue: progressPercentage,
duration: 600,
useNativeDriver: false,
}).start();
}, [progressPercentage]);
// 使用动画值计算strokeDashoffset
const strokeDashoffset = animatedProgress.interpolate({
inputRange: [0, 100],
outputRange: [circumference, 0],
extrapolate: 'clamp',
});
return (
<View style={[styles.container, { backgroundColor: surfaceColor }]}>
{/* 左上角公式展示 */}
<View style={styles.formulaContainer}>
<ThemedText style={[styles.formulaText, { color: textSecondaryColor }]}>
= + - -
</ThemedText>
</View>
{/* 主要内容区域 */}
<View style={styles.mainContent}>
{/* 左侧圆环图 */}
<View style={styles.chartContainer}>
<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={progressPercentage > 80 ? "#FF6B6B" : "#E0E0E0"}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={`${strokeDasharray}`}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
{/* 中心内容 */}
<View style={styles.centerContent}>
<ThemedText style={[styles.centerLabel, { color: textSecondaryColor }]}>
</ThemedText>
<ThemedText style={[styles.centerValue, { color: textColor }]}>
{canEat.toLocaleString()}
</ThemedText>
<ThemedText style={[styles.centerPercentage, { color: textSecondaryColor }]}>
{Math.round(progressPercentage)}%
</ThemedText>
</View>
</View>
{/* 右侧数据展示 */}
<View style={styles.dataContainer}>
{/* 各项数值 */}
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{metabolism.toLocaleString()}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{exercise}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{consumed}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{goal}
</ThemedText>
</View>
</View>
</View>
{/* 底部营养素展示 */}
<View style={styles.nutritionContainer}>
<View style={styles.nutritionItem}>
<ThemedText style={[styles.nutritionLabel, { color: textSecondaryColor }]}>
</ThemedText>
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
{protein.toFixed(2)}/{proteinGoal.toFixed(2)}g
</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={[styles.nutritionLabel, { color: textSecondaryColor }]}>
</ThemedText>
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
{fat.toFixed(2)}/{fatGoal.toFixed(2)}g
</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={[styles.nutritionLabel, { color: textSecondaryColor }]}>
</ThemedText>
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
{carbs.toFixed(2)}/{carbsGoal.toFixed(2)}g
</ThemedText>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginHorizontal: 16,
marginBottom: 16,
shadowColor: '#000000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
},
formulaContainer: {
alignItems: 'flex-start',
marginBottom: 12,
},
formulaText: {
fontSize: 12,
fontWeight: '500',
color: '#999999',
lineHeight: 16,
},
mainContent: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
paddingHorizontal: 8,
},
chartContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
width: 140,
flexShrink: 0,
},
centerContent: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
centerLabel: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
marginBottom: 2,
},
centerValue: {
fontSize: 16,
fontWeight: '700',
color: '#333333',
marginBottom: 1,
},
centerPercentage: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
},
dataContainer: {
flex: 1,
marginLeft: 32,
gap: 4,
paddingLeft: 8,
},
dataItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 2,
},
dataIcon: {
width: 6,
height: 6,
borderRadius: 3,
},
dataLabel: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
minWidth: 28,
},
dataValue: {
fontSize: 11,
fontWeight: '600',
color: '#333333',
},
nutritionContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
},
nutritionItem: {
alignItems: 'center',
flex: 1,
},
nutritionLabel: {
fontSize: 10,
fontWeight: '500',
color: '#999999',
marginBottom: 3,
},
nutritionValue: {
fontSize: 11,
fontWeight: '600',
color: '#333333',
},
});

View File

@@ -463,7 +463,10 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -518,7 +521,10 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = false;

6
package-lock.json generated
View File

@@ -5895,6 +5895,7 @@
"version": "5.2.2",
"resolved": "https://mirrors.tencent.com/npm/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
@@ -5921,6 +5922,7 @@
"version": "1.1.3",
"resolved": "https://mirrors.tencent.com/npm/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
@@ -5933,6 +5935,7 @@
"version": "0.6.1",
"resolved": "https://mirrors.tencent.com/npm/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -6179,6 +6182,7 @@
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
@@ -6204,6 +6208,7 @@
"version": "5.0.3",
"resolved": "https://mirrors.tencent.com/npm/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
@@ -6218,6 +6223,7 @@
"version": "3.2.2",
"resolved": "https://mirrors.tencent.com/npm/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",

View File

@@ -1,85 +0,0 @@
declare module 'react-native-svg' {
import * as React from 'react';
import { ViewProps } from 'react-native';
export interface SvgProps extends ViewProps {
width?: number | string;
height?: number | string;
viewBox?: string;
}
export default function Svg(props: React.PropsWithChildren<SvgProps>): React.ReactElement | null;
export interface CommonProps {
fill?: string;
stroke?: string;
strokeWidth?: number;
strokeLinecap?: 'butt' | 'round' | 'square';
strokeLinejoin?: 'miter' | 'round' | 'bevel';
strokeDasharray?: string | number[];
strokeDashoffset?: number;
}
export interface CircleProps extends CommonProps {
cx?: number;
cy?: number;
r?: number;
originX?: number;
originY?: number;
}
export const Circle: React.ComponentType<CircleProps>;
export interface GProps extends CommonProps {
rotation?: number;
originX?: number;
originY?: number;
}
export const G: React.ComponentType<React.PropsWithChildren<GProps>>;
export interface DefsProps { }
export const Defs: React.ComponentType<React.PropsWithChildren<DefsProps>>;
export interface LineProps extends CommonProps {
x1?: number | string;
y1?: number | string;
x2?: number | string;
y2?: number | string;
}
export const Line: React.ComponentType<LineProps>;
export interface LinearGradientProps {
id?: string;
x1?: number | string;
y1?: number | string;
x2?: number | string;
y2?: number | string;
}
export const LinearGradient: React.ComponentType<React.PropsWithChildren<LinearGradientProps>>;
export interface StopProps {
offset?: number | string;
stopColor?: string;
stopOpacity?: number;
}
export const Stop: React.ComponentType<StopProps>;
export interface PolygonProps extends CommonProps {
points?: string;
}
export const Polygon: React.ComponentType<PolygonProps>;
export interface PathProps extends CommonProps {
d?: string;
}
export const Path: React.ComponentType<PathProps>;
export interface TextProps extends CommonProps {
x?: number | string;
y?: number | string;
fontSize?: number | string;
fill?: string;
textAnchor?: 'start' | 'middle' | 'end';
}
export const Text: React.ComponentType<React.PropsWithChildren<TextProps>>;
}