feat: 新增营养记录页面及相关组件

- 在应用中新增营养记录页面,展示用户的饮食记录
- 引入营养记录卡片组件,优化记录展示效果
- 更新路由常量,添加营养记录相关路径
- 修改布局文件,整合营养记录功能
- 优化数据加载逻辑,支持分页和日期过滤
This commit is contained in:
richarjiang
2025-08-19 11:34:50 +08:00
parent df2afeb5a1
commit 260546ff46
9 changed files with 1082 additions and 9 deletions

View File

@@ -16,7 +16,6 @@ import Toast from 'react-native-toast-message';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { Provider } from 'react-redux';
import { ROUTES } from '@/constants/Routes';
function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
@@ -29,7 +28,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
await dispatch(rehydrateUser());
setUserDataLoaded(true);
};
loadUserData();
// 冷启动时清空 AI 教练会话缓存
clearAiCoachSessionCache();
@@ -48,7 +47,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
};
const handlePrivacyDisagree = () => {
RNExitApp.exitApp();
RNExitApp.exitApp();
};
return (
@@ -93,6 +92,7 @@ export default function RootLayout() {
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="nutrition/records" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />

View File

@@ -0,0 +1,9 @@
import { Stack } from 'expo-router';
export default function NutritionLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="records" />
</Stack>
);
}

466
app/nutrition/records.tsx Normal file
View File

@@ -0,0 +1,466 @@
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { DietRecord } from '@/services/dietRecords';
import { getMockDietRecords } from '@/services/mockDietRecords';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons';
// import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type ViewMode = 'daily' | 'all';
export default function NutritionRecordsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
// 日期相关状态 - 使用与统计页面相同的日期逻辑
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 视图模式:按天查看 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 daysScrollRef = useRef<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 68;
const DAY_PILL_SPACING = 12;
// 日期滚动控制
const scrollToIndex = (index: number, animated = true) => {
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
}, [scrollWidth, selectedIndex]);
// 加载记录数据
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') {
// 按天查看时,获取选中日期的数据
const selectedDate = days[selectedIndex]?.date?.format('YYYY-MM-DD') ?? dayjs().format('YYYY-MM-DD');
startDate = selectedDate;
endDate = selectedDate;
}
// viewMode === 'all' 时不设置日期范围,获取所有数据
// 使用模拟数据进行测试
// const data = await getDietRecords({
// startDate,
// endDate,
// page: currentPage,
// limit: 10,
// });
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 800));
const data = getMockDietRecords({
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]);
const onRefresh = () => {
loadRecords(true);
};
const loadMoreRecords = () => {
if (hasMoreData && !loading && !refreshing) {
loadRecords(false, true);
}
};
// 渲染视图模式切换器
const renderViewModeToggle = () => (
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.surface }]}>
<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 (
<View style={styles.daysContainer}>
<ScrollView
ref={daysScrollRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysScrollContainer}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
>
{days.map((day, index) => {
const isSelected = index === selectedIndex;
const isToday = day.isToday;
const isDisabled = day.date?.isAfter(dayjs(), 'day') ?? false;
return (
<TouchableOpacity
key={index}
style={[
styles.dayPill,
{
backgroundColor: isSelected
? colorTokens.primary
: colorTokens.surface,
borderColor: isToday && !isSelected
? colorTokens.primary
: 'transparent',
borderWidth: isToday && !isSelected ? 1 : 0,
opacity: isDisabled ? 0.4 : 1,
},
]}
onPress={() => {
if (!isDisabled) {
setSelectedIndex(index);
scrollToIndex(index);
}
}}
disabled={isDisabled}
>
<Text
style={[
styles.dayNumber,
{
color: isSelected
? colorTokens.onPrimary
: colorTokens.text,
fontWeight: isSelected || isToday ? '700' : '600',
},
]}
>
{day.date?.date() ?? ''}
</Text>
<Text
style={[
styles.dayLabel,
{
color: isSelected
? colorTokens.onPrimary
: colorTokens.textSecondary,
fontWeight: isSelected ? '600' : '500',
},
]}
>
{day.dayAbbr}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
};
const renderEmptyState = () => (
<View style={styles.emptyContainer}>
<Ionicons name="restaurant-outline" size={64} color={colorTokens.textSecondary} />
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
</Text>
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
{viewMode === 'daily' ? '点击右上角添加今日营养摄入' : '开始记录你的营养摄入吧'}
</Text>
</View>
);
const renderRecord = ({ item }: { item: DietRecord }) => (
<NutritionRecordCard record={item} />
);
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()}
right={
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colorTokens.primary }]}
onPress={() => {
// TODO: 跳转到添加营养记录页面
console.log('添加营养记录');
}}
>
<Ionicons name="add" size={20} color={colorTokens.onPrimary} />
</TouchableOpacity>
}
/>
{renderViewModeToggle()}
{renderDateSelector()}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>
...
</Text>
</View>
) : (
<FlatList
data={records}
renderItem={renderRecord}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingBottom: 40 }
]}
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: 68,
height: 68,
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,
},
emptyTitle: {
fontSize: 20,
fontWeight: '700',
marginTop: 16,
marginBottom: 8,
},
emptySubtitle: {
fontSize: 16,
fontWeight: '500',
textAlign: 'center',
lineHeight: 22,
},
footerContainer: {
paddingVertical: 20,
alignItems: 'center',
},
footerText: {
fontSize: 14,
fontWeight: '500',
},
loadMoreButton: {
paddingVertical: 16,
alignItems: 'center',
},
loadMoreText: {
fontSize: 16,
fontWeight: '600',
},
});