Files
digital-pilates/app/nutrition/records.tsx
richarjiang bca6670390 Add Chinese translations for medication management and personal settings
- Introduced new translation files for medication, personal, and weight management in Chinese.
- Updated the main index file to include the new translation modules.
- Enhanced the medication type definitions to include 'ointment'.
- Refactored workout type labels to utilize i18n for better localization support.
- Improved sleep quality descriptions and recommendations with i18n integration.
2025-11-28 17:29:51 +08:00

623 lines
19 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 { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
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 { getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
FlatList,
RefreshControl,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
type ViewMode = 'daily' | 'all';
export default function NutritionRecordsScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const isGlassAvailable = isLiquidGlassAvailable();
const { isLoggedIn } = useAuthGuard();
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
// 直接使用 state 管理当前选中日期,而不是从 days 数组派生,以支持 DateSelector 内部月份切换
const [currentSelectedDate, setCurrentSelectedDate] = useState<Date>(new Date());
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);
// 从 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 [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
// 食物添加弹窗状态
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
// 根据视图模式选择使用的数据
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
// 页面聚焦时自动刷新数据
useFocusEffect(
useCallback(() => {
if (!isLoggedIn) return;
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, isLoggedIn])
);
// 当选中日期或视图模式变化时重新加载数据
useEffect(() => {
fetchBasalMetabolismData();
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 fetchBasalMetabolismData = useCallback(async () => {
try {
const options = {
startDate: dayjs(currentSelectedDate).startOf('day').toDate().toISOString(),
endDate: dayjs(currentSelectedDate).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
setBasalMetabolism(basalEnergy || 1482);
} catch (error) {
console.error('获取基础代谢数据失败:', error);
setBasalMetabolism(1482); // 失败时使用默认值
}
}, [currentSelectedDate]);
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 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
onPress={handleAddFood}
activeOpacity={0.7}
>
{isGlassAvailable ? (
<GlassView
style={styles.glassAddButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="add" size={24} color={colorTokens.primary} />
</GlassView>
) : (
<View style={[styles.fallbackAddButton, { backgroundColor: 'rgba(255,255,255,0.8)' }]}>
<Ionicons name="add" size={24} color={colorTokens.primary} />
</View>
)}
</TouchableOpacity>
);
const renderEmptyState = () => (
<View style={styles.emptySimpleContainer}>
<Image
source={require('@/assets/images/icons/icon-yingyang.png')}
style={styles.emptySimpleImage}
contentFit="contain"
/>
<Text style={styles.emptySimpleText}>
{t('nutritionRecords.empty.title')}
</Text>
<TouchableOpacity onPress={handleAddFood}>
<Text style={[styles.emptyActionText, { color: colorTokens.primary }]}>
{t('nutritionRecords.empty.action')}
</Text>
</TouchableOpacity>
</View>
);
const renderRecord = ({ item }: { item: DietRecord }) => (
<NutritionRecordCard
record={item}
onPress={() => handleRecordPress(item)}
onDelete={() => handleDeleteRecord(item.id)}
/>
);
const renderFooter = () => {
if (!hasMoreData) {
if (displayRecords.length === 0) return null;
return (
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
{t('nutritionRecords.footer.end')}
</Text>
</View>
);
}
if (viewMode === 'all' && displayRecords.length > 0) {
return (
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
{t('nutritionRecords.footer.loadMore')}
</Text>
</TouchableOpacity>
);
}
return null;
};
const ListHeader = () => (
<View>
<View style={styles.headerContent}>
{viewMode === 'daily' && (
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => {
setSelectedIndex(index);
setCurrentSelectedDate(date);
}}
showMonthTitle={true}
disableFutureDates={true}
showCalendarIcon={true}
containerStyle={styles.dateSelectorContainer}
/>
)}
<View style={styles.chartWrapper}>
<CalorieRingChart
metabolism={basalMetabolism}
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}
/>
</View>
<View style={styles.listTitleContainer}>
<Text style={styles.listTitle}>{t('nutritionRecords.listTitle')}</Text>
{displayRecords.length > 0 && (
<Text style={styles.listSubtitle}>{t('nutritionRecords.recordCount', { count: displayRecords.length })}</Text>
)}
</View>
</View>
</View>
);
return (
<View style={[styles.container, { backgroundColor: '#f3f4fb' }]}>
<StatusBar barStyle="dark-content" />
{/* 顶部柔和渐变背景 */}
<LinearGradient
colors={['rgba(255, 243, 224, 0.8)', 'rgba(243, 244, 251, 0)']}
style={styles.topGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<HeaderBar
title={t('nutritionRecords.title')}
onBack={() => router.back()}
right={renderRightButton()}
transparent={true}
/>
<FlatList
data={displayRecords}
renderItem={renderRecord}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colorTokens.primary}
colors={[colorTokens.primary]}
/>
}
ListHeaderComponent={ListHeader}
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,
},
topGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: 320,
},
listContainer: {
paddingBottom: 100, // 留出底部空间防止遮挡
},
headerContent: {
marginBottom: 16,
},
dateSelectorContainer: {
paddingHorizontal: 16,
marginBottom: 16,
},
chartWrapper: {
marginBottom: 24,
shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 4,
},
listTitleContainer: {
flexDirection: 'row',
alignItems: 'baseline',
paddingHorizontal: 24,
marginBottom: 12,
gap: 8,
},
listTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
listSubtitle: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
glassAddButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
overflow: 'hidden',
},
fallbackAddButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
},
emptySimpleContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptySimpleImage: {
width: 48,
height: 48,
opacity: 0.4,
marginBottom: 12,
},
emptySimpleText: {
fontSize: 14,
color: '#94A3B8',
fontFamily: 'AliRegular',
marginBottom: 8,
},
emptyActionText: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
footerContainer: {
paddingVertical: 24,
alignItems: 'center',
},
footerText: {
fontSize: 12,
fontWeight: '500',
opacity: 0.6,
fontFamily: 'AliRegular',
},
loadMoreButton: {
paddingVertical: 16,
alignItems: 'center',
},
loadMoreText: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
});