Files
digital-pilates/app/nutrition/records.tsx
richarjiang 9bcea25a2f feat(auth): 为未登录用户添加登录引导界面
为目标页面、营养记录、食物添加等功能添加登录状态检查和引导界面,确保用户在未登录状态下能够获得清晰的登录提示和指引。

- 在目标页面添加精美的未登录引导界面,包含渐变背景和登录按钮
- 为食物记录相关组件添加登录状态检查,未登录时自动跳转登录页面
- 重构血氧饱和度卡片为独立数据获取,移除对外部数据依赖
- 移除个人页面的实验性SwiftUI组件,统一使用原生TouchableOpacity
- 清理统计页面和营养记录页面的冗余代码和未使用变量
2025-09-19 15:52:24 +08:00

589 lines
17 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 { CalorieRingChart } from '@/components/CalorieRingChart';
import { DateSelector } from '@/components/DateSelector';
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
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 } from '@/services/dietRecords';
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
import { saveRecognitionResult } from '@/store/foodRecognitionSlice';
import { selectHealthDataByDate } from '@/store/healthSlice';
import {
deleteNutritionRecord,
fetchDailyNutritionData,
fetchNutritionRecords,
selectNutritionLoading,
selectNutritionRecordsByDate,
selectNutritionSummaryByDate
} from '@/store/nutritionSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
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();
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象
const currentSelectedDate = useMemo(() => {
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex, days]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 从 Redux 获取数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
const userProfile = useAppSelector((state) => state.user.profile);
// 从 Redux 获取营养记录数据
const nutritionRecords = useAppSelector(selectNutritionRecordsByDate(currentSelectedDateString));
const nutritionLoading = useAppSelector(selectNutritionLoading);
// 视图模式:按天查看 vs 全部查看
const [viewMode, setViewMode] = useState<ViewMode>('daily');
// 全部记录模式的本地状态
const [allRecords, setAllRecords] = useState<DietRecord[]>([]);
const [allRecordsLoading, setAllRecordsLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [hasMoreData, setHasMoreData] = useState(true);
const [page, setPage] = useState(1);
// 食物添加弹窗状态
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
// 根据视图模式选择使用的数据
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
// 页面聚焦时自动刷新数据
useFocusEffect(
useCallback(() => {
console.log('营养记录页面聚焦,刷新数据...');
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
// 全部记录模式:重新加载数据
const loadAllRecords = async () => {
try {
setAllRecordsLoading(true);
const response = await dispatch(fetchNutritionRecords({
page: 1,
limit: 10,
append: false,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(records);
setHasMoreData(records.length === 10);
setPage(1);
}
setAllRecordsLoading(false);
} catch (error) {
console.error('加载全部记录失败:', error);
setAllRecordsLoading(false);
}
};
loadAllRecords();
}
}, [viewMode, currentSelectedDateString, dispatch])
);
// 当选中日期或视图模式变化时重新加载数据
useEffect(() => {
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
setPage(1); // 重置分页
setAllRecords([]); // 清空记录
// 全部记录模式:加载数据
const loadAllRecords = async () => {
try {
setAllRecordsLoading(true);
const response = await dispatch(fetchNutritionRecords({
page: 1,
limit: 10,
append: false,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(records);
setHasMoreData(records.length === 10);
}
setAllRecordsLoading(false);
} catch (error) {
console.error('加载全部记录失败:', error);
setAllRecordsLoading(false);
}
};
loadAllRecords();
}
}, [viewMode, currentSelectedDateString, dispatch]);
const onRefresh = useCallback(async () => {
try {
setRefreshing(true);
if (viewMode === 'daily') {
await dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
// 全部记录模式:刷新数据
setPage(1);
const response = await dispatch(fetchNutritionRecords({
page: 1,
limit: 10,
append: false,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(records);
setHasMoreData(records.length === 10);
}
}
} catch (error) {
console.error('刷新数据失败:', error);
} finally {
setRefreshing(false);
}
}, [viewMode, currentSelectedDateString, dispatch]);
// 计算营养目标
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 = useCallback(async () => {
if (hasMoreData && !loading && !refreshing && viewMode === 'all') {
try {
const nextPage = page + 1;
const response = await dispatch(fetchNutritionRecords({
page: nextPage,
limit: 10,
append: true,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(prev => [...prev, ...records]);
setHasMoreData(records.length === 10);
setPage(nextPage);
}
} catch (error) {
console.error('加载更多记录失败:', error);
}
}
}, [hasMoreData, loading, refreshing, viewMode, page, dispatch]);
// 删除记录
const handleDeleteRecord = async (recordId: number) => {
try {
if (viewMode === 'daily') {
// 按天查看模式,使用 Redux 删除
await dispatch(deleteNutritionRecord({
recordId,
dateKey: currentSelectedDateString
}));
} else {
// 全部记录模式,从本地状态中移除
await dispatch(deleteNutritionRecord({
recordId,
dateKey: currentSelectedDateString
}));
setAllRecords(prev => prev.filter(record => record.id !== recordId));
}
} catch (error) {
console.error('删除营养记录失败:', error);
}
};
// 处理营养记录卡片点击
const handleRecordPress = (record: DietRecord) => {
// 将 DietRecord 转换为 FoodRecognitionResponse 格式
const recognitionResult: FoodRecognitionResponse = {
items: [{
id: record.id.toString(),
label: record.foodName,
foodName: record.foodName,
portion: record.portionDescription || `${record.estimatedCalories || 0}g`,
calories: record.estimatedCalories || 0,
mealType: record.mealType,
nutritionData: {
proteinGrams: record.proteinGrams || 0,
carbohydrateGrams: record.carbohydrateGrams || 0,
fatGrams: record.fatGrams || 0,
fiberGrams: 0, // DietRecord 中没有纤维数据设为0
}
}],
analysisText: record.foodDescription || `${record.foodName} - ${record.portionDescription}`,
confidence: 95, // 设置一个默认置信度
isFoodDetected: true,
nonFoodMessage: undefined
};
// 生成唯一的识别ID
const recognitionId = `record-${record.id}-${Date.now()}`;
// 保存到 Redux
dispatch(saveRecognitionResult({
id: recognitionId,
result: recognitionResult
}));
// 跳转到分析结果页面
router.push({
pathname: '/food/analysis-result',
params: {
imageUri: record.imageUrl || '',
mealType: record.mealType,
recognitionId: recognitionId,
hideRecordBar: 'true'
}
});
};
// 渲染日期选择器(仅在按天查看模式下显示)
const renderDateSelector = () => {
if (viewMode !== 'daily') return null;
return (
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => setSelectedIndex(index)}
showMonthTitle={true}
disableFutureDates={true}
showCalendarIcon={true}
containerStyle={{
paddingHorizontal: 16
}}
/>
);
};
const renderEmptyState = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
</Text>
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
{viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
</Text>
</View>
</View>
);
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
<NutritionRecordCard
record={item}
onPress={() => handleRecordPress(item)}
onDelete={() => handleDeleteRecord(item.id)}
/>
);
const renderFooter = () => {
if (!hasMoreData) {
return (
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
</Text>
</View>
);
}
if (viewMode === 'all' && displayRecords.length > 0) {
return (
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
</Text>
</TouchableOpacity>
);
}
return null;
};
// 根据当前时间智能判断餐次类型
const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) {
return 'breakfast'; // 5:00-10:59 早餐
} else if (hour >= 11 && hour < 14) {
return 'lunch'; // 11:00-13:59 午餐
} else if (hour >= 17 && hour < 21) {
return 'dinner'; // 17:00-20:59 晚餐
} else {
return 'snack'; // 其他时间默认为零食
}
};
// 添加食物的处理函数
const handleAddFood = () => {
setShowFoodOverlay(true);
};
// 渲染右侧添加按钮
const renderRightButton = () => (
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
onPress={handleAddFood}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colorTokens.primary} />
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<HeaderBar
title="营养记录"
onBack={() => router.back()}
right={renderRightButton()}
/>
{/* {renderViewModeToggle()} */}
{renderDateSelector()}
{/* Calorie Ring Chart */}
<CalorieRingChart
metabolism={healthData?.basalEnergyBurned || 1482}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}
fat={nutritionSummary?.totalFat || 0}
carbs={nutritionSummary?.totalCarbohydrate || 0}
proteinGoal={nutritionGoals.proteinGoal}
fatGoal={nutritionGoals.fatGoal}
carbsGoal={nutritionGoals.carbsGoal}
/>
{(
<FlatList
data={displayRecords}
renderItem={({ item, index }) => renderRecord({ item, index })}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingBottom: 40, paddingTop: 16 }
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colorTokens.primary}
colors={[colorTokens.primary]}
/>
}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderFooter}
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
onEndReachedThreshold={0.1}
/>
)}
{/* 食物添加悬浮窗 */}
<FloatingFoodOverlay
visible={showFoodOverlay}
onClose={() => setShowFoodOverlay(false)}
mealType={getCurrentMealType()}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
viewModeContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 8,
},
monthTitle: {
fontSize: 22,
fontWeight: '800',
},
toggleContainer: {
flexDirection: 'row',
borderRadius: 20,
padding: 2,
},
toggleButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 18,
minWidth: 80,
alignItems: 'center',
},
toggleText: {
fontSize: 14,
fontWeight: '600',
},
daysContainer: {
marginBottom: 12,
},
daysScrollContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
},
dayPill: {
width: 48,
height: 48,
borderRadius: 34,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 3,
},
dayNumber: {
fontSize: 18,
textAlign: 'center',
},
dayLabel: {
fontSize: 12,
marginTop: 2,
textAlign: 'center',
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
fontWeight: '500',
},
listContainer: {
paddingHorizontal: 16,
paddingTop: 8,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
paddingHorizontal: 16,
},
emptyContent: {
alignItems: 'center',
maxWidth: 320,
},
emptyTitle: {
fontSize: 18,
fontWeight: '700',
marginTop: 16,
marginBottom: 8,
textAlign: 'center',
},
emptySubtitle: {
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
lineHeight: 20,
},
footerContainer: {
paddingVertical: 20,
alignItems: 'center',
},
footerText: {
fontSize: 14,
fontWeight: '500',
},
loadMoreButton: {
paddingVertical: 16,
alignItems: 'center',
},
loadMoreText: {
fontSize: 16,
fontWeight: '600',
},
});