481 lines
13 KiB
TypeScript
481 lines
13 KiB
TypeScript
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { DietRecord, getDietRecords } from '@/services/dietRecords';
|
||
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, useRef, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
FlatList,
|
||
RefreshControl,
|
||
ScrollView,
|
||
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 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') {
|
||
// 按天查看时,获取选中日期的数据
|
||
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]);
|
||
|
||
const onRefresh = () => {
|
||
loadRecords(true);
|
||
};
|
||
|
||
const loadMoreRecords = () => {
|
||
if (hasMoreData && !loading && !refreshing) {
|
||
loadRecords(false, true);
|
||
}
|
||
};
|
||
|
||
// 渲染视图模式切换器
|
||
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 (
|
||
<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}>
|
||
<View style={styles.emptyTimelineContainer}>
|
||
<View style={styles.emptyTimeline}>
|
||
<View style={[styles.emptyTimelineDot, { backgroundColor: colorTokens.primary }]}>
|
||
<Ionicons name="add-outline" size={16} color="#FFFFFF" />
|
||
</View>
|
||
</View>
|
||
<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>
|
||
</View>
|
||
);
|
||
|
||
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
|
||
<NutritionRecordCard
|
||
record={item}
|
||
showTimeline={true}
|
||
isFirst={index === 0}
|
||
isLast={index === records.length - 1}
|
||
/>
|
||
);
|
||
|
||
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()}
|
||
|
||
{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,
|
||
},
|
||
emptyTimelineContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
maxWidth: 320,
|
||
},
|
||
emptyTimeline: {
|
||
width: 64,
|
||
alignItems: 'center',
|
||
paddingTop: 8,
|
||
},
|
||
emptyTimelineDot: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 4,
|
||
elevation: 2,
|
||
},
|
||
emptyContent: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
marginLeft: 16,
|
||
},
|
||
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',
|
||
},
|
||
});
|