From fe634ba258f9b062e6553aa27505d526c99cc463 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 31 Aug 2025 16:30:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=90=A5=E5=85=BB?= =?UTF-8?q?=E5=9C=86=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 2 +- app/(tabs)/statistics.tsx | 42 +-- app/index.tsx | 2 +- app/nutrition/records.tsx | 111 ++++--- components/CalorieRingChart.tsx | 300 +++++++++++++++++++ ios/digitalpilates.xcodeproj/project.pbxproj | 10 +- package-lock.json | 6 + types/react-native-svg.d.ts | 85 ------ 8 files changed, 400 insertions(+), 158 deletions(-) create mode 100644 components/CalorieRingChart.tsx delete mode 100644 types/react-native-svg.d.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 65e3493..d78ac7c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -16,7 +16,7 @@ export default function TabLayout() { return ( { const routeName = route.name; const isSelected = (routeName === 'explore' && pathname === ROUTES.TAB_EXPLORE) || diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 3daf22c..707ab43 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -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() { {/* 营养摄入雷达图卡片 */} { @@ -355,28 +354,6 @@ export default function ExploreScreen() { /> - - - 消耗卡路里 - - {activeCalories != null ? ( - `${Math.round(v)}`} - /> - ) : ( - —— - )} - 千卡 - - - + + {/* 心率卡片 */} + + + {/* 右列 */} @@ -439,14 +425,6 @@ export default function ExploreScreen() { /> - {/* 心率卡片 */} - - - diff --git a/app/index.tsx b/app/index.tsx index f575fe5..330cc69 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -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); // 如果出现错误,默认显示引导页面 diff --git a/app/nutrition/records.tsx b/app/nutrition/records.tsx index 19b51cb..5a39c28 100644 --- a/app/nutrition/records.tsx +++ b/app/nutrition/records.tsx @@ -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('daily'); @@ -40,41 +58,6 @@ export default function NutritionRecordsScreen() { const [hasMoreData, setHasMoreData] = useState(true); const [page, setPage] = useState(1); - // 日期滚动相关 - const daysScrollRef = useRef(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 */} + + {loading ? ( diff --git a/components/CalorieRingChart.tsx b/components/CalorieRingChart.tsx new file mode 100644 index 0000000..d08a780 --- /dev/null +++ b/components/CalorieRingChart.tsx @@ -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 ( + + {/* 左上角公式展示 */} + + + 还能吃 = 代谢 + 运动 - 饮食 - 目标 + + + + {/* 主要内容区域 */} + + {/* 左侧圆环图 */} + + + {/* 背景圆环 */} + + {/* 进度圆环 - 保持固定颜色 */} + 80 ? "#FF6B6B" : "#E0E0E0"} + strokeWidth={strokeWidth} + fill="none" + strokeDasharray={`${strokeDasharray}`} + strokeDashoffset={strokeDashoffset} + strokeLinecap="round" + transform={`rotate(-90 ${center} ${center})`} + /> + + + {/* 中心内容 */} + + + 还能吃 + + + {canEat.toLocaleString()}千卡 + + + {Math.round(progressPercentage)}% + + + + + {/* 右侧数据展示 */} + + {/* 各项数值 */} + + 代谢 + + {metabolism.toLocaleString()}千卡 + + + + + 运动 + + {exercise}千卡 + + + + + 饮食 + + {consumed}千卡 + + + + + 目标 + + {goal}千卡 + + + + + + {/* 底部营养素展示 */} + + + + 蛋白质 + + + {protein.toFixed(2)}/{proteinGoal.toFixed(2)}g + + + + + + 脂肪 + + + {fat.toFixed(2)}/{fatGoal.toFixed(2)}g + + + + + + 碳水化合物 + + + {carbs.toFixed(2)}/{carbsGoal.toFixed(2)}g + + + + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/ios/digitalpilates.xcodeproj/project.pbxproj b/ios/digitalpilates.xcodeproj/project.pbxproj index 08f860d..7b57eb0 100644 --- a/ios/digitalpilates.xcodeproj/project.pbxproj +++ b/ios/digitalpilates.xcodeproj/project.pbxproj @@ -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; diff --git a/package-lock.json b/package-lock.json index 8bd79fe..6d18443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/types/react-native-svg.d.ts b/types/react-native-svg.d.ts deleted file mode 100644 index 01a655f..0000000 --- a/types/react-native-svg.d.ts +++ /dev/null @@ -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): 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; - - export interface GProps extends CommonProps { - rotation?: number; - originX?: number; - originY?: number; - } - export const G: React.ComponentType>; - - export interface DefsProps { } - export const Defs: React.ComponentType>; - - export interface LineProps extends CommonProps { - x1?: number | string; - y1?: number | string; - x2?: number | string; - y2?: number | string; - } - export const Line: React.ComponentType; - - export interface LinearGradientProps { - id?: string; - x1?: number | string; - y1?: number | string; - x2?: number | string; - y2?: number | string; - } - export const LinearGradient: React.ComponentType>; - - export interface StopProps { - offset?: number | string; - stopColor?: string; - stopOpacity?: number; - } - export const Stop: React.ComponentType; - - export interface PolygonProps extends CommonProps { - points?: string; - } - export const Polygon: React.ComponentType; - - export interface PathProps extends CommonProps { - d?: string; - } - export const Path: React.ComponentType; - - 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>; -} - -