diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 12baaba..bc1e10e 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -6,17 +6,16 @@ import { ThemedView } from '@/components/ThemedView'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { listRecommendedArticles } from '@/services/articles'; import { fetchRecommendations, RecommendationType } from '@/services/recommendations'; import { loadPlans } from '@/store/trainingPlanSlice'; // Removed WorkoutCard import since we no longer use the horizontal carousel +import { QUERY_PARAMS, ROUTE_PARAMS, ROUTES } from '@/constants/Routes'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { TrainingPlan } from '@/services/trainingPlanApi'; import { useRouter } from 'expo-router'; import React from 'react'; import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { ROUTES, QUERY_PARAMS, ROUTE_PARAMS } from '@/constants/Routes'; // 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片 @@ -103,42 +102,7 @@ export default function HomeScreen() { readCount: number; }; - // 打底数据(接口不可用时) - const getFallbackItems = React.useCallback((): RecommendItem[] => { - return [ - { - type: 'plan', - key: 'today-workout', - image: - 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', - title: '今日训练', - subtitle: '完成一次普拉提训练,记录你的坚持', - level: '初学者', - onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY), - }, - { - type: 'plan', - key: 'assess', - image: - 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', - title: '体态评估', - subtitle: '评估你的体态,制定训练计划', - level: '初学者', - onPress: () => router.push(ROUTES.AI_POSTURE_ASSESSMENT), - }, - ...listRecommendedArticles().map((a) => ({ - type: 'article' as const, - key: `article-${a.id}`, - id: a.id, - title: a.title, - coverImage: a.coverImage, - publishedAt: a.publishedAt, - readCount: a.readCount, - })), - ]; - }, [router, pushIfAuthedElseLogin]); - - const [items, setItems] = React.useState(() => getFallbackItems()); + const [items, setItems] = React.useState(); // 加载训练计划数据 React.useEffect(() => { @@ -191,15 +155,15 @@ export default function HomeScreen() { } } // 若接口返回空,也回退到打底 - setItems(mapped.length > 0 ? mapped : getFallbackItems()); + setItems(mapped.length > 0 ? mapped : []); } catch (e) { console.error('fetchRecommendations error', e); - setItems(getFallbackItems()); + setItems([]); } } load(); return () => { canceled = true; }; - }, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]); + }, [isLoggedIn, pushIfAuthedElseLogin]); // 处理点击训练计划卡片,跳转到锻炼tab const handlePlanCardPress = () => { @@ -335,7 +299,7 @@ export default function HomeScreen() { 为你推荐 - {items.map((item) => { + {items?.map((item) => { if (item.type === 'article') { return ( s.user.profile?.dailyStepsGoal) ?? 2000; const userProfile = useAppSelector((s) => s.user.profile); + const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); + // 使用 dayjs:当月日期与默认选中“今天” const days = getMonthDaysZh(); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); @@ -67,12 +71,17 @@ export default function ExploreScreen() { const [isLoading, setIsLoading] = useState(false); // 用于触发动画重置的 token(当日期或数据变化时更新) const [animToken, setAnimToken] = useState(0); - const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80% + const [trainingProgress, setTrainingProgress] = useState(0); // 暂定静态80% + + // 营养数据状态 + const [nutritionSummary, setNutritionSummary] = useState(null); + const [isNutritionLoading, setIsNutritionLoading] = useState(false); // 记录最近一次请求的“日期键”,避免旧请求覆盖新结果 const latestRequestKeyRef = useRef(null); - const getDateKey = (d: Date) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; + const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`; + const loadHealthData = async (targetDate?: Date) => { try { @@ -112,11 +121,44 @@ export default function ExploreScreen() { } }; + // 加载营养数据 + const loadNutritionData = async (targetDate?: Date) => { + try { + setIsNutritionLoading(true); + + // 若未显式传入日期,按当前选中索引推导日期 + const derivedDate = targetDate ?? days[selectedIndex]?.date?.toDate() ?? new Date(); + + console.log('加载营养数据...', derivedDate); + const data = await getDietRecords({ + startDate: dayjs(derivedDate).startOf('day').toISOString(), + endDate: dayjs(derivedDate).endOf('day').toISOString(), + }); + + if (data.records.length > 0) { + const summary = calculateNutritionSummary(data.records); + setNutritionSummary(summary); + } else { + setNutritionSummary(null); + } + console.log('营养数据加载完成:', data); + + } catch (error) { + console.error('营养数据加载失败:', error); + setNutritionSummary(null); + } finally { + setIsNutritionLoading(false); + } + }; + useFocusEffect( React.useCallback(() => { // 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致 loadHealthData(); - }, [selectedIndex]) + if (isLoggedIn) { + loadNutritionData(); + } + }, []) ); // 日期点击时,加载对应日期数据 @@ -126,6 +168,9 @@ export default function ExploreScreen() { const target = days[index]?.date?.toDate(); if (target) { loadHealthData(target); + if (isLoggedIn) { + loadNutritionData(target); + } } }; @@ -138,10 +183,10 @@ export default function ExploreScreen() { contentContainerStyle={{ paddingBottom: bottomPadding }} showsVerticalScrollIndicator={false} > - {/* 体重历史记录卡片 */} - 健康数据 + {/* 体重历史记录卡片 */} + 健康数据 - + {/* 标题与日期选择 */} {monthTitle} + {/* 营养摄入雷达图卡片 */} + + {/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */} @@ -230,7 +281,8 @@ export default function ExploreScreen() { height={userProfile?.height ? parseFloat(userProfile.height) : undefined} /> - + + diff --git a/app/index.tsx b/app/index.tsx index ed68baf..f575fe5 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,4 +1,5 @@ import { ThemedView } from '@/components/ThemedView'; +import { ROUTES } from '@/constants/Routes'; import { useThemeColor } from '@/hooks/useThemeColor'; import { router } from 'expo-router'; import React, { useEffect, useState } from 'react'; @@ -25,7 +26,7 @@ export default function SplashScreen() { // router.replace('/onboarding'); // } // setIsLoading(false); - router.replace('/(tabs)'); + router.replace(ROUTES.TAB_COACH); } catch (error) { console.error('检查引导状态失败:', error); // 如果出现错误,默认显示引导页面 diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx new file mode 100644 index 0000000..be0873d --- /dev/null +++ b/components/NutritionRadarCard.tsx @@ -0,0 +1,158 @@ +import { NutritionSummary } from '@/services/dietRecords'; +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { RadarCategory, RadarChart } from './RadarChart'; + +export type NutritionRadarCardProps = { + nutritionSummary: NutritionSummary | null; + isLoading?: boolean; +}; + +// 营养维度定义 +const NUTRITION_DIMENSIONS: RadarCategory[] = [ + { key: 'calories', label: '热量' }, + { key: 'protein', label: '蛋白质' }, + { key: 'carbohydrate', label: '碳水' }, + { key: 'fat', label: '脂肪' }, + { key: 'fiber', label: '膳食纤维' }, + { key: 'sodium', label: '钠含量' }, +]; + +export function NutritionRadarCard({ nutritionSummary, isLoading = false }: NutritionRadarCardProps) { + const radarValues = useMemo(() => { + // 基于推荐日摄入量计算分数 + const recommendations = { + calories: 2000, // 卡路里 + protein: 50, // 蛋白质(g) + carbohydrate: 300, // 碳水化合物(g) + fat: 65, // 脂肪(g) + fiber: 25, // 膳食纤维(g) + sodium: 2300, // 钠(mg) + }; + + if (!nutritionSummary) return [0, 0, 0, 0, 0, 0]; + + return [ + Math.min(5, (nutritionSummary.totalCalories / recommendations.calories) * 5), + Math.min(5, (nutritionSummary.totalProtein / recommendations.protein) * 5), + Math.min(5, (nutritionSummary.totalCarbohydrate / recommendations.carbohydrate) * 5), + Math.min(5, (nutritionSummary.totalFat / recommendations.fat) * 5), + Math.min(5, (nutritionSummary.totalFiber / recommendations.fiber) * 5), + Math.min(5, Math.max(0, 5 - (nutritionSummary.totalSodium / recommendations.sodium) * 5)), // 钠含量越低越好 + ]; + }, [nutritionSummary]); + + 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]); + + return ( + + 营养摄入分析 + + {isLoading ? ( + + 加载中... + + ) : ( + + + + + + + {nutritionStats.map((stat, index) => ( + + + {stat.label} + {stat.value} + + ))} + + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#FFFFFF', + borderRadius: 22, + padding: 18, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + }, + cardTitle: { + fontSize: 18, + fontWeight: '800', + color: '#192126', + marginBottom: 16, + }, + contentContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + radarContainer: { + alignItems: 'center', + marginRight: 20, + }, + statsContainer: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + statItem: { + flexDirection: 'row', + alignItems: 'center', + width: '48%', + marginBottom: 8, + }, + statDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8, + }, + statLabel: { + fontSize: 12, + color: '#9AA3AE', + fontWeight: '600', + flex: 1, + }, + statValue: { + fontSize: 12, + color: '#192126', + fontWeight: '700', + }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + height: 80, + }, + loadingText: { + fontSize: 16, + color: '#9AA3AE', + fontWeight: '600', + }, +}); diff --git a/components/WeightHistoryCard.tsx b/components/WeightHistoryCard.tsx index 0d80c4e..d4b79d6 100644 --- a/components/WeightHistoryCard.tsx +++ b/components/WeightHistoryCard.tsx @@ -1,8 +1,9 @@ +import { ROUTES } from '@/constants/Routes'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { fetchWeightHistory } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; -import { useRouter } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { Dimensions, @@ -26,13 +27,14 @@ type WeightHistoryItem = { }; export function WeightHistoryCard() { - const router = useRouter(); const dispatch = useAppDispatch(); const userProfile = useAppSelector((s) => s.user.profile); const weightHistory = useAppSelector((s) => s.user.weightHistory); const [isLoading, setIsLoading] = useState(false); const [showChart, setShowChart] = useState(false); + const { pushIfAuthedElseLogin } = useAuthGuard(); + const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0; useEffect(() => { @@ -53,7 +55,7 @@ export function WeightHistoryCard() { }; const navigateToCoach = () => { - router.push('/(tabs)/coach'); + pushIfAuthedElseLogin(ROUTES.TAB_COACH); }; // 如果正在加载,显示加载状态 @@ -83,22 +85,19 @@ export function WeightHistoryCard() { 体重记录 - + - - - 开始记录你的体重变化 记录体重变化,追踪你的健康进展 - - - 去记录体重 + + 记录 @@ -119,13 +118,13 @@ export function WeightHistoryCard() { 体重记录 - + 暂无体重记录,点击下方按钮开始记录 - @@ -158,8 +157,8 @@ export function WeightHistoryCard() { }).join(' '); // 如果只有一个数据点,显示为水平线 - const singlePointPath = points.length === 1 ? - `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` : + const singlePointPath = points.length === 1 ? + `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` : pathData; return ( @@ -170,19 +169,19 @@ export function WeightHistoryCard() { 体重记录 - setShowChart(!showChart)} activeOpacity={0.8} > - - @@ -210,8 +209,8 @@ export function WeightHistoryCard() { - setShowChart(true)} activeOpacity={0.8} > @@ -226,100 +225,100 @@ export function WeightHistoryCard() { {/* 背景网格线 */} - {[0, 1, 2, 3, 4].map(i => ( - ( + + ))} + + {/* 折线 */} + - ))} - {/* 折线 */} - + {/* 数据点和标签 */} + {points.map((point, index) => { + const isLastPoint = index === points.length - 1; + const isFirstPoint = index === 0; + const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1; - {/* 数据点和标签 */} - {points.map((point, index) => { - const isLastPoint = index === points.length - 1; - const isFirstPoint = index === 0; - const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1; - - return ( - - - {/* 体重标签 - 只在关键点显示 */} - {showLabel && ( - <> - - - {point.weight} - - - )} - - ); - })} + return ( + + + {/* 体重标签 - 只在关键点显示 */} + {showLabel && ( + <> + + + {point.weight} + + + )} + + ); + })} - + - {/* 图表底部信息 */} - - - 当前体重 - {userProfile.weight}kg + {/* 图表底部信息 */} + + + 当前体重 + {userProfile.weight}kg + + + 记录天数 + {sortedHistory.length}天 + + + 变化范围 + + {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + + - - 记录天数 - {sortedHistory.length}天 - - - 变化范围 - - {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + + {/* 最近记录时间 */} + {sortedHistory.length > 0 && ( + + 最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')} - + )} - - {/* 最近记录时间 */} - {sortedHistory.length > 0 && ( - - 最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')} - - )} - - )} - + )} + ); } @@ -378,16 +377,6 @@ const styles = StyleSheet.create({ }, emptyContent: { alignItems: 'center', - paddingVertical: 20, - }, - emptyIconContainer: { - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: '#F0F8E0', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 12, }, emptyTitle: { fontSize: 16, diff --git a/services/articles.ts b/services/articles.ts index cb2a889..ff3be8a 100644 --- a/services/articles.ts +++ b/services/articles.ts @@ -1,4 +1,3 @@ -import dayjs from 'dayjs'; import { api } from './api'; export type Article = { @@ -10,33 +9,6 @@ export type Article = { readCount: number; }; -const demoArticles: Article[] = [ - { - id: 'intro-pilates-posture', - title: '新手入门:普拉提核心与体态的关系', - coverImage: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', - publishedAt: dayjs().subtract(2, 'day').toISOString(), - readCount: 1268, - htmlContent: ` -

为什么核心很重要?

-

核心是维持良好体态与动作稳定的关键。普拉提通过强调呼吸与深层肌群激活,帮助你在日常站立、坐姿与训练中保持更好的身体对齐。

-

入门建议

-
    -
  1. 从呼吸开始:尝试胸廓外扩而非耸肩。
  2. -
  3. 慢而可控:注意动作过程中的连贯与专注。
  4. -
  5. 记录变化:每周拍照或在应用中记录体态变化。
  6. -
-

更多实操可在本应用的「AI体态评估」中获取个性化建议。

- pilates-illustration - `, - }, -]; - -export function listRecommendedArticles(): Article[] { - // 实际项目中可替换为 API 请求 - return demoArticles; -} - export async function getArticleById(id: string): Promise
{ return api.get
(`/articles/${id}`); } diff --git a/services/dietRecords.ts b/services/dietRecords.ts new file mode 100644 index 0000000..b0b83d8 --- /dev/null +++ b/services/dietRecords.ts @@ -0,0 +1,115 @@ +import { api } from '@/services/api'; + +export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack' | 'other'; +export type RecordSource = 'manual' | 'vision' | 'other'; + +export type DietRecord = { + id: number; + mealType: MealType; + foodName: string; + foodDescription?: string; + weightGrams?: number; + portionDescription?: string; + estimatedCalories?: number; + proteinGrams?: number; + carbohydrateGrams?: number; + fatGrams?: number; + fiberGrams?: number; + sugarGrams?: number; + sodiumMg?: number; + additionalNutrition?: any; + source: RecordSource; + mealTime?: string; + imageUrl?: string; + notes?: string; + createdAt: string; + updatedAt: string; +}; + +export type NutritionSummary = { + totalCalories: number; + totalProtein: number; + totalCarbohydrate: number; + totalFat: number; + totalFiber: number; + totalSugar: number; + totalSodium: number; +}; + +export async function getDietRecords({ + startDate, + endDate, +}: { + startDate: string; + endDate: string; +}): Promise<{ + records: DietRecord[] + total: number + page: number + limit: number +}> { + const params = startDate && endDate ? `?startDate=${startDate}&endDate=${endDate}` : ''; + return await api.get<{ + records: DietRecord[] + total: number + page: number + limit: number + }>(`/users/diet-records${params}`); +} + +export function calculateNutritionSummary(records: DietRecord[]): NutritionSummary { + if (records?.length === 0) { + return { + totalCalories: 0, + totalProtein: 0, + totalCarbohydrate: 0, + totalFat: 0, + totalFiber: 0, + totalSugar: 0, + totalSodium: 0, + }; + } + + return records.reduce( + (summary, record) => ({ + totalCalories: summary.totalCalories + (record.estimatedCalories || 0), + totalProtein: summary.totalProtein + (record.proteinGrams || 0), + totalCarbohydrate: summary.totalCarbohydrate + (record.carbohydrateGrams || 0), + totalFat: summary.totalFat + (record.fatGrams || 0), + totalFiber: summary.totalFiber + (record.fiberGrams || 0), + totalSugar: summary.totalSugar + (record.sugarGrams || 0), + totalSodium: summary.totalSodium + (record.sodiumMg || 0), + }), + { + totalCalories: 0, + totalProtein: 0, + totalCarbohydrate: 0, + totalFat: 0, + totalFiber: 0, + totalSugar: 0, + totalSodium: 0, + } + ); +} + +// 将营养数据转换为雷达图数据(0-5分制) +export function convertToRadarData(summary: NutritionSummary): number[] { + // 基于推荐日摄入量计算分数 + const recommendations = { + calories: 2000, // 卡路里 + protein: 50, // 蛋白质(g) + carbohydrate: 300, // 碳水化合物(g) + fat: 65, // 脂肪(g) + fiber: 25, // 膳食纤维(g) + sodium: 2300, // 钠(mg) + }; + + return [ + Math.min(5, (summary.totalCalories / recommendations.calories) * 5), + Math.min(5, (summary.totalProtein / recommendations.protein) * 5), + Math.min(5, (summary.totalCarbohydrate / recommendations.carbohydrate) * 5), + Math.min(5, (summary.totalFat / recommendations.fat) * 5), + Math.min(5, (summary.totalFiber / recommendations.fiber) * 5), + Math.min(5, Math.max(0, 5 - (summary.totalSodium / recommendations.sodium) * 5)), // 钠含量越低越好 + ]; +} \ No newline at end of file