452 lines
13 KiB
TypeScript
452 lines
13 KiB
TypeScript
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, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
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();
|
||
|
||
// 获取当前选中日期
|
||
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');
|
||
|
||
// 数据状态
|
||
const [records, setRecords] = useState<DietRecord[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [hasMoreData, setHasMoreData] = useState(true);
|
||
const [page, setPage] = useState(1);
|
||
|
||
// 加载记录数据
|
||
const loadRecords = async (isRefresh = false, loadMore = false) => {
|
||
try {
|
||
if (isRefresh) {
|
||
setRefreshing(true);
|
||
setPage(1);
|
||
} else if (loadMore) {
|
||
// 加载更多时不显示loading
|
||
} else {
|
||
setLoading(true);
|
||
}
|
||
|
||
const currentPage = isRefresh ? 1 : (loadMore ? page + 1 : 1);
|
||
|
||
let startDate: string | undefined;
|
||
let endDate: string | undefined;
|
||
|
||
if (viewMode === 'daily') {
|
||
// 按天查看时,获取选中日期的数据
|
||
startDate = days[selectedIndex]?.date.startOf('day').toISOString();
|
||
endDate = days[selectedIndex]?.date.endOf('day').toISOString();
|
||
}
|
||
|
||
const data = await getDietRecords({
|
||
startDate,
|
||
endDate,
|
||
page: currentPage,
|
||
limit: 10,
|
||
});
|
||
|
||
if (isRefresh || currentPage === 1) {
|
||
setRecords(data.records);
|
||
} else {
|
||
setRecords(prev => [...prev, ...data.records]);
|
||
}
|
||
|
||
setHasMoreData(data.records.length === 10); // 如果返回的记录数少于limit,说明没有更多数据
|
||
setPage(currentPage);
|
||
} catch (error) {
|
||
console.error('加载营养记录失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
};
|
||
|
||
// 当选中日期或视图模式变化时重新加载数据
|
||
useEffect(() => {
|
||
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);
|
||
}
|
||
};
|
||
|
||
// 删除记录
|
||
const handleDeleteRecord = async (recordId: number) => {
|
||
try {
|
||
await deleteDietRecord(recordId);
|
||
// 从本地状态中移除已删除的记录
|
||
setRecords(prev => prev.filter(record => record.id !== recordId));
|
||
} catch (error) {
|
||
console.error('删除营养记录失败:', error);
|
||
// 可以添加错误提示
|
||
}
|
||
};
|
||
|
||
// 渲染视图模式切换器
|
||
const renderViewModeToggle = () => (
|
||
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
||
<View style={[styles.toggleContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.toggleButton,
|
||
viewMode === 'daily' && { backgroundColor: colorTokens.primary }
|
||
]}
|
||
onPress={() => setViewMode('daily')}
|
||
>
|
||
<Text style={[
|
||
styles.toggleText,
|
||
{ color: viewMode === 'daily' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||
]}>
|
||
按天查看
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.toggleButton,
|
||
viewMode === 'all' && { backgroundColor: colorTokens.primary }
|
||
]}
|
||
onPress={() => setViewMode('all')}
|
||
>
|
||
<Text style={[
|
||
styles.toggleText,
|
||
{ color: viewMode === 'all' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||
]}>
|
||
全部记录
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
|
||
// 渲染日期选择器(仅在按天查看模式下显示)
|
||
const renderDateSelector = () => {
|
||
if (viewMode !== 'daily') return null;
|
||
|
||
return (
|
||
<DateSelector
|
||
selectedIndex={selectedIndex}
|
||
onDateSelect={(index, date) => setSelectedIndex(index)}
|
||
showMonthTitle={false}
|
||
disableFutureDates={true}
|
||
/>
|
||
);
|
||
};
|
||
|
||
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}
|
||
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' && records.length > 0) {
|
||
return (
|
||
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||
加载更多
|
||
</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||
<HeaderBar
|
||
title="营养记录"
|
||
onBack={() => router.back()}
|
||
/>
|
||
|
||
{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} />
|
||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>
|
||
加载中...
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
<FlatList
|
||
data={records}
|
||
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}
|
||
/>
|
||
)}
|
||
</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',
|
||
},
|
||
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',
|
||
},
|
||
});
|