feat: 新增营养记录页面及相关组件
- 在应用中新增营养记录页面,展示用户的饮食记录 - 引入营养记录卡片组件,优化记录展示效果 - 更新路由常量,添加营养记录相关路径 - 修改布局文件,整合营养记录功能 - 优化数据加载逻辑,支持分页和日期过滤
This commit is contained in:
@@ -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" />
|
||||
|
||||
9
app/nutrition/_layout.tsx
Normal file
9
app/nutrition/_layout.tsx
Normal 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
466
app/nutrition/records.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user