Files
digital-pilates/app/nutrition/records.tsx
richarjiang 0a8b20f0ec feat: 增强目标管理功能及相关组件
- 在 GoalsListScreen 中新增目标编辑功能,支持用户编辑现有目标
- 更新 CreateGoalModal 组件,支持编辑模式下的目标更新
- 在 NutritionRecordsScreen 中新增删除营养记录功能,允许用户删除不需要的记录
- 更新 NutritionRecordCard 组件,增加操作选项,支持删除记录
- 修改 dietRecords 服务,添加删除营养记录的 API 调用
- 优化 goalsSlice,确保目标更新逻辑与 Redux 状态管理一致
2025-08-26 22:34:03 +08:00

509 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, deleteDietRecord, 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 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 (
<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}
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()}
{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',
},
});