feat: 实现目标列表左滑删除功能及相关组件

- 在目标列表中添加左滑删除功能,用户可通过左滑手势显示删除按钮并确认删除目标
- 修改 GoalCard 组件,使用 Swipeable 组件包装卡片内容,支持删除操作
- 更新目标列表页面,集成删除目标的逻辑,确保与 Redux 状态管理一致
- 添加开发模式下的模拟数据,方便测试删除功能
- 更新相关文档,详细描述左滑删除功能的实现和使用方法
This commit is contained in:
2025-08-23 17:58:39 +08:00
parent 20a244e375
commit 4f2bd76b8f
14 changed files with 1592 additions and 732 deletions

View File

@@ -197,9 +197,9 @@ export default function GoalsScreen() {
// 导航到目标管理页面
const handleNavigateToGoals = () => {
router.push('/goals-detail');
// 导航到任务列表页面
const handleNavigateToTasks = () => {
router.push('/task-list');
};
// 计算各状态的任务数量
@@ -210,18 +210,38 @@ export default function GoalsScreen() {
skipped: tasks.filter(task => task.status === 'skipped').length,
};
// 根据筛选条件过滤任务
// 根据筛选条件过滤任务,并将已完成的任务放到最后
const filteredTasks = React.useMemo(() => {
let filtered: TaskListItem[] = [];
switch (selectedFilter) {
case 'pending':
return tasks.filter(task => task.status === 'pending');
filtered = tasks.filter(task => task.status === 'pending');
break;
case 'completed':
return tasks.filter(task => task.status === 'completed');
filtered = tasks.filter(task => task.status === 'completed');
break;
case 'skipped':
return tasks.filter(task => task.status === 'skipped');
filtered = tasks.filter(task => task.status === 'skipped');
break;
default:
return tasks;
filtered = tasks;
break;
}
// 对所有筛选结果进行排序:已完成的任务放到最后
return [...filtered].sort((a, b) => {
// 如果a已完成而b未完成a排在后面
if (a.status === 'completed' && b.status !== 'completed') {
return 1;
}
// 如果b已完成而a未完成b排在后面
if (b.status === 'completed' && a.status !== 'completed') {
return -1;
}
// 如果都已完成或都未完成,保持原有顺序
return 0;
});
}, [tasks, selectedFilter]);
// 处理筛选变化
@@ -346,12 +366,12 @@ export default function GoalsScreen() {
<View style={styles.cardHeaderButtons}>
<TouchableOpacity
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
onPress={handleNavigateToGoals}
onPress={handleNavigateToTasks}
>
<Text style={[styles.cardGoalsButtonText, { color: colorTokens.primary }]}>
</Text>
<MaterialIcons name="flag" size={16} color={colorTokens.primary} />
<MaterialIcons name="list" size={16} color={colorTokens.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
@@ -652,6 +672,16 @@ const styles = StyleSheet.create({
backgroundColor: '#F3F4F6',
borderWidth: 1,
},
cardGoalsListButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#F3F4F6',
borderWidth: 1,
},
cardGoalsButtonText: {
fontSize: 12,
fontWeight: '600',

View File

@@ -17,7 +17,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
import { ensureHealthPermissions, fetchHealthDataForDate, testOxygenSaturationData } from '@/utils/health';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
@@ -309,6 +309,13 @@ export default function ExploreScreen() {
}
};
// 测试血氧饱和度数据
const testOxygenData = async () => {
console.log('开始测试血氧饱和度数据...');
const currentDate = getCurrentSelectedDate();
await testOxygenSaturationData(currentDate);
};
// 使用统一的渐变背景色
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
@@ -450,6 +457,13 @@ export default function ExploreScreen() {
style={styles.basalMetabolismCardOverride}
oxygenSaturation={oxygenSaturation}
/>
{/* 测试按钮 - 开发时使用 */}
<Text
style={styles.testButton}
onPress={testOxygenData}
>
</Text>
</FloatingCard>
{/* 心率卡片 */}
@@ -794,4 +808,11 @@ const styles = StyleSheet.create({
moodCard: {
backgroundColor: '#F0FDF4',
},
testButton: {
fontSize: 12,
color: '#3B82F6',
textAlign: 'center',
marginTop: 8,
padding: 4,
},
});

View File

@@ -3,6 +3,8 @@ import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import 'react-native-gesture-handler';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -74,30 +76,33 @@ export default function RootLayout() {
}
return (
<Provider store={store}>
<Bootstrapper>
<ToastProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="challenge" options={{ headerShown: false }} />
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
<Stack.Screen name="workout" options={{ headerShown: false }} />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<GestureHandlerRootView style={{ flex: 1 }}>
<Provider store={store}>
<Bootstrapper>
<ToastProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="challenge" options={{ headerShown: false }} />
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
<Stack.Screen name="workout" options={{ headerShown: false }} />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<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" />
</ThemeProvider>
</ToastProvider>
</Bootstrapper>
</Provider>
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<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" />
</ThemeProvider>
</ToastProvider>
</Bootstrapper>
</Provider>
</GestureHandlerRootView>
);
}

View File

@@ -1,449 +0,0 @@
import CreateGoalModal from '@/components/CreateGoalModal';
import { DateSelector } from '@/components/DateSelector';
import { GoalItem } from '@/components/GoalCard';
import { GoalCarousel } from '@/components/GoalCarousel';
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
import { TimelineSchedule } from '@/components/TimelineSchedule';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { clearErrors, createGoal, fetchGoals } from '@/store/goalsSlice';
import { CreateGoalRequest, GoalListItem } from '@/types/goals';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
dayjs.extend(isBetween);
// 将目标转换为GoalItem的辅助函数
const goalToGoalItem = (goal: GoalListItem): GoalItem => {
return {
id: goal.id,
title: goal.title,
description: goal.description || `${goal.frequency}次/${getRepeatTypeLabel(goal.repeatType)}`,
time: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString() || '',
category: getCategoryFromGoal(goal.category),
priority: getPriorityFromGoal(goal.priority),
};
};
// 获取重复类型标签
const getRepeatTypeLabel = (repeatType: string): string => {
switch (repeatType) {
case 'daily': return '每日';
case 'weekly': return '每周';
case 'monthly': return '每月';
default: return '自定义';
}
};
// 从目标分类获取GoalItem分类
const getCategoryFromGoal = (category?: string): GoalItem['category'] => {
if (!category) return 'personal';
if (category.includes('运动') || category.includes('健身')) return 'workout';
if (category.includes('工作')) return 'work';
if (category.includes('健康')) return 'health';
if (category.includes('财务')) return 'finance';
return 'personal';
};
// 从目标优先级获取GoalItem优先级
const getPriorityFromGoal = (priority: number): GoalItem['priority'] => {
if (priority >= 8) return 'high';
if (priority >= 5) return 'medium';
return 'low';
};
// 将目标转换为时间轴事件的辅助函数
const goalToTimelineEvent = (goal: GoalListItem) => {
return {
id: goal.id,
title: goal.title,
startTime: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString(),
endTime: goal.endTime ? dayjs().startOf('day').add(goal.endTime, 'minute').toISOString() : undefined,
category: getCategoryFromGoal(goal.category),
isCompleted: goal.status === 'completed',
};
};
export default function GoalsDetailScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const router = useRouter();
// Redux状态
const {
goals,
goalsLoading,
goalsError,
createLoading,
createError
} = useAppSelector((state) => state.goals);
const [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [showCreateModal, setShowCreateModal] = useState(false);
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect - loading goals');
dispatch(fetchGoals({
status: 'active',
page: 1,
pageSize: 200,
}));
}, [dispatch])
);
// 处理错误提示
useEffect(() => {
console.log('goalsError', goalsError);
console.log('createError', createError);
if (goalsError) {
Alert.alert('错误', goalsError);
dispatch(clearErrors());
}
if (createError) {
Alert.alert('创建失败', createError);
dispatch(clearErrors());
}
}, [goalsError, createError, dispatch]);
// 创建目标处理函数
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
try {
await dispatch(createGoal(goalData)).unwrap();
setShowCreateModal(false);
Alert.alert('成功', '目标创建成功!');
} catch (error) {
// 错误已在useEffect中处理
}
};
// tab切换处理函数
const handleTabChange = (tab: TimeTabType) => {
setSelectedTab(tab);
// 当切换到周或月模式时,如果当前选择的日期不是今天,则重置为今天
const today = new Date();
const currentDate = selectedDate;
if (tab === 'week' || tab === 'month') {
// 如果当前选择的日期不是今天,重置为今天
if (!dayjs(currentDate).isSame(dayjs(today), 'day')) {
setSelectedDate(today);
setSelectedIndex(getTodayIndexInMonth());
}
} else if (tab === 'day') {
// 天模式下也重置为今天
setSelectedDate(today);
setSelectedIndex(getTodayIndexInMonth());
}
};
// 日期选择器相关状态 (参考 statistics.tsx)
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 日期条自动滚动到选中项
const daysScrollRef = useRef<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 48;
const DAY_PILL_SPACING = 8;
const scrollToIndex = (index: number, animated = true) => {
if (!daysScrollRef.current || scrollWidth === 0) return;
const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING;
const baseOffset = index * itemWidth;
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
// 确保不会滚动超出边界
const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth);
const finalOffset = Math.min(centerOffset, maxScrollOffset);
daysScrollRef.current.scrollTo({ x: finalOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// 当选中索引变化时,滚动到对应位置
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// 日期选择处理
const onSelectDate = (index: number) => {
setSelectedIndex(index);
const targetDate = days[index]?.date?.toDate();
if (targetDate) {
setSelectedDate(targetDate);
// 在周模式下,如果用户选择了新日期,更新周的显示范围
if (selectedTab === 'week') {
// 自动滚动到新选择的日期
setTimeout(() => {
scrollToIndex(index, true);
}, 100);
}
}
};
// 将目标转换为GoalItem数据
const todayGoals = useMemo(() => {
const today = dayjs();
const activeGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' ||
(goal.repeatType === 'weekly' && today.day() !== 0) ||
(goal.repeatType === 'monthly' && today.date() <= 28))
);
return activeGoals.map(goalToGoalItem);
}, [goals]);
// 将目标转换为时间轴事件数据
const filteredTimelineEvents = useMemo(() => {
const selected = dayjs(selectedDate);
let filteredGoals: GoalListItem[] = [];
switch (selectedTab) {
case 'day':
filteredGoals = goals.filter(goal => {
if (goal.status !== 'active') return false;
if (goal.repeatType === 'daily') return true;
if (goal.repeatType === 'weekly') return selected.day() !== 0;
if (goal.repeatType === 'monthly') return selected.date() <= 28;
return false;
});
break;
case 'week':
filteredGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' || goal.repeatType === 'weekly')
);
break;
case 'month':
filteredGoals = goals.filter(goal => goal.status === 'active');
break;
default:
filteredGoals = goals.filter(goal => goal.status === 'active');
}
return filteredGoals.map(goalToTimelineEvent);
}, [selectedTab, selectedDate, goals]);
console.log('filteredTimelineEvents', filteredTimelineEvents);
const handleGoalPress = (item: GoalItem) => {
console.log('Goal pressed:', item.title);
// 这里可以导航到目标详情页面
};
const handleEventPress = (event: any) => {
console.log('Event pressed:', event.title);
// 这里可以处理时间轴事件点击
};
const handleBackPress = () => {
router.back();
};
return (
<SafeAreaView style={styles.container}>
<StatusBar
backgroundColor="transparent"
translucent
/>
{/* 背景渐变 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<View style={styles.content}>
{/* 标题区域 */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBackPress}
>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
</Text>
<TouchableOpacity
style={styles.addButton}
onPress={() => setShowCreateModal(true)}
>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
</View>
{/* 今日目标卡片 */}
<GoalCarousel
goals={todayGoals}
onGoalPress={handleGoalPress}
/>
{/* 时间筛选选项卡 */}
<TimeTabSelector
selectedTab={selectedTab}
onTabChange={handleTabChange}
/>
{/* 日期选择器 - 在周和月模式下显示 */}
{(selectedTab === 'week' || selectedTab === 'month') && (
<View style={styles.dateSelector}>
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => onSelectDate(index)}
showMonthTitle={true}
disableFutureDates={true}
/>
</View>
)}
{/* 时间轴安排 */}
<View style={styles.timelineSection}>
<TimelineSchedule
events={filteredTimelineEvents}
selectedDate={selectedDate}
onEventPress={handleEventPress}
/>
</View>
{/* 创建目标弹窗 */}
<CreateGoalModal
visible={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateGoal}
loading={createLoading}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
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,
},
backButtonText: {
color: '#0EA5E9',
fontSize: 20,
fontWeight: '600',
},
pageTitle: {
fontSize: 24,
fontWeight: '700',
flex: 1,
textAlign: 'center',
},
addButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
addButtonText: {
color: '#FFFFFF',
fontSize: 24,
fontWeight: '600',
lineHeight: 24,
},
timelineSection: {
flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
},
// 日期选择器样式
dateSelector: {
paddingHorizontal: 20,
paddingVertical: 16,
},
});

400
app/goals-list.tsx Normal file
View File

@@ -0,0 +1,400 @@
import { GoalCard } from '@/components/GoalCard';
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 } from '@/store/goalsSlice';
import { GoalListItem } 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, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export default function GoalsListScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const router = useRouter();
// Redux状态
const {
goals,
goalsLoading,
goalsError,
goalsPagination,
} = useAppSelector((state) => state.goals);
const [refreshing, setRefreshing] = useState(false);
// 页面聚焦时重新加载数据
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);
}
}, [goalsError]);
// 计算各状态的目标数量
const goalCounts = useMemo(() => ({
all: goals.length,
active: goals.filter(goal => goal.status === 'active').length,
paused: goals.filter(goal => goal.status === 'paused').length,
completed: goals.filter(goal => goal.status === 'completed').length,
cancelled: goals.filter(goal => goal.status === 'cancelled').length,
}), [goals]);
// 根据筛选条件过滤目标
const filteredGoals = useMemo(() => {
return goals;
}, [goals]);
// 处理目标点击
const handleGoalPress = (goal: GoalListItem) => {
};
// 渲染目标项
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>
</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',
},
});

288
app/task-list.tsx Normal file
View File

@@ -0,0 +1,288 @@
import { DateSelector } from '@/components/DateSelector';
import { TaskCard } from '@/components/TaskCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
import { useColorScheme } from '@/hooks/useColorScheme';
import { tasksApi } from '@/services/tasksApi';
import { TaskListItem } from '@/types/goals';
import { getTodayIndexInMonth } from '@/utils/date';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, View } from 'react-native';
export default function GoalsDetailScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const router = useRouter();
// 本地状态管理
const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [tasksLoading, setTasksLoading] = useState(false);
const [tasksError, setTasksError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [refreshing, setRefreshing] = useState(false);
// 日期选择器相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
// 加载任务列表
const loadTasks = async (targetDate?: Date) => {
try {
setTasksLoading(true);
setTasksError(null);
const dateToUse = targetDate || selectedDate;
console.log('Loading tasks for date:', dayjs(dateToUse).format('YYYY-MM-DD'));
const response = await tasksApi.getTasks({
startDate: dayjs(dateToUse).startOf('day').toISOString(),
endDate: dayjs(dateToUse).endOf('day').toISOString(),
});
console.log('Tasks API response:', response);
setTasks(response.list || []);
} catch (error: any) {
console.error('Failed to load tasks:', error);
setTasksError(error.message || '获取任务列表失败');
setTasks([]);
} finally {
setTasksLoading(false);
}
};
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect - loading tasks');
loadTasks();
}, [])
);
// 下拉刷新
const onRefresh = async () => {
setRefreshing(true);
try {
await loadTasks();
} finally {
setRefreshing(false);
}
};
// 处理错误提示
useEffect(() => {
if (tasksError) {
Alert.alert('错误', tasksError);
setTasksError(null);
}
}, [tasksError]);
// 日期选择处理
const onSelectDate = async (index: number, date: Date) => {
console.log('Date selected:', dayjs(date).format('YYYY-MM-DD'));
setSelectedIndex(index);
setSelectedDate(date);
// 重新加载对应日期的任务数据
await loadTasks(date);
};
// 根据选中日期筛选任务,并将已完成的任务放到最后
const filteredTasks = useMemo(() => {
const selected = dayjs(selectedDate);
const filtered = tasks.filter(task => {
if (task.status === 'skipped') return false;
const taskDate = dayjs(task.startDate);
return taskDate.isSame(selected, 'day');
});
// 对筛选结果进行排序:已完成的任务放到最后
return [...filtered].sort((a, b) => {
const aCompleted = a.status === 'completed';
const bCompleted = b.status === 'completed';
// 如果a已完成而b未完成a排在后面
if (aCompleted && !bCompleted) {
return 1;
}
// 如果b已完成而a未完成b排在后面
if (bCompleted && !aCompleted) {
return -1;
}
// 如果都已完成或都未完成,保持原有顺序
return 0;
});
}, [selectedDate, tasks]);
const handleBackPress = () => {
router.back();
};
// 渲染任务项
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
<TaskCard
task={item}
/>
);
// 渲染空状态
const renderEmptyState = () => {
const selectedDateStr = dayjs(selectedDate).format('YYYY年M月D日');
if (tasksLoading) {
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
...
</Text>
</View>
);
}
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
</Text>
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
{selectedDateStr}
</Text>
</View>
);
};
return (
<SafeAreaView style={styles.container}>
<StatusBar
backgroundColor="transparent"
translucent
/>
{/* 背景渐变 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<View style={styles.content}>
{/* 标题区域 */}
<HeaderBar
title="任务列表"
onBack={handleBackPress}
transparent={true}
withSafeTop={false}
/>
{/* 日期选择器 */}
<View style={styles.dateSelector}>
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={true}
disableFutureDates={true}
/>
</View>
{/* 任务列表 */}
<View style={styles.taskListContainer}>
<FlatList
data={filteredTasks}
renderItem={renderTaskItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.taskList}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#0EA5E9']}
tintColor="#0EA5E9"
/>
}
ListEmptyComponent={renderEmptyState}
/>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
content: {
flex: 1,
},
// 日期选择器样式
dateSelector: {
paddingHorizontal: 20,
paddingVertical: 16,
},
taskListContainer: {
flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
},
taskList: {
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
emptyStateSubtitle: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
});