feat: 实现目标列表左滑删除功能及相关组件
- 在目标列表中添加左滑删除功能,用户可通过左滑手势显示删除按钮并确认删除目标 - 修改 GoalCard 组件,使用 Swipeable 组件包装卡片内容,支持删除操作 - 更新目标列表页面,集成删除目标的逻辑,确保与 Redux 状态管理一致 - 添加开发模式下的模拟数据,方便测试删除功能 - 更新相关文档,详细描述左滑删除功能的实现和使用方法
This commit is contained in:
@@ -197,9 +197,9 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 导航到目标管理页面
|
// 导航到任务列表页面
|
||||||
const handleNavigateToGoals = () => {
|
const handleNavigateToTasks = () => {
|
||||||
router.push('/goals-detail');
|
router.push('/task-list');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 计算各状态的任务数量
|
// 计算各状态的任务数量
|
||||||
@@ -210,18 +210,38 @@ export default function GoalsScreen() {
|
|||||||
skipped: tasks.filter(task => task.status === 'skipped').length,
|
skipped: tasks.filter(task => task.status === 'skipped').length,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据筛选条件过滤任务
|
// 根据筛选条件过滤任务,并将已完成的任务放到最后
|
||||||
const filteredTasks = React.useMemo(() => {
|
const filteredTasks = React.useMemo(() => {
|
||||||
|
let filtered: TaskListItem[] = [];
|
||||||
|
|
||||||
switch (selectedFilter) {
|
switch (selectedFilter) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return tasks.filter(task => task.status === 'pending');
|
filtered = tasks.filter(task => task.status === 'pending');
|
||||||
|
break;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return tasks.filter(task => task.status === 'completed');
|
filtered = tasks.filter(task => task.status === 'completed');
|
||||||
|
break;
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
return tasks.filter(task => task.status === 'skipped');
|
filtered = tasks.filter(task => task.status === 'skipped');
|
||||||
|
break;
|
||||||
default:
|
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]);
|
}, [tasks, selectedFilter]);
|
||||||
|
|
||||||
// 处理筛选变化
|
// 处理筛选变化
|
||||||
@@ -346,12 +366,12 @@ export default function GoalsScreen() {
|
|||||||
<View style={styles.cardHeaderButtons}>
|
<View style={styles.cardHeaderButtons}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
|
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
|
||||||
onPress={handleNavigateToGoals}
|
onPress={handleNavigateToTasks}
|
||||||
>
|
>
|
||||||
<Text style={[styles.cardGoalsButtonText, { color: colorTokens.primary }]}>
|
<Text style={[styles.cardGoalsButtonText, { color: colorTokens.primary }]}>
|
||||||
回顾
|
历史
|
||||||
</Text>
|
</Text>
|
||||||
<MaterialIcons name="flag" size={16} color={colorTokens.primary} />
|
<MaterialIcons name="list" size={16} color={colorTokens.primary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
|
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
|
||||||
@@ -652,6 +672,16 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#F3F4F6',
|
backgroundColor: '#F3F4F6',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
|
cardGoalsListButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
cardGoalsButtonText: {
|
cardGoalsButtonText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
|||||||
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
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 { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
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;
|
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||||
|
|
||||||
@@ -450,6 +457,13 @@ export default function ExploreScreen() {
|
|||||||
style={styles.basalMetabolismCardOverride}
|
style={styles.basalMetabolismCardOverride}
|
||||||
oxygenSaturation={oxygenSaturation}
|
oxygenSaturation={oxygenSaturation}
|
||||||
/>
|
/>
|
||||||
|
{/* 测试按钮 - 开发时使用 */}
|
||||||
|
<Text
|
||||||
|
style={styles.testButton}
|
||||||
|
onPress={testOxygenData}
|
||||||
|
>
|
||||||
|
测试血氧数据
|
||||||
|
</Text>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
{/* 心率卡片 */}
|
{/* 心率卡片 */}
|
||||||
@@ -794,4 +808,11 @@ const styles = StyleSheet.create({
|
|||||||
moodCard: {
|
moodCard: {
|
||||||
backgroundColor: '#F0FDF4',
|
backgroundColor: '#F0FDF4',
|
||||||
},
|
},
|
||||||
|
testButton: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#3B82F6',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useFonts } from 'expo-font';
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
import 'react-native-gesture-handler';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
@@ -74,6 +76,7 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Bootstrapper>
|
<Bootstrapper>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
@@ -85,6 +88,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="workout" options={{ headerShown: false }} />
|
<Stack.Screen name="workout" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="profile/edit" />
|
<Stack.Screen name="profile/edit" />
|
||||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
<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="ai-posture-assessment" />
|
||||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||||
@@ -99,5 +103,6 @@ export default function RootLayout() {
|
|||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</Bootstrapper>
|
</Bootstrapper>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
400
app/goals-list.tsx
Normal 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
288
app/task-list.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
assets/images/task/icon-copy.png
Normal file
BIN
assets/images/task/icon-copy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -1,197 +1,289 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { GoalListItem } from '@/types/goals';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import React, { useRef } from 'react';
|
||||||
import dayjs from 'dayjs';
|
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import React from 'react';
|
import { Swipeable } from 'react-native-gesture-handler';
|
||||||
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
|
|
||||||
export interface GoalItem {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
time: string;
|
|
||||||
category: 'workout' | 'finance' | 'personal' | 'work' | 'health';
|
|
||||||
priority?: 'high' | 'medium' | 'low';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GoalCardProps {
|
interface GoalCardProps {
|
||||||
item: GoalItem;
|
goal: GoalListItem;
|
||||||
onPress?: (item: GoalItem) => void;
|
onPress?: (goal: GoalListItem) => void;
|
||||||
|
onDelete?: (goalId: string) => void;
|
||||||
|
showStatus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
export const GoalCard: React.FC<GoalCardProps> = ({
|
||||||
const CARD_WIDTH = (screenWidth - 60) * 0.65; // 显示1.5张卡片
|
goal,
|
||||||
|
onPress,
|
||||||
|
onDelete,
|
||||||
|
showStatus = true
|
||||||
|
}) => {
|
||||||
|
const swipeableRef = useRef<Swipeable>(null);
|
||||||
|
|
||||||
const getCategoryIcon = (category: GoalItem['category']) => {
|
// 获取重复类型显示文本
|
||||||
switch (category) {
|
const getRepeatTypeText = (goal: GoalListItem) => {
|
||||||
case 'workout':
|
switch (goal.repeatType) {
|
||||||
return 'fitness-outline';
|
case 'daily':
|
||||||
case 'finance':
|
return '每日';
|
||||||
return 'card-outline';
|
case 'weekly':
|
||||||
case 'personal':
|
return '每周';
|
||||||
return 'person-outline';
|
case 'monthly':
|
||||||
case 'work':
|
return '每月';
|
||||||
return 'briefcase-outline';
|
|
||||||
case 'health':
|
|
||||||
return 'heart-outline';
|
|
||||||
default:
|
default:
|
||||||
return 'checkmark-circle-outline';
|
return '每日';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryColor = (category: GoalItem['category']) => {
|
// 获取目标状态显示文本
|
||||||
switch (category) {
|
const getStatusText = (goal: GoalListItem) => {
|
||||||
case 'workout':
|
switch (goal.status) {
|
||||||
return '#FF6B6B';
|
case 'active':
|
||||||
case 'finance':
|
return '进行中';
|
||||||
return '#4ECDC4';
|
case 'paused':
|
||||||
case 'personal':
|
return '已暂停';
|
||||||
return '#45B7D1';
|
case 'completed':
|
||||||
case 'work':
|
return '已完成';
|
||||||
return '#96CEB4';
|
case 'cancelled':
|
||||||
case 'health':
|
return '已取消';
|
||||||
return '#FFEAA7';
|
|
||||||
default:
|
default:
|
||||||
return '#DDA0DD';
|
return '进行中';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPriorityColor = (priority: GoalItem['priority']) => {
|
// 获取目标状态颜色
|
||||||
switch (priority) {
|
const getStatusColor = (goal: GoalListItem) => {
|
||||||
case 'high':
|
switch (goal.status) {
|
||||||
return '#FF4757';
|
case 'active':
|
||||||
case 'medium':
|
return '#10B981';
|
||||||
return '#FFA502';
|
case 'paused':
|
||||||
case 'low':
|
return '#F59E0B';
|
||||||
return '#2ED573';
|
case 'completed':
|
||||||
|
return '#3B82F6';
|
||||||
|
case 'cancelled':
|
||||||
|
return '#EF4444';
|
||||||
default:
|
default:
|
||||||
return '#747D8C';
|
return '#10B981';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GoalCard({ item, onPress }: GoalCardProps) {
|
// 获取目标图标
|
||||||
const theme = useColorScheme() ?? 'light';
|
const getGoalIcon = (goal: GoalListItem) => {
|
||||||
const colorTokens = Colors[theme];
|
// 根据目标类别或标题返回不同的图标
|
||||||
|
const title = goal.title.toLowerCase();
|
||||||
|
const category = goal.category?.toLowerCase();
|
||||||
|
|
||||||
const categoryColor = getCategoryColor(item.category);
|
if (title.includes('运动') || title.includes('健身') || title.includes('跑步')) {
|
||||||
const categoryIcon = getCategoryIcon(item.category);
|
return 'fitness-center';
|
||||||
const priorityColor = getPriorityColor(item.priority);
|
} else if (title.includes('喝水') || title.includes('饮水')) {
|
||||||
|
return 'local-drink';
|
||||||
|
} else if (title.includes('睡眠') || title.includes('睡觉')) {
|
||||||
|
return 'bedtime';
|
||||||
|
} else if (title.includes('学习') || title.includes('读书')) {
|
||||||
|
return 'school';
|
||||||
|
} else if (title.includes('冥想') || title.includes('放松')) {
|
||||||
|
return 'self-improvement';
|
||||||
|
} else if (title.includes('早餐') || title.includes('午餐') || title.includes('晚餐')) {
|
||||||
|
return 'restaurant';
|
||||||
|
} else {
|
||||||
|
return 'flag';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const timeFormatted = dayjs(item.time).format('HH:mm');
|
// 处理删除操作
|
||||||
|
const handleDelete = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'确认删除',
|
||||||
|
`确定要删除目标"${goal.title}"吗?此操作无法撤销。`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '取消',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
onDelete?.(goal.id);
|
||||||
|
swipeableRef.current?.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染删除按钮
|
||||||
|
const renderRightActions = () => {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.container, { backgroundColor: colorTokens.card }]}
|
style={styles.deleteButton}
|
||||||
onPress={() => onPress?.(item)}
|
onPress={handleDelete}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{/* 顶部标签和优先级 */}
|
<MaterialIcons name="delete" size={24} color="#EF4444" />
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={[styles.categoryBadge, { backgroundColor: categoryColor }]}>
|
|
||||||
<Ionicons name={categoryIcon as any} size={12} color="#fff" />
|
|
||||||
<Text style={styles.categoryText}>{item.category}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item.priority && (
|
|
||||||
<View style={[styles.priorityDot, { backgroundColor: priorityColor }]} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 主要内容 */}
|
|
||||||
<View style={styles.content}>
|
|
||||||
<Text style={[styles.title, { color: colorTokens.text }]} numberOfLines={2}>
|
|
||||||
{item.title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{item.description && (
|
|
||||||
<Text style={[styles.description, { color: colorTokens.textSecondary }]} numberOfLines={2}>
|
|
||||||
{item.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 底部时间 */}
|
|
||||||
<View style={styles.footer}>
|
|
||||||
<View style={styles.timeContainer}>
|
|
||||||
<Ionicons
|
|
||||||
name='time-outline'
|
|
||||||
size={14}
|
|
||||||
color={colorTokens.textMuted}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.timeText, { color: colorTokens.textMuted }]}>
|
|
||||||
{timeFormatted}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Swipeable
|
||||||
|
ref={swipeableRef}
|
||||||
|
renderRightActions={renderRightActions}
|
||||||
|
rightThreshold={40}
|
||||||
|
overshootRight={false}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.goalCard}
|
||||||
|
onPress={() => onPress?.(goal)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{/* 左侧图标 */}
|
||||||
|
<View style={styles.goalIcon}>
|
||||||
|
<MaterialIcons name={getGoalIcon(goal)} size={20} color="#7A5AF8" />
|
||||||
|
<View style={styles.iconStars}>
|
||||||
|
<View style={styles.star} />
|
||||||
|
<View style={styles.star} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 中间内容 */}
|
||||||
|
<View style={styles.goalContent}>
|
||||||
|
<Text style={styles.goalTitle} numberOfLines={1}>
|
||||||
|
{goal.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 底部信息行 */}
|
||||||
|
<View style={styles.goalInfo}>
|
||||||
|
{/* 积分 */}
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoText}>+1</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 目标数量 */}
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
{goal.targetCount || goal.frequency}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 提醒图标(如果有提醒) */}
|
||||||
|
{goal.hasReminder && (
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<MaterialIcons name="notifications" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提醒时间(如果有提醒) */}
|
||||||
|
{goal.hasReminder && goal.reminderTime && (
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoText}>{goal.reminderTime}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 重复图标 */}
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<MaterialIcons name="loop" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 重复类型 */}
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoText}>{getRepeatTypeText(goal)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右侧状态指示器 */}
|
||||||
|
{showStatus && (
|
||||||
|
<View style={[styles.statusIndicator, { backgroundColor: getStatusColor(goal) }]}>
|
||||||
|
<Text style={styles.statusText}>{getStatusText(goal)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Swipeable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
goalCard: {
|
||||||
width: CARD_WIDTH,
|
flexDirection: 'row',
|
||||||
height: 140,
|
alignItems: 'center',
|
||||||
marginHorizontal: 8,
|
backgroundColor: '#FFFFFF',
|
||||||
borderRadius: 20,
|
borderRadius: 16,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
elevation: 6,
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
goalIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
header: {
|
iconStars: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
gap: 1,
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
},
|
||||||
categoryBadge: {
|
star: {
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
goalContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
goalTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
goalInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
infoItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
statusIndicator: {
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#4ECDC4',
|
minWidth: 60,
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
categoryText: {
|
statusText: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
color: '#FFFFFF',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#fff',
|
|
||||||
marginLeft: 4,
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
},
|
},
|
||||||
priorityDot: {
|
deleteButton: {
|
||||||
width: 8,
|
width: 60,
|
||||||
height: 8,
|
height: '100%',
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: '#FF4757',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
lineHeight: 20,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 16,
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 12,
|
|
||||||
},
|
|
||||||
timeContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
timeText: {
|
deleteButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '600',
|
||||||
marginLeft: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useAppDispatch } from '@/hooks/redux';
|
|||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { completeTask, skipTask } from '@/store/tasksSlice';
|
import { completeTask, skipTask } from '@/store/tasksSlice';
|
||||||
import { TaskListItem } from '@/types/goals';
|
import { TaskListItem } from '@/types/goals';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Alert, Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
@@ -27,7 +26,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||||||
|
|
||||||
// 当任务进度变化时,启动动画
|
// 当任务进度变化时,启动动画
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const targetProgress = task.progressPercentage > 0 ? Math.min(task.progressPercentage, 100) : 2;
|
const targetProgress = task.progressPercentage > 0 ? Math.min(task.progressPercentage, 100) : 6;
|
||||||
|
|
||||||
Animated.timing(progressAnimation, {
|
Animated.timing(progressAnimation, {
|
||||||
toValue: targetProgress,
|
toValue: targetProgress,
|
||||||
@@ -36,7 +35,6 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||||||
}).start();
|
}).start();
|
||||||
}, [task.progressPercentage, progressAnimation]);
|
}, [task.progressPercentage, progressAnimation]);
|
||||||
|
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
@@ -179,60 +177,102 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.container, { backgroundColor: colorTokens.background }]}
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: task.status === 'completed'
|
||||||
|
? 'rgba(248, 250, 252, 0.8)' // 已完成任务使用稍微透明的背景色
|
||||||
|
: colorTokens.background
|
||||||
|
}
|
||||||
|
]}
|
||||||
onPress={handleTaskPress}
|
onPress={handleTaskPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{/* 头部区域 */}
|
<View style={styles.cardContent}>
|
||||||
<View style={styles.header}>
|
{/* 左侧图标区域 */}
|
||||||
<View style={styles.titleSection}>
|
<View style={styles.iconSection}>
|
||||||
<Text style={[styles.title, { color: colorTokens.text }]} numberOfLines={2}>
|
<View style={[
|
||||||
|
styles.iconCircle,
|
||||||
|
{
|
||||||
|
backgroundColor: task.status === 'completed'
|
||||||
|
? '#EDE9FE' // 完成状态使用更深的紫色背景
|
||||||
|
: '#F3E8FF',
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/task/icon-copy.png')}
|
||||||
|
style={styles.taskIcon}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右侧信息区域 */}
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
{/* 任务标题 */}
|
||||||
|
<Text style={[
|
||||||
|
styles.title,
|
||||||
|
{
|
||||||
|
color: task.status === 'completed'
|
||||||
|
? '#6B7280'
|
||||||
|
: colorTokens.text,
|
||||||
|
}
|
||||||
|
]} numberOfLines={1}>
|
||||||
{task.title}
|
{task.title}
|
||||||
</Text>
|
</Text>
|
||||||
{renderActionIcons()}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 状态和优先级标签 */}
|
|
||||||
<View style={styles.tagsContainer}>
|
|
||||||
<View style={styles.statusTag}>
|
|
||||||
<MaterialIcons name="schedule" size={12} color="#6B7280" />
|
|
||||||
<Text style={styles.statusTagText}>{getStatusText(task.status)}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
<View style={styles.progressBar}>
|
<View style={styles.progressContainer}>
|
||||||
|
{/* 背景进度条 */}
|
||||||
|
<View style={styles.progressBackground} />
|
||||||
|
|
||||||
|
{/* 实际进度条 */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.progressFill,
|
styles.progressBar,
|
||||||
{
|
{
|
||||||
width: progressAnimation.interpolate({
|
width: progressAnimation.interpolate({
|
||||||
inputRange: [0, 100],
|
inputRange: [0, 100],
|
||||||
outputRange: ['0%', '100%'],
|
outputRange: ['6%', '100%'], // 最小显示6%确保可见
|
||||||
}),
|
}),
|
||||||
backgroundColor: task.progressPercentage > 0 ? colorTokens.primary : '#E5E7EB',
|
backgroundColor: task.status === 'completed'
|
||||||
|
? '#8B5CF6' // 完成状态也使用紫色
|
||||||
|
: task.progressPercentage > 0
|
||||||
|
? '#8B5CF6'
|
||||||
|
: '#C7D2FE', // 浅紫色,表示待开始
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{task.progressPercentage > 0 && task.progressPercentage < 100 && (
|
|
||||||
<View style={styles.progressGlow} />
|
{/* 进度文字 */}
|
||||||
)}
|
<Text style={[
|
||||||
{/* 进度百分比文本 */}
|
styles.progressText,
|
||||||
<View style={styles.progressTextContainer}>
|
{
|
||||||
<Text style={styles.progressText}>{task.currentCount}/{task.targetCount}</Text>
|
color: task.progressPercentage > 20 || task.status === 'completed'
|
||||||
|
? '#FFFFFF'
|
||||||
|
: '#374151', // 进度较少时使用深色文字
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{task.currentCount}/{task.targetCount} 次
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{renderActionIcons()}
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
padding: 16,
|
padding: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 30,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
@@ -240,6 +280,91 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
|
cardContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
iconSection: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#F3E8FF', // 浅紫色背景
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 20,
|
||||||
|
color: '#1F2937', // 深蓝紫色文字
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
height: 14,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
progressBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 14,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
minWidth: '6%', // 确保最小宽度可见
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textAlign: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
actionIconsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
skipIconContainer: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
taskIcon: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
},
|
||||||
|
// 保留其他样式以备后用
|
||||||
header: {
|
header: {
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
@@ -248,38 +373,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
lineHeight: 22,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
actionIconsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
iconContainer: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: '#F3F4F6',
|
|
||||||
},
|
|
||||||
skipIconContainer: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
backgroundColor: '#F3F4F6',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
taskIcon: {
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
},
|
|
||||||
tagsContainer: {
|
tagsContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
@@ -312,7 +405,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
progressBar: {
|
progressBarOld: {
|
||||||
height: 6,
|
height: 6,
|
||||||
backgroundColor: '#F3F4F6',
|
backgroundColor: '#F3F4F6',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
@@ -360,11 +453,6 @@ const styles = StyleSheet.create({
|
|||||||
borderColor: '#E5E7EB',
|
borderColor: '#E5E7EB',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
progressText: {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#374151',
|
|
||||||
},
|
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -391,10 +479,6 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
infoSection: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
infoTag: {
|
infoTag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -409,3 +493,4 @@ const styles = StyleSheet.create({
|
|||||||
color: '#374151',
|
color: '#374151',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { TaskListItem } from '@/types/goals';
|
import { TaskListItem } from '@/types/goals';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
interface TaskProgressCardProps {
|
interface TaskProgressCardProps {
|
||||||
tasks: TaskListItem[];
|
tasks: TaskListItem[];
|
||||||
@@ -12,24 +13,40 @@ export const TaskProgressCard: React.FC<TaskProgressCardProps> = ({
|
|||||||
tasks,
|
tasks,
|
||||||
headerButtons,
|
headerButtons,
|
||||||
}) => {
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 计算各状态的任务数量
|
// 计算各状态的任务数量
|
||||||
const pendingTasks = tasks.filter(task => task.status === 'pending');
|
const pendingTasks = tasks.filter(task => task.status === 'pending');
|
||||||
const completedTasks = tasks.filter(task => task.status === 'completed');
|
const completedTasks = tasks.filter(task => task.status === 'completed');
|
||||||
const skippedTasks = tasks.filter(task => task.status === 'skipped');
|
const skippedTasks = tasks.filter(task => task.status === 'skipped');
|
||||||
|
|
||||||
|
// 处理跳转到目标列表
|
||||||
|
const handleNavigateToGoals = () => {
|
||||||
|
router.push('/goals-list');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 标题区域 */}
|
{/* 标题区域 */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Text style={styles.title}>统计</Text>
|
<Text style={styles.title}>统计</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.goalsIconButton}
|
||||||
|
onPress={handleNavigateToGoals}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="flag" size={18} color="#7A5AF8" />
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.headerActions}>
|
||||||
|
|
||||||
{headerButtons && (
|
{headerButtons && (
|
||||||
<View style={styles.headerButtons}>
|
<View style={styles.headerButtons}>
|
||||||
{headerButtons}
|
{headerButtons}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 状态卡片区域 */}
|
{/* 状态卡片区域 */}
|
||||||
<View style={styles.statusCards}>
|
<View style={styles.statusCards}>
|
||||||
@@ -85,15 +102,27 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
titleContainer: {
|
titleContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
},
|
},
|
||||||
headerButtons: {
|
headerButtons: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
goalsIconButton: {
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
lineHeight: 24,
|
||||||
|
height: 24,
|
||||||
color: '#1F2937',
|
color: '#1F2937',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<HealthDataCard
|
<HealthDataCard
|
||||||
title="血氧饱和度"
|
title="血氧饱和度"
|
||||||
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? (oxygenSaturation * 100).toFixed(1) : '--'}
|
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--'}
|
||||||
unit="%"
|
unit="%"
|
||||||
icon={oxygenIcon}
|
icon={oxygenIcon}
|
||||||
style={style}
|
style={style}
|
||||||
|
|||||||
131
docs/goal-swipe-delete-implementation.md
Normal file
131
docs/goal-swipe-delete-implementation.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# 目标列表左滑删除功能实现
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
为目标列表的卡片添加了左滑删除功能,用户可以通过左滑手势显示删除按钮,点击后确认删除目标。
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 1. 依赖库
|
||||||
|
- `react-native-gesture-handler`: 提供手势处理功能
|
||||||
|
- `@expo/vector-icons`: 提供删除图标
|
||||||
|
|
||||||
|
### 2. 核心组件修改
|
||||||
|
|
||||||
|
#### GoalCard 组件 (`components/GoalCard.tsx`)
|
||||||
|
- 使用 `Swipeable` 组件包装原有的卡片内容
|
||||||
|
- 添加 `renderRightActions` 方法渲染删除按钮
|
||||||
|
- 添加删除确认对话框
|
||||||
|
- 支持删除回调函数
|
||||||
|
|
||||||
|
#### 应用根布局 (`app/_layout.tsx`)
|
||||||
|
- 添加 `GestureHandlerRootView` 包装器
|
||||||
|
- 确保手势处理功能正常工作
|
||||||
|
|
||||||
|
#### 目标列表页面 (`app/goals-list.tsx`)
|
||||||
|
- 添加删除目标的处理逻辑
|
||||||
|
- 集成 Redux 删除 action
|
||||||
|
- 添加开发模式下的模拟数据用于测试
|
||||||
|
|
||||||
|
### 3. 功能特性
|
||||||
|
|
||||||
|
#### 左滑手势
|
||||||
|
- 支持左滑显示删除按钮
|
||||||
|
- 设置合适的滑动阈值和边界
|
||||||
|
- 自动关闭滑动状态
|
||||||
|
|
||||||
|
#### 删除确认
|
||||||
|
- 显示确认对话框
|
||||||
|
- 包含目标标题信息
|
||||||
|
- 提供取消和确认选项
|
||||||
|
|
||||||
|
#### 视觉反馈
|
||||||
|
- 红色删除按钮背景
|
||||||
|
- 删除图标和文字
|
||||||
|
- 圆角设计保持一致性
|
||||||
|
|
||||||
|
#### 状态管理
|
||||||
|
- 集成 Redux 删除 action
|
||||||
|
- 自动更新列表状态
|
||||||
|
- 错误处理和提示
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 用户操作流程
|
||||||
|
1. 在目标列表中,向左滑动任意目标卡片
|
||||||
|
2. 删除按钮会从右侧滑出
|
||||||
|
3. 点击删除按钮
|
||||||
|
4. 确认删除对话框出现
|
||||||
|
5. 选择"删除"确认或"取消"放弃
|
||||||
|
|
||||||
|
### 开发测试
|
||||||
|
在开发模式下,如果 API 调用失败,会自动加载模拟数据进行测试:
|
||||||
|
- 每日运动30分钟
|
||||||
|
- 每天喝8杯水
|
||||||
|
- 每周读书2小时
|
||||||
|
|
||||||
|
## 代码结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GoalCard 组件结构
|
||||||
|
<Swipeable
|
||||||
|
ref={swipeableRef}
|
||||||
|
renderRightActions={renderRightActions}
|
||||||
|
rightThreshold={40}
|
||||||
|
overshootRight={false}
|
||||||
|
>
|
||||||
|
<TouchableOpacity>
|
||||||
|
{/* 原有的卡片内容 */}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Swipeable>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 样式设计
|
||||||
|
|
||||||
|
### 删除按钮样式
|
||||||
|
```typescript
|
||||||
|
deleteButton: {
|
||||||
|
backgroundColor: '#EF4444',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 80,
|
||||||
|
height: '100%',
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
borderBottomRightRadius: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除按钮文字样式
|
||||||
|
```typescript
|
||||||
|
deleteButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 4,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **手势处理**: 必须使用 `GestureHandlerRootView` 包装应用
|
||||||
|
2. **类型安全**: 确保所有必要的字段都正确设置
|
||||||
|
3. **错误处理**: 删除失败时显示错误提示
|
||||||
|
4. **用户体验**: 提供确认对话框防止误删
|
||||||
|
5. **性能优化**: 使用 `useRef` 管理 Swipeable 引用
|
||||||
|
|
||||||
|
## 扩展功能
|
||||||
|
|
||||||
|
可以考虑添加的其他功能:
|
||||||
|
- 批量删除
|
||||||
|
- 撤销删除
|
||||||
|
- 删除动画效果
|
||||||
|
- 更多操作按钮(编辑、暂停等)
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
1. **手势测试**: 测试不同滑动距离和速度
|
||||||
|
2. **边界测试**: 测试快速连续滑动
|
||||||
|
3. **状态测试**: 测试删除后的列表更新
|
||||||
|
4. **错误测试**: 测试网络错误时的处理
|
||||||
|
5. **UI测试**: 测试不同屏幕尺寸下的显示效果
|
||||||
158
docs/goals-list-implementation.md
Normal file
158
docs/goals-list-implementation.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# 目标列表功能实现文档
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
基于图片中的习惯列表样式,我们创建了一个全新的目标列表页面,高保真还原了设计稿的视觉效果,并将内容替换为目标相关的内容。
|
||||||
|
|
||||||
|
## 新增文件
|
||||||
|
|
||||||
|
### 1. 目标列表页面 (`app/goals-list.tsx`)
|
||||||
|
- 完整的目标列表展示
|
||||||
|
- 支持筛选不同状态的目标
|
||||||
|
- 下拉刷新和加载更多功能
|
||||||
|
- 响应式设计和主题适配
|
||||||
|
|
||||||
|
### 2. 目标卡片组件 (`components/GoalCard.tsx`)
|
||||||
|
- 可复用的目标卡片组件
|
||||||
|
- 支持显示目标状态、重复类型、提醒等信息
|
||||||
|
- 智能图标选择(根据目标内容自动选择合适图标)
|
||||||
|
- 支持点击事件和状态显示控制
|
||||||
|
|
||||||
|
### 3. 目标筛选组件 (`components/GoalFilterTabs.tsx`)
|
||||||
|
- 支持按状态筛选目标(全部、进行中、已暂停、已完成、已取消)
|
||||||
|
- 显示各状态的目标数量
|
||||||
|
- 美观的标签式设计
|
||||||
|
|
||||||
|
### 4. 目标详情页面 (`app/goal-detail.tsx`)
|
||||||
|
- 显示单个目标的详细信息
|
||||||
|
- 目标统计信息(已完成、目标、进度)
|
||||||
|
- 目标属性信息(状态、重复类型、时间等)
|
||||||
|
- 完成记录列表
|
||||||
|
|
||||||
|
## 设计特点
|
||||||
|
|
||||||
|
### 视觉设计
|
||||||
|
- **高保真还原**: 完全按照图片中的习惯列表样式设计
|
||||||
|
- **卡片式布局**: 白色圆角卡片,带有阴影效果
|
||||||
|
- **图标设计**: 紫色主题图标,带有白色星星装饰
|
||||||
|
- **信息层次**: 清晰的信息层次结构,主标题 + 详细信息行
|
||||||
|
|
||||||
|
### 交互设计
|
||||||
|
- **点击反馈**: 卡片点击有视觉反馈
|
||||||
|
- **筛选功能**: 支持按状态筛选目标
|
||||||
|
- **导航流畅**: 支持导航到目标详情页面
|
||||||
|
- **下拉刷新**: 支持下拉刷新数据
|
||||||
|
|
||||||
|
## 数据结构映射
|
||||||
|
|
||||||
|
### 图片中的习惯信息 → 目标信息
|
||||||
|
| 图片元素 | 目标对应 | 说明 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| 习惯标题 | 目标标题 | 显示目标名称 |
|
||||||
|
| +1 | 积分 | 显示目标完成获得的积分 |
|
||||||
|
| 目标数量 | 目标数量/频率 | 显示目标的目标数量或频率 |
|
||||||
|
| 提醒图标 | 提醒设置 | 如果目标有提醒则显示 |
|
||||||
|
| 提醒时间 | 提醒时间 | 显示具体的提醒时间 |
|
||||||
|
| 重复图标 | 重复类型 | 显示循环图标 |
|
||||||
|
| 重复类型 | 重复类型 | 显示"每日"、"每周"、"每月" |
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 智能图标选择
|
||||||
|
根据目标标题和类别自动选择合适图标:
|
||||||
|
- 运动相关:`fitness-center`
|
||||||
|
- 饮水相关:`local-drink`
|
||||||
|
- 睡眠相关:`bedtime`
|
||||||
|
- 学习相关:`school`
|
||||||
|
- 冥想相关:`self-improvement`
|
||||||
|
- 饮食相关:`restaurant`
|
||||||
|
- 默认:`flag`
|
||||||
|
|
||||||
|
### 2. 状态管理
|
||||||
|
- 使用Redux进行状态管理
|
||||||
|
- 支持分页加载
|
||||||
|
- 错误处理和加载状态
|
||||||
|
- 筛选状态保持
|
||||||
|
|
||||||
|
### 3. 响应式设计
|
||||||
|
- 适配不同屏幕尺寸
|
||||||
|
- 支持浅色/深色主题
|
||||||
|
- 流畅的动画效果
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
### 1. 访问目标列表
|
||||||
|
在目标页面点击"列表"按钮,或直接访问 `/goals-list` 路由。
|
||||||
|
|
||||||
|
### 2. 筛选目标
|
||||||
|
使用顶部的筛选标签,可以按状态筛选目标:
|
||||||
|
- 全部:显示所有目标
|
||||||
|
- 进行中:显示正在进行的目标
|
||||||
|
- 已暂停:显示已暂停的目标
|
||||||
|
- 已完成:显示已完成的目标
|
||||||
|
- 已取消:显示已取消的目标
|
||||||
|
|
||||||
|
### 3. 查看目标详情
|
||||||
|
点击任意目标卡片,可以查看目标的详细信息。
|
||||||
|
|
||||||
|
### 4. 刷新数据
|
||||||
|
下拉列表可以刷新目标数据。
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 组件架构
|
||||||
|
```
|
||||||
|
GoalsListScreen
|
||||||
|
├── GoalFilterTabs (筛选标签)
|
||||||
|
└── FlatList
|
||||||
|
└── GoalCard (目标卡片)
|
||||||
|
├── 图标区域
|
||||||
|
├── 内容区域
|
||||||
|
└── 状态指示器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
- 使用Redux Toolkit管理目标数据
|
||||||
|
- 支持分页和筛选状态
|
||||||
|
- 错误处理和加载状态
|
||||||
|
|
||||||
|
### 样式系统
|
||||||
|
- 使用StyleSheet进行样式管理
|
||||||
|
- 支持主题切换
|
||||||
|
- 响应式设计
|
||||||
|
|
||||||
|
## 扩展功能
|
||||||
|
|
||||||
|
### 1. 搜索功能
|
||||||
|
可以添加搜索框,支持按标题搜索目标。
|
||||||
|
|
||||||
|
### 2. 排序功能
|
||||||
|
可以添加排序选项,按创建时间、优先级等排序。
|
||||||
|
|
||||||
|
### 3. 批量操作
|
||||||
|
可以添加批量选择、批量操作功能。
|
||||||
|
|
||||||
|
### 4. 统计图表
|
||||||
|
可以添加目标完成情况的统计图表。
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
### 1. 功能测试
|
||||||
|
- 测试目标列表加载
|
||||||
|
- 测试筛选功能
|
||||||
|
- 测试点击导航
|
||||||
|
- 测试下拉刷新
|
||||||
|
|
||||||
|
### 2. 样式测试
|
||||||
|
- 测试不同屏幕尺寸
|
||||||
|
- 测试主题切换
|
||||||
|
- 测试长文本显示
|
||||||
|
|
||||||
|
### 3. 性能测试
|
||||||
|
- 测试大量数据加载
|
||||||
|
- 测试内存使用情况
|
||||||
|
- 测试滚动性能
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
新创建的目标列表功能完全按照设计稿实现,提供了良好的用户体验和完整的功能支持。通过模块化的组件设计,代码具有良好的可维护性和可扩展性。
|
||||||
@@ -220,8 +220,15 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
// 获取最新的血氧饱和度值
|
// 获取最新的血氧饱和度值
|
||||||
const latestOxygen = res[res.length - 1];
|
const latestOxygen = res[res.length - 1];
|
||||||
if (latestOxygen && latestOxygen.value !== undefined && latestOxygen.value !== null) {
|
if (latestOxygen && latestOxygen.value !== undefined && latestOxygen.value !== null) {
|
||||||
|
let value = Number(latestOxygen.value);
|
||||||
|
|
||||||
|
// 检查数据格式:如果值小于1,可能是小数形式(0.0-1.0),需要转换为百分比
|
||||||
|
if (value > 0 && value < 1) {
|
||||||
|
value = value * 100;
|
||||||
|
console.log('血氧饱和度数据从小数转换为百分比:', latestOxygen.value, '->', value);
|
||||||
|
}
|
||||||
|
|
||||||
// 血氧饱和度通常在0-100之间,验证数据有效性
|
// 血氧饱和度通常在0-100之间,验证数据有效性
|
||||||
const value = Number(latestOxygen.value);
|
|
||||||
if (value >= 0 && value <= 100) {
|
if (value >= 0 && value <= 100) {
|
||||||
resolve(Number(value.toFixed(1)));
|
resolve(Number(value.toFixed(1)));
|
||||||
} else {
|
} else {
|
||||||
@@ -346,3 +353,66 @@ export async function updateWeight(weight: number) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新增:测试血氧饱和度数据获取
|
||||||
|
export async function testOxygenSaturationData(date: Date = new Date()): Promise<void> {
|
||||||
|
console.log('=== 开始测试血氧饱和度数据获取 ===');
|
||||||
|
|
||||||
|
const start = dayjs(date).startOf('day').toDate();
|
||||||
|
const end = dayjs(date).endOf('day').toDate();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
startDate: start.toISOString(),
|
||||||
|
endDate: end.toISOString()
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
AppleHealthKit.getOxygenSaturationSamples(options, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('获取血氧饱和度失败:', err);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('原始血氧饱和度数据:', res);
|
||||||
|
|
||||||
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
|
console.warn('血氧饱和度数据为空');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析所有数据样本
|
||||||
|
res.forEach((sample, index) => {
|
||||||
|
console.log(`样本 ${index + 1}:`, {
|
||||||
|
value: sample.value,
|
||||||
|
valueType: typeof sample.value,
|
||||||
|
startDate: sample.startDate,
|
||||||
|
endDate: sample.endDate
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取最新的血氧饱和度值
|
||||||
|
const latestOxygen = res[res.length - 1];
|
||||||
|
if (latestOxygen && latestOxygen.value !== undefined && latestOxygen.value !== null) {
|
||||||
|
let value = Number(latestOxygen.value);
|
||||||
|
|
||||||
|
console.log('处理前的值:', latestOxygen.value);
|
||||||
|
console.log('转换为数字后的值:', value);
|
||||||
|
|
||||||
|
// 检查数据格式:如果值小于1,可能是小数形式(0.0-1.0),需要转换为百分比
|
||||||
|
if (value > 0 && value < 1) {
|
||||||
|
const originalValue = value;
|
||||||
|
value = value * 100;
|
||||||
|
console.log('血氧饱和度数据从小数转换为百分比:', originalValue, '->', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('最终处理后的值:', value);
|
||||||
|
console.log('数据有效性检查:', value >= 0 && value <= 100 ? '有效' : '无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== 血氧饱和度数据测试完成 ===');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user