Files
digital-pilates/app/nutrition/records.tsx
richarjiang 78620f18ee feat: 更新依赖项并优化组件结构
- 在 package.json 和 package-lock.json 中新增 @sentry/react-native、react-native-device-info 和 react-native-purchases 依赖
- 更新统计页面,替换 CircularRing 组件为 FitnessRingsCard,增强健身数据展示
- 在布局文件中引入 ToastProvider,优化用户通知体验
- 新增 SuccessToast 组件,提供全局成功提示功能
- 更新健康数据获取逻辑,支持健身圆环数据的提取
- 优化多个组件的样式和交互,提升用户体验
2025-08-21 09:51:25 +08:00

496 lines
14 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 { 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 = 60; // 48px width + 12px marginRight = 60px total per item
const DAY_PILL_SPACING = 0; // spacing is included in the width above
// 日期滚动控制
const scrollToIndex = (index: number, animated = true) => {
if (scrollWidth <= 0) return;
const itemOffset = index * DAY_PILL_WIDTH;
const scrollViewCenterX = scrollWidth / 2;
const itemCenterX = DAY_PILL_WIDTH / 2;
const centerOffset = Math.max(0, itemOffset - scrollViewCenterX + itemCenterX);
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
};
// 初始化时滚动到选中位置
useEffect(() => {
if (scrollWidth > 0) {
// 延迟滚动以确保ScrollView已经完全渲染
setTimeout(() => {
scrollToIndex(selectedIndex, false);
}, 100);
}
}, [scrollWidth]);
// 选中日期变化时滚动
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
}, [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);
}
}}
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',
},
});