- 在 GoalsListScreen 中新增目标编辑功能,支持用户编辑现有目标 - 更新 CreateGoalModal 组件,支持编辑模式下的目标更新 - 在 NutritionRecordsScreen 中新增删除营养记录功能,允许用户删除不需要的记录 - 更新 NutritionRecordCard 组件,增加操作选项,支持删除记录 - 修改 dietRecords 服务,添加删除营养记录的 API 调用 - 优化 goalsSlice,确保目标更新逻辑与 Redux 状态管理一致
472 lines
13 KiB
TypeScript
472 lines
13 KiB
TypeScript
import { GoalCard } from '@/components/GoalCard';
|
||
import { CreateGoalModal } from '@/components/model/CreateGoalModal';
|
||
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { deleteGoal, fetchGoals, loadMoreGoals, updateGoal } from '@/store/goalsSlice';
|
||
import { CreateGoalRequest, GoalListItem, UpdateGoalRequest } from '@/types/goals';
|
||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||
import { useFocusEffect } from '@react-navigation/native';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
|
||
export default function GoalsListScreen() {
|
||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||
const colorTokens = Colors[theme];
|
||
const dispatch = useAppDispatch();
|
||
const router = useRouter();
|
||
|
||
const { showConfirm } = useGlobalDialog();
|
||
|
||
// Redux状态
|
||
const {
|
||
goals,
|
||
goalsLoading,
|
||
goalsError,
|
||
goalsPagination,
|
||
updateLoading,
|
||
updateError,
|
||
} = useAppSelector((state) => state.goals);
|
||
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
|
||
// 编辑目标相关状态
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [editingGoal, setEditingGoal] = useState<GoalListItem | null>(null);
|
||
|
||
// 页面聚焦时重新加载数据
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
console.log('useFocusEffect - loading goals');
|
||
loadGoals();
|
||
}, [dispatch])
|
||
);
|
||
|
||
// 加载目标列表
|
||
const loadGoals = async () => {
|
||
try {
|
||
await dispatch(fetchGoals({
|
||
page: 1,
|
||
pageSize: 20,
|
||
sortBy: 'createdAt',
|
||
sortOrder: 'desc',
|
||
})).unwrap();
|
||
} catch (error) {
|
||
console.error('Failed to load goals:', error);
|
||
// 在开发模式下,如果API调用失败,使用模拟数据
|
||
if (__DEV__) {
|
||
console.log('Using mock data for development');
|
||
// 添加模拟数据用于测试左滑删除功能
|
||
const mockGoals: GoalListItem[] = [
|
||
{
|
||
id: 'mock-1',
|
||
userId: 'test-user-1',
|
||
title: '每日运动30分钟',
|
||
repeatType: 'daily',
|
||
frequency: 1,
|
||
status: 'active',
|
||
completedCount: 5,
|
||
targetCount: 30,
|
||
hasReminder: true,
|
||
reminderTime: '09:00',
|
||
category: '运动',
|
||
priority: 5,
|
||
startDate: '2024-01-01',
|
||
startTime: 900,
|
||
endTime: 1800,
|
||
progressPercentage: 17,
|
||
},
|
||
{
|
||
id: 'mock-2',
|
||
userId: 'test-user-1',
|
||
title: '每天喝8杯水',
|
||
repeatType: 'daily',
|
||
frequency: 8,
|
||
status: 'active',
|
||
completedCount: 6,
|
||
targetCount: 8,
|
||
hasReminder: true,
|
||
reminderTime: '10:00',
|
||
category: '健康',
|
||
priority: 8,
|
||
startDate: '2024-01-01',
|
||
startTime: 600,
|
||
endTime: 2200,
|
||
progressPercentage: 75,
|
||
},
|
||
{
|
||
id: 'mock-3',
|
||
userId: 'test-user-1',
|
||
title: '每周读书2小时',
|
||
repeatType: 'weekly',
|
||
frequency: 2,
|
||
status: 'paused',
|
||
completedCount: 1,
|
||
targetCount: 2,
|
||
hasReminder: false,
|
||
category: '学习',
|
||
priority: 3,
|
||
startDate: '2024-01-01',
|
||
startTime: 800,
|
||
endTime: 2000,
|
||
progressPercentage: 50,
|
||
},
|
||
];
|
||
|
||
// 直接更新 Redux 状态(仅用于开发测试)
|
||
dispatch({
|
||
type: 'goals/fetchGoals/fulfilled',
|
||
payload: {
|
||
query: { page: 1, pageSize: 20, sortBy: 'createdAt', sortOrder: 'desc' },
|
||
response: {
|
||
list: mockGoals,
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: mockGoals.length,
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// 下拉刷新
|
||
const onRefresh = async () => {
|
||
setRefreshing(true);
|
||
try {
|
||
await loadGoals();
|
||
} finally {
|
||
setRefreshing(false);
|
||
}
|
||
};
|
||
|
||
// 加载更多目标
|
||
const handleLoadMoreGoals = async () => {
|
||
if (goalsPagination.hasMore && !goalsLoading) {
|
||
try {
|
||
await dispatch(loadMoreGoals()).unwrap();
|
||
} catch (error) {
|
||
console.error('Failed to load more goals:', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理删除目标
|
||
const handleDeleteGoal = async (goalId: string) => {
|
||
try {
|
||
await dispatch(deleteGoal(goalId)).unwrap();
|
||
// 删除成功,Redux 会自动更新状态
|
||
} catch (error) {
|
||
console.error('Failed to delete goal:', error);
|
||
Alert.alert('错误', '删除目标失败,请重试');
|
||
}
|
||
};
|
||
|
||
// 处理错误提示
|
||
useEffect(() => {
|
||
if (goalsError) {
|
||
Alert.alert('错误', goalsError);
|
||
}
|
||
if (updateError) {
|
||
Alert.alert('更新失败', updateError);
|
||
}
|
||
}, [goalsError, updateError]);
|
||
|
||
|
||
// 根据筛选条件过滤目标
|
||
const filteredGoals = useMemo(() => {
|
||
return goals;
|
||
}, [goals]);
|
||
|
||
|
||
|
||
// 处理目标点击
|
||
const handleGoalPress = (goal: GoalListItem) => {
|
||
setEditingGoal(goal);
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
// 将 GoalListItem 转换为 CreateGoalRequest 格式
|
||
const convertGoalToModalData = (goal: GoalListItem): Partial<CreateGoalRequest> => {
|
||
return {
|
||
title: goal.title,
|
||
description: goal.description,
|
||
repeatType: goal.repeatType,
|
||
frequency: goal.frequency,
|
||
category: goal.category,
|
||
priority: goal.priority,
|
||
hasReminder: goal.hasReminder,
|
||
reminderTime: goal.reminderTime,
|
||
customRepeatRule: goal.customRepeatRule,
|
||
endDate: goal.endDate,
|
||
};
|
||
};
|
||
|
||
// 处理更新目标
|
||
const handleUpdateGoal = async (goalId: string, goalData: UpdateGoalRequest) => {
|
||
try {
|
||
await dispatch(updateGoal({ goalId, goalData })).unwrap();
|
||
setShowEditModal(false);
|
||
setEditingGoal(null);
|
||
|
||
// 使用全局弹窗显示成功消息
|
||
showConfirm(
|
||
{
|
||
title: '目标更新成功',
|
||
message: '恭喜!您的目标已成功更新。',
|
||
confirmText: '确定',
|
||
cancelText: '',
|
||
icon: 'checkmark-circle',
|
||
iconColor: '#10B981',
|
||
},
|
||
() => {
|
||
console.log('用户确认了目标更新成功');
|
||
}
|
||
);
|
||
} catch (error) {
|
||
console.error('Failed to update goal:', error);
|
||
Alert.alert('错误', '更新目标失败,请重试');
|
||
// 更新失败时不关闭弹窗,保持编辑状态
|
||
}
|
||
};
|
||
|
||
// 处理编辑弹窗关闭
|
||
const handleCloseEditModal = () => {
|
||
setShowEditModal(false);
|
||
setEditingGoal(null);
|
||
};
|
||
|
||
// 渲染目标项
|
||
const renderGoalItem = ({ item }: { item: GoalListItem }) => (
|
||
<GoalCard
|
||
goal={item}
|
||
onPress={handleGoalPress}
|
||
onDelete={handleDeleteGoal}
|
||
showStatus={false}
|
||
/>
|
||
);
|
||
|
||
// 渲染空状态
|
||
const renderEmptyState = () => {
|
||
let title = '暂无目标';
|
||
let subtitle = '创建您的第一个目标,开始您的健康之旅';
|
||
|
||
|
||
|
||
return (
|
||
<View style={styles.emptyState}>
|
||
<MaterialIcons name="flag" size={64} color="#D1D5DB" />
|
||
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||
{title}
|
||
</Text>
|
||
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||
{subtitle}
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={[styles.createButton, { backgroundColor: colorTokens.primary }]}
|
||
onPress={() => router.push('/(tabs)/goals')}
|
||
>
|
||
<Text style={styles.createButtonText}>创建目标</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
|
||
|
||
// 渲染加载更多
|
||
const renderLoadMore = () => {
|
||
if (!goalsPagination.hasMore) return null;
|
||
return (
|
||
<View style={styles.loadMoreContainer}>
|
||
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
|
||
{goalsLoading ? '加载中...' : '上拉加载更多'}
|
||
</Text>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={styles.container}>
|
||
<StatusBar
|
||
backgroundColor="transparent"
|
||
translucent
|
||
/>
|
||
|
||
{/* 背景渐变 */}
|
||
<LinearGradient
|
||
colors={['#F8FAFC', '#F1F5F9']}
|
||
style={styles.gradientBackground}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
/>
|
||
|
||
<View style={styles.content}>
|
||
{/* 标题区域 */}
|
||
<View style={styles.header}>
|
||
<TouchableOpacity
|
||
style={styles.backButton}
|
||
onPress={() => router.back()}
|
||
>
|
||
<MaterialIcons name="arrow-back" size={24} color={colorTokens.text} />
|
||
</TouchableOpacity>
|
||
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||
目标列表
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={styles.addButton}
|
||
onPress={() => router.push('/(tabs)/goals')}
|
||
>
|
||
<MaterialIcons name="add" size={24} color="#FFFFFF" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
|
||
{/* 目标列表 */}
|
||
<View style={styles.goalsListContainer}>
|
||
<FlatList
|
||
data={filteredGoals}
|
||
renderItem={renderGoalItem}
|
||
keyExtractor={(item) => item.id}
|
||
contentContainerStyle={styles.goalsList}
|
||
showsVerticalScrollIndicator={false}
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={refreshing}
|
||
onRefresh={onRefresh}
|
||
colors={['#7A5AF8']}
|
||
tintColor="#7A5AF8"
|
||
/>
|
||
}
|
||
onEndReached={handleLoadMoreGoals}
|
||
onEndReachedThreshold={0.1}
|
||
ListEmptyComponent={renderEmptyState}
|
||
ListFooterComponent={renderLoadMore}
|
||
/>
|
||
</View>
|
||
|
||
{/* 编辑目标弹窗 */}
|
||
{editingGoal && (
|
||
<CreateGoalModal
|
||
visible={showEditModal}
|
||
onClose={handleCloseEditModal}
|
||
onSubmit={() => {}} // 编辑模式下不使用这个回调
|
||
onUpdate={handleUpdateGoal}
|
||
loading={updateLoading}
|
||
initialData={convertGoalToModalData(editingGoal)}
|
||
editGoalId={editingGoal.id}
|
||
/>
|
||
)}
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
},
|
||
gradientBackground: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
},
|
||
content: {
|
||
flex: 1,
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 20,
|
||
paddingTop: 20,
|
||
paddingBottom: 16,
|
||
},
|
||
backButton: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 4,
|
||
elevation: 3,
|
||
},
|
||
pageTitle: {
|
||
fontSize: 24,
|
||
fontWeight: '700',
|
||
flex: 1,
|
||
textAlign: 'center',
|
||
},
|
||
addButton: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
backgroundColor: '#7A5AF8',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 4,
|
||
elevation: 3,
|
||
},
|
||
goalsListContainer: {
|
||
flex: 1,
|
||
borderTopLeftRadius: 24,
|
||
borderTopRightRadius: 24,
|
||
overflow: 'hidden',
|
||
},
|
||
goalsList: {
|
||
paddingHorizontal: 20,
|
||
paddingTop: 20,
|
||
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
|
||
},
|
||
emptyState: {
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: 80,
|
||
},
|
||
emptyStateTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
marginTop: 16,
|
||
marginBottom: 8,
|
||
},
|
||
emptyStateSubtitle: {
|
||
fontSize: 14,
|
||
textAlign: 'center',
|
||
lineHeight: 20,
|
||
marginBottom: 24,
|
||
paddingHorizontal: 40,
|
||
},
|
||
createButton: {
|
||
paddingHorizontal: 24,
|
||
paddingVertical: 12,
|
||
borderRadius: 20,
|
||
},
|
||
createButtonText: {
|
||
color: '#FFFFFF',
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
loadMoreContainer: {
|
||
alignItems: 'center',
|
||
paddingVertical: 20,
|
||
},
|
||
loadMoreText: {
|
||
fontSize: 14,
|
||
fontWeight: '500',
|
||
},
|
||
});
|