feat: 新增任务管理功能及相关组件
- 将目标页面改为任务列表,支持任务的创建、完成和跳过功能 - 新增任务卡片和任务进度卡片组件,展示任务状态和进度 - 实现任务数据的获取和管理,集成Redux状态管理 - 更新API服务,支持任务相关的CRUD操作 - 编写任务管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
@@ -1827,12 +1827,17 @@ export default function CoachScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.screen}>
|
<View style={styles.screen}>
|
||||||
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[theme.backgroundGradientStart, theme.backgroundGradientEnd]}
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
style={styles.gradientBackground}
|
style={styles.gradientBackground}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 0, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
|
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
|
||||||
<View
|
<View
|
||||||
style={[styles.header, { paddingTop: insets.top + 10 }]}
|
style={[styles.header, { paddingTop: insets.top + 10 }]}
|
||||||
@@ -2124,6 +2129,34 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
},
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -2673,13 +2706,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FF4444',
|
color: '#FF4444',
|
||||||
},
|
},
|
||||||
gradientBackground: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
// 饮食方案卡片样式
|
// 饮食方案卡片样式
|
||||||
dietPlanContainer: {
|
dietPlanContainer: {
|
||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
|||||||
@@ -1,121 +1,111 @@
|
|||||||
import CreateGoalModal from '@/components/CreateGoalModal';
|
import CreateGoalModal from '@/components/CreateGoalModal';
|
||||||
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
|
import { TaskCard } from '@/components/TaskCard';
|
||||||
import { TimelineSchedule } from '@/components/TimelineSchedule';
|
import { TaskProgressCard } from '@/components/TaskProgressCard';
|
||||||
import { TodoItem } from '@/components/TodoCard';
|
|
||||||
import { TodoCarousel } from '@/components/TodoCarousel';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { clearErrors, completeGoal, createGoal, fetchGoals } from '@/store/goalsSlice';
|
import { clearErrors, createGoal } from '@/store/goalsSlice';
|
||||||
import { CreateGoalRequest, GoalListItem } from '@/types/goals';
|
import { clearErrors as clearTaskErrors, completeTask, fetchTasks, loadMoreTasks, skipTask } from '@/store/tasksSlice';
|
||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useRouter } from 'expo-router';
|
||||||
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
dayjs.extend(isBetween);
|
|
||||||
|
|
||||||
// 将目标转换为TodoItem的辅助函数
|
|
||||||
const goalToTodoItem = (goal: GoalListItem): TodoItem => {
|
|
||||||
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),
|
|
||||||
isCompleted: goal.status === 'completed',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取重复类型标签
|
|
||||||
const getRepeatTypeLabel = (repeatType: string): string => {
|
|
||||||
switch (repeatType) {
|
|
||||||
case 'daily': return '每日';
|
|
||||||
case 'weekly': return '每周';
|
|
||||||
case 'monthly': return '每月';
|
|
||||||
default: return '自定义';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从目标分类获取TodoItem分类
|
|
||||||
const getCategoryFromGoal = (category?: string): TodoItem['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';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从目标优先级获取TodoItem优先级
|
|
||||||
const getPriorityFromGoal = (priority: number): TodoItem['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 GoalsScreen() {
|
export default function GoalsScreen() {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// Redux状态
|
// Redux状态
|
||||||
const {
|
const {
|
||||||
goals,
|
tasks,
|
||||||
goalsLoading,
|
tasksLoading,
|
||||||
goalsError,
|
tasksError,
|
||||||
|
tasksPagination,
|
||||||
|
completeLoading,
|
||||||
|
completeError,
|
||||||
|
skipLoading,
|
||||||
|
skipError,
|
||||||
|
} = useAppSelector((state) => state.tasks);
|
||||||
|
|
||||||
|
const {
|
||||||
createLoading,
|
createLoading,
|
||||||
createError
|
createError
|
||||||
} = useAppSelector((state) => state.goals);
|
} = useAppSelector((state) => state.goals);
|
||||||
|
|
||||||
const [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
// 页面聚焦时重新加载数据
|
// 页面聚焦时重新加载数据
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
console.log('useFocusEffect');
|
console.log('useFocusEffect - loading tasks');
|
||||||
// 只在需要时刷新数据,比如从后台返回或从其他页面返回
|
loadTasks();
|
||||||
dispatch(fetchGoals({
|
|
||||||
status: 'active',
|
|
||||||
page: 1,
|
|
||||||
pageSize: 200,
|
|
||||||
}));
|
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 加载任务列表
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(fetchTasks({
|
||||||
|
startDate: dayjs().startOf('day').toISOString(),
|
||||||
|
endDate: dayjs().endOf('day').toISOString(),
|
||||||
|
})).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tasks:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await loadTasks();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载更多任务
|
||||||
|
const handleLoadMoreTasks = async () => {
|
||||||
|
if (tasksPagination.hasMore && !tasksLoading) {
|
||||||
|
try {
|
||||||
|
await dispatch(loadMoreTasks()).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load more tasks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理错误提示
|
// 处理错误提示
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('goalsError', goalsError);
|
console.log('tasksError', tasksError);
|
||||||
console.log('createError', createError);
|
console.log('createError', createError);
|
||||||
if (goalsError) {
|
console.log('completeError', completeError);
|
||||||
Alert.alert('错误', goalsError);
|
console.log('skipError', skipError);
|
||||||
dispatch(clearErrors());
|
|
||||||
|
if (tasksError) {
|
||||||
|
Alert.alert('错误', tasksError);
|
||||||
|
dispatch(clearTaskErrors());
|
||||||
}
|
}
|
||||||
if (createError) {
|
if (createError) {
|
||||||
Alert.alert('创建失败', createError);
|
Alert.alert('创建失败', createError);
|
||||||
dispatch(clearErrors());
|
dispatch(clearErrors());
|
||||||
}
|
}
|
||||||
}, [goalsError, createError, dispatch]);
|
if (completeError) {
|
||||||
|
Alert.alert('完成失败', completeError);
|
||||||
|
dispatch(clearTaskErrors());
|
||||||
|
}
|
||||||
|
if (skipError) {
|
||||||
|
Alert.alert('跳过失败', skipError);
|
||||||
|
dispatch(clearTaskErrors());
|
||||||
|
}
|
||||||
|
}, [tasksError, createError, completeError, skipError, dispatch]);
|
||||||
|
|
||||||
// 创建目标处理函数
|
// 创建目标处理函数
|
||||||
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
|
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
|
||||||
@@ -123,156 +113,85 @@ export default function GoalsScreen() {
|
|||||||
await dispatch(createGoal(goalData)).unwrap();
|
await dispatch(createGoal(goalData)).unwrap();
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
Alert.alert('成功', '目标创建成功!');
|
Alert.alert('成功', '目标创建成功!');
|
||||||
|
// 创建目标后重新加载任务列表
|
||||||
|
loadTasks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 错误已在useEffect中处理
|
// 错误已在useEffect中处理
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// tab切换处理函数
|
// 任务点击处理
|
||||||
const handleTabChange = (tab: TimeTabType) => {
|
const handleTaskPress = (task: TaskListItem) => {
|
||||||
setSelectedTab(tab);
|
console.log('Task pressed:', task.title);
|
||||||
|
// 这里可以导航到任务详情页面
|
||||||
// 当切换到周或月模式时,如果当前选择的日期不是今天,则重置为今天
|
|
||||||
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 handleCompleteTask = async (task: TaskListItem) => {
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 将目标转换为TodoItem数据
|
|
||||||
const todayTodos = 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(goalToTodoItem);
|
|
||||||
}, [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 handleTodoPress = (item: TodoItem) => {
|
|
||||||
console.log('Goal pressed:', item.title);
|
|
||||||
// 这里可以导航到目标详情页面
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleComplete = async (item: TodoItem) => {
|
|
||||||
try {
|
try {
|
||||||
await dispatch(completeGoal({
|
await dispatch(completeTask({
|
||||||
goalId: item.id,
|
taskId: task.id,
|
||||||
completionData: {
|
completionData: {
|
||||||
completionCount: 1,
|
count: 1,
|
||||||
notes: '通过待办卡片完成'
|
notes: '通过任务卡片完成'
|
||||||
}
|
}
|
||||||
})).unwrap();
|
})).unwrap();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert('错误', '记录完成失败');
|
Alert.alert('错误', '完成任务失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEventPress = (event: any) => {
|
// 跳过任务处理
|
||||||
console.log('Event pressed:', event.title);
|
const handleSkipTask = async (task: TaskListItem) => {
|
||||||
// 这里可以处理时间轴事件点击
|
try {
|
||||||
|
await dispatch(skipTask({
|
||||||
|
taskId: task.id,
|
||||||
|
skipData: {
|
||||||
|
reason: '用户主动跳过'
|
||||||
|
}
|
||||||
|
})).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', '跳过任务失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导航到目标管理页面
|
||||||
|
const handleNavigateToGoals = () => {
|
||||||
|
router.push('/goals-detail');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染任务项
|
||||||
|
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
|
||||||
|
<TaskCard
|
||||||
|
task={item}
|
||||||
|
onPress={handleTaskPress}
|
||||||
|
onComplete={handleCompleteTask}
|
||||||
|
onSkip={handleSkipTask}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
const renderEmptyState = () => (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||||
|
暂无任务
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
创建目标后,系统会自动生成相应的任务
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染加载更多
|
||||||
|
const renderLoadMore = () => {
|
||||||
|
if (!tasksPagination.hasMore) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.loadMoreContainer}>
|
||||||
|
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{tasksLoading ? '加载中...' : '上拉加载更多'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -284,103 +203,61 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
{/* 背景渐变 */}
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
colorTokens.backgroundGradientStart,
|
style={styles.gradientBackground}
|
||||||
colorTokens.backgroundGradientEnd,
|
start={{ x: 0, y: 0 }}
|
||||||
]}
|
end={{ x: 1, y: 1 }}
|
||||||
style={styles.backgroundGradient}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* 标题区域 */}
|
{/* 标题区域 */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||||||
今日
|
任务
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<View style={styles.headerButtons}>
|
||||||
style={styles.addButton}
|
<TouchableOpacity
|
||||||
onPress={() => setShowCreateModal(true)}
|
style={styles.goalsButton}
|
||||||
>
|
onPress={handleNavigateToGoals}
|
||||||
<Text style={styles.addButtonText}>+</Text>
|
>
|
||||||
</TouchableOpacity>
|
<MaterialIcons name="flag" size={16} color="#0EA5E9" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addButton}
|
||||||
|
onPress={() => setShowCreateModal(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.addButtonText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 今日待办事项卡片 */}
|
{/* 任务进度卡片 */}
|
||||||
<TodoCarousel
|
<TaskProgressCard tasks={tasks} />
|
||||||
todos={todayTodos}
|
|
||||||
onTodoPress={handleTodoPress}
|
|
||||||
onToggleComplete={handleToggleComplete}
|
|
||||||
/>
|
|
||||||
{/* 时间筛选选项卡 */}
|
|
||||||
<TimeTabSelector
|
|
||||||
selectedTab={selectedTab}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 日期选择器 - 在周和月模式下显示 */}
|
{/* 任务列表 */}
|
||||||
{(selectedTab === 'week' || selectedTab === 'month') && (
|
<View style={styles.taskListContainer}>
|
||||||
<View style={styles.dateSelector}>
|
<FlatList
|
||||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>
|
data={tasks}
|
||||||
{monthTitle}
|
renderItem={renderTaskItem}
|
||||||
</Text>
|
keyExtractor={(item) => item.id}
|
||||||
<ScrollView
|
contentContainerStyle={styles.taskList}
|
||||||
horizontal
|
showsVerticalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
refreshControl={
|
||||||
contentContainerStyle={styles.daysContainer}
|
<RefreshControl
|
||||||
ref={daysScrollRef}
|
refreshing={refreshing}
|
||||||
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
onRefresh={onRefresh}
|
||||||
>
|
colors={['#0EA5E9']}
|
||||||
{days.map((d, i) => {
|
tintColor="#0EA5E9"
|
||||||
const selected = i === selectedIndex;
|
/>
|
||||||
const isFutureDate = d.date.isAfter(dayjs(), 'day');
|
}
|
||||||
|
onEndReached={handleLoadMoreTasks}
|
||||||
// 根据选择的tab模式决定是否显示该日期
|
onEndReachedThreshold={0.1}
|
||||||
let shouldShow = true;
|
ListEmptyComponent={renderEmptyState}
|
||||||
if (selectedTab === 'week') {
|
ListFooterComponent={renderLoadMore}
|
||||||
// 周模式:只显示选中日期所在周的日期
|
|
||||||
const selectedWeekStart = dayjs(selectedDate).startOf('week');
|
|
||||||
const selectedWeekEnd = dayjs(selectedDate).endOf('week');
|
|
||||||
shouldShow = d.date.isBetween(selectedWeekStart, selectedWeekEnd, 'day', '[]');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldShow) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.dayPill,
|
|
||||||
selected ? styles.dayPillSelected : styles.dayPillNormal,
|
|
||||||
isFutureDate && styles.dayPillDisabled
|
|
||||||
]}
|
|
||||||
onPress={() => !isFutureDate && onSelectDate(i)}
|
|
||||||
activeOpacity={isFutureDate ? 1 : 0.8}
|
|
||||||
disabled={isFutureDate}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.dayLabel,
|
|
||||||
selected && styles.dayLabelSelected,
|
|
||||||
isFutureDate && styles.dayLabelDisabled
|
|
||||||
]}> {d.weekdayZh} </Text>
|
|
||||||
<Text style={[
|
|
||||||
styles.dayDate,
|
|
||||||
selected && styles.dayDateSelected,
|
|
||||||
isFutureDate && styles.dayDateDisabled
|
|
||||||
]}>{d.dayOfMonth}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 时间轴安排 */}
|
|
||||||
<View style={styles.timelineSection}>
|
|
||||||
<TimelineSchedule
|
|
||||||
events={filteredTimelineEvents}
|
|
||||||
selectedDate={selectedDate}
|
|
||||||
onEventPress={handleEventPress}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -400,12 +277,33 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
backgroundGradient: {
|
gradientBackground: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 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: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -418,16 +316,35 @@ const styles = StyleSheet.create({
|
|||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
},
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
goalsButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
pageTitle: {
|
pageTitle: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
addButton: {
|
addButton: {
|
||||||
width: 40,
|
width: 30,
|
||||||
height: 40,
|
height: 30,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
backgroundColor: '#6366F1',
|
backgroundColor: '#0EA5E9',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@@ -438,80 +355,44 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
addButtonText: {
|
addButtonText: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontSize: 24,
|
fontSize: 22,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
lineHeight: 24,
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
pageSubtitle: {
|
|
||||||
fontSize: 16,
|
taskListContainer: {
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
timelineSection: {
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
// 日期选择器样式 (参考 statistics.tsx)
|
taskList: {
|
||||||
dateSelector: {
|
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 16,
|
paddingTop: 20,
|
||||||
|
paddingBottom: 20,
|
||||||
},
|
},
|
||||||
monthTitle: {
|
emptyState: {
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '800',
|
|
||||||
marginBottom: 14,
|
|
||||||
},
|
|
||||||
daysContainer: {
|
|
||||||
paddingBottom: 8,
|
|
||||||
},
|
|
||||||
dayItemWrapper: {
|
|
||||||
alignItems: 'center',
|
|
||||||
width: 48,
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
dayPill: {
|
|
||||||
width: 40,
|
|
||||||
height: 60,
|
|
||||||
borderRadius: 24,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
},
|
},
|
||||||
dayPillNormal: {
|
emptyStateTitle: {
|
||||||
backgroundColor: 'transparent',
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
dayPillSelected: {
|
emptyStateSubtitle: {
|
||||||
backgroundColor: '#FFFFFF',
|
fontSize: 14,
|
||||||
shadowColor: '#000',
|
textAlign: 'center',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
lineHeight: 20,
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 3,
|
|
||||||
},
|
},
|
||||||
dayPillDisabled: {
|
loadMoreContainer: {
|
||||||
backgroundColor: 'transparent',
|
alignItems: 'center',
|
||||||
opacity: 0.4,
|
paddingVertical: 20,
|
||||||
},
|
},
|
||||||
dayLabel: {
|
loadMoreText: {
|
||||||
fontSize: 11,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '500',
|
||||||
color: 'gray',
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
dayLabelSelected: {
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
dayLabelDisabled: {
|
|
||||||
},
|
|
||||||
dayDate: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: 'gray',
|
|
||||||
},
|
|
||||||
dayDateSelected: {
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
dayDateDisabled: {
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||||
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
import { MoodCard } from '@/components/MoodCard';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
import { ProgressBar } from '@/components/ProgressBar';
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
import { StressMeter } from '@/components/StressMeter';
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
||||||
|
import HeartRateCard from '@/components/statistic/HeartRateCard';
|
||||||
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
@@ -13,7 +16,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
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, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
import { ensureHealthPermissions, fetchHealthDataForDate } 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';
|
||||||
@@ -27,8 +30,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
View
|
||||||
View,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
@@ -92,7 +94,6 @@ export default function ExploreScreen() {
|
|||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
// 使用 dayjs:当月日期与默认选中"今天"
|
// 使用 dayjs:当月日期与默认选中"今天"
|
||||||
const days = getMonthDaysZh();
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
const tabBarHeight = useBottomTabBarHeight();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -100,43 +101,6 @@ export default function ExploreScreen() {
|
|||||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||||
}, [tabBarHeight, insets?.bottom]);
|
}, [tabBarHeight, insets?.bottom]);
|
||||||
|
|
||||||
const monthTitle = getMonthTitleZh();
|
|
||||||
|
|
||||||
// 日期条自动滚动到选中项
|
|
||||||
const daysScrollRef = useRef<import('react-native').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]);
|
|
||||||
|
|
||||||
// HealthKit: 每次页面聚焦都拉取今日数据
|
// HealthKit: 每次页面聚焦都拉取今日数据
|
||||||
const [stepCount, setStepCount] = useState<number | null>(null);
|
const [stepCount, setStepCount] = useState<number | null>(null);
|
||||||
const [activeCalories, setActiveCalories] = useState<number | null>(null);
|
const [activeCalories, setActiveCalories] = useState<number | null>(null);
|
||||||
@@ -170,16 +134,22 @@ export default function ExploreScreen() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [isMoodLoading, setIsMoodLoading] = useState(false);
|
const [isMoodLoading, setIsMoodLoading] = useState(false);
|
||||||
|
|
||||||
// 从 Redux 获取当前日期的心情记录
|
|
||||||
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
|
||||||
days[selectedIndex]?.date?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD')
|
|
||||||
));
|
|
||||||
|
|
||||||
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
||||||
const latestRequestKeyRef = useRef<string | null>(null);
|
const latestRequestKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||||
|
|
||||||
|
// 获取当前选中日期
|
||||||
|
const getCurrentSelectedDate = () => {
|
||||||
|
const days = getMonthDaysZh();
|
||||||
|
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 Redux 获取当前日期的心情记录
|
||||||
|
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
||||||
|
dayjs(getCurrentSelectedDate()).format('YYYY-MM-DD')
|
||||||
|
));
|
||||||
|
|
||||||
// 加载心情数据
|
// 加载心情数据
|
||||||
const loadMoodData = async (targetDate?: Date) => {
|
const loadMoodData = async (targetDate?: Date) => {
|
||||||
if (!isLoggedIn) return;
|
if (!isLoggedIn) return;
|
||||||
@@ -192,7 +162,7 @@ export default function ExploreScreen() {
|
|||||||
if (targetDate) {
|
if (targetDate) {
|
||||||
derivedDate = targetDate;
|
derivedDate = targetDate;
|
||||||
} else {
|
} else {
|
||||||
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date();
|
derivedDate = getCurrentSelectedDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||||
@@ -223,7 +193,7 @@ export default function ExploreScreen() {
|
|||||||
if (targetDate) {
|
if (targetDate) {
|
||||||
derivedDate = targetDate;
|
derivedDate = targetDate;
|
||||||
} else {
|
} else {
|
||||||
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date();
|
derivedDate = getCurrentSelectedDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestKey = getDateKey(derivedDate);
|
const requestKey = getDateKey(derivedDate);
|
||||||
@@ -278,7 +248,7 @@ export default function ExploreScreen() {
|
|||||||
if (targetDate) {
|
if (targetDate) {
|
||||||
derivedDate = targetDate;
|
derivedDate = targetDate;
|
||||||
} else {
|
} else {
|
||||||
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date();
|
derivedDate = getCurrentSelectedDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('加载营养数据...', derivedDate);
|
console.log('加载营养数据...', derivedDate);
|
||||||
@@ -306,7 +276,7 @@ export default function ExploreScreen() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||||||
const currentDate = days[selectedIndex]?.date?.toDate();
|
const currentDate = getCurrentSelectedDate();
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
loadHealthData(currentDate);
|
loadHealthData(currentDate);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
@@ -318,15 +288,12 @@ export default function ExploreScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 日期点击时,加载对应日期数据
|
// 日期点击时,加载对应日期数据
|
||||||
const onSelectDate = (index: number) => {
|
const onSelectDate = (index: number, date: Date) => {
|
||||||
setSelectedIndex(index);
|
setSelectedIndex(index);
|
||||||
const target = days[index]?.date?.toDate();
|
loadHealthData(date);
|
||||||
if (target) {
|
if (isLoggedIn) {
|
||||||
loadHealthData(target);
|
loadNutritionData(date);
|
||||||
if (isLoggedIn) {
|
loadMoodData(date);
|
||||||
loadNutritionData(target);
|
|
||||||
loadMoodData(target);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -335,12 +302,18 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={backgroundGradientColors}
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
style={styles.gradientBackground}
|
style={styles.gradientBackground}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 0, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
@@ -351,46 +324,13 @@ export default function ExploreScreen() {
|
|||||||
<Text style={styles.sectionTitle}>健康数据</Text>
|
<Text style={styles.sectionTitle}>健康数据</Text>
|
||||||
<WeightHistoryCard />
|
<WeightHistoryCard />
|
||||||
|
|
||||||
{/* 标题与日期选择 */}
|
{/* 日期选择器 */}
|
||||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
<DateSelector
|
||||||
<ScrollView
|
selectedIndex={selectedIndex}
|
||||||
horizontal
|
onDateSelect={onSelectDate}
|
||||||
showsHorizontalScrollIndicator={false}
|
showMonthTitle={true}
|
||||||
contentContainerStyle={styles.daysContainer}
|
disableFutureDates={true}
|
||||||
ref={daysScrollRef}
|
/>
|
||||||
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
|
||||||
>
|
|
||||||
{days.map((d, i) => {
|
|
||||||
const selected = i === selectedIndex;
|
|
||||||
const isFutureDate = d.date.isAfter(dayjs(), 'day');
|
|
||||||
return (
|
|
||||||
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.dayPill,
|
|
||||||
selected ? styles.dayPillSelected : styles.dayPillNormal,
|
|
||||||
isFutureDate && styles.dayPillDisabled
|
|
||||||
]}
|
|
||||||
onPress={() => !isFutureDate && onSelectDate(i)}
|
|
||||||
activeOpacity={isFutureDate ? 1 : 0.8}
|
|
||||||
disabled={isFutureDate}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.dayLabel,
|
|
||||||
selected && styles.dayLabelSelected,
|
|
||||||
isFutureDate && styles.dayLabelDisabled
|
|
||||||
]}> {d.weekdayZh} </Text>
|
|
||||||
<Text style={[
|
|
||||||
styles.dayDate,
|
|
||||||
selected && styles.dayDateSelected,
|
|
||||||
isFutureDate && styles.dayDateDisabled
|
|
||||||
]}>{d.dayOfMonth}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{selected && <View style={styles.selectedDot} />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* 营养摄入雷达图卡片 */}
|
{/* 营养摄入雷达图卡片 */}
|
||||||
<NutritionRadarCard
|
<NutritionRadarCard
|
||||||
@@ -491,6 +431,22 @@ export default function ExploreScreen() {
|
|||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
|
{/* 血氧饱和度卡片 */}
|
||||||
|
<FloatingCard style={styles.masonryCard} delay={1750}>
|
||||||
|
<OxygenSaturationCard
|
||||||
|
resetToken={animToken}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
{/* 心率卡片 */}
|
||||||
|
<FloatingCard style={styles.masonryCard} delay={2000}>
|
||||||
|
<HeartRateCard
|
||||||
|
resetToken={animToken}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -514,6 +470,26 @@ const styles = StyleSheet.create({
|
|||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
},
|
},
|
||||||
|
decorativeCircle1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 40,
|
||||||
|
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,
|
||||||
|
},
|
||||||
safeArea: {
|
safeArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@@ -521,70 +497,8 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
monthTitle: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#192126',
|
|
||||||
marginTop: 8,
|
|
||||||
marginBottom: 14,
|
|
||||||
},
|
|
||||||
daysContainer: {
|
|
||||||
paddingBottom: 8,
|
|
||||||
},
|
|
||||||
dayItemWrapper: {
|
|
||||||
alignItems: 'center',
|
|
||||||
width: 48,
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
dayPill: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
dayPillNormal: {
|
|
||||||
backgroundColor: lightColors.datePickerNormal,
|
|
||||||
},
|
|
||||||
dayPillSelected: {
|
|
||||||
backgroundColor: lightColors.datePickerSelected,
|
|
||||||
},
|
|
||||||
dayPillDisabled: {
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
dayLabel: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#192126',
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
dayLabelSelected: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
|
||||||
dayLabelDisabled: {
|
|
||||||
color: '#9AA3AE',
|
|
||||||
},
|
|
||||||
dayDate: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
dayDateSelected: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
|
||||||
dayDateDisabled: {
|
|
||||||
color: '#9AA3AE',
|
|
||||||
},
|
|
||||||
selectedDot: {
|
|
||||||
width: 5,
|
|
||||||
height: 5,
|
|
||||||
borderRadius: 2.5,
|
|
||||||
backgroundColor: lightColors.datePickerSelected,
|
|
||||||
marginTop: 6,
|
|
||||||
marginBottom: 2,
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
|
|||||||
465
app/goals-detail.tsx
Normal file
465
app/goals-detail.tsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import CreateGoalModal from '@/components/CreateGoalModal';
|
||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
|
||||||
|
import { TimelineSchedule } from '@/components/TimelineSchedule';
|
||||||
|
import { TodoItem } from '@/components/TodoCard';
|
||||||
|
import { TodoCarousel } from '@/components/TodoCarousel';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { clearErrors, completeGoal, 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);
|
||||||
|
|
||||||
|
// 将目标转换为TodoItem的辅助函数
|
||||||
|
const goalToTodoItem = (goal: GoalListItem): TodoItem => {
|
||||||
|
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),
|
||||||
|
isCompleted: goal.status === 'completed',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取重复类型标签
|
||||||
|
const getRepeatTypeLabel = (repeatType: string): string => {
|
||||||
|
switch (repeatType) {
|
||||||
|
case 'daily': return '每日';
|
||||||
|
case 'weekly': return '每周';
|
||||||
|
case 'monthly': return '每月';
|
||||||
|
default: return '自定义';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从目标分类获取TodoItem分类
|
||||||
|
const getCategoryFromGoal = (category?: string): TodoItem['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';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从目标优先级获取TodoItem优先级
|
||||||
|
const getPriorityFromGoal = (priority: number): TodoItem['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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将目标转换为TodoItem数据
|
||||||
|
const todayTodos = 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(goalToTodoItem);
|
||||||
|
}, [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 handleTodoPress = (item: TodoItem) => {
|
||||||
|
console.log('Goal pressed:', item.title);
|
||||||
|
// 这里可以导航到目标详情页面
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleComplete = async (item: TodoItem) => {
|
||||||
|
try {
|
||||||
|
await dispatch(completeGoal({
|
||||||
|
goalId: item.id,
|
||||||
|
completionData: {
|
||||||
|
completionCount: 1,
|
||||||
|
notes: '通过待办卡片完成'
|
||||||
|
}
|
||||||
|
})).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', '记录完成失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 今日待办事项卡片 */}
|
||||||
|
<TodoCarousel
|
||||||
|
todos={todayTodos}
|
||||||
|
onTodoPress={handleTodoPress}
|
||||||
|
onToggleComplete={handleToggleComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 时间筛选选项卡 */}
|
||||||
|
<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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -47,7 +47,6 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis
|
|||||||
{/* 头部区域 */}
|
{/* 头部区域 */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.leftSection}>
|
<View style={styles.leftSection}>
|
||||||
|
|
||||||
<Text style={styles.title}>基础代谢</Text>
|
<Text style={styles.title}>基础代谢</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals';
|
import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import WheelPickerExpo from 'react-native-wheel-picker-expo';
|
||||||
|
|
||||||
interface CreateGoalModalProps {
|
interface CreateGoalModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -30,7 +33,7 @@ const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [
|
|||||||
{ value: 'custom', label: '自定义' },
|
{ value: 'custom', label: '自定义' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FREQUENCY_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
const FREQUENCY_OPTIONS = Array.from({ length: 30 }, (_, i) => i + 1);
|
||||||
|
|
||||||
export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
||||||
visible,
|
visible,
|
||||||
@@ -47,6 +50,8 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
|||||||
const [repeatType, setRepeatType] = useState<RepeatType>('daily');
|
const [repeatType, setRepeatType] = useState<RepeatType>('daily');
|
||||||
const [frequency, setFrequency] = useState(1);
|
const [frequency, setFrequency] = useState(1);
|
||||||
const [hasReminder, setHasReminder] = useState(false);
|
const [hasReminder, setHasReminder] = useState(false);
|
||||||
|
const [showFrequencyPicker, setShowFrequencyPicker] = useState(false);
|
||||||
|
const [showRepeatTypePicker, setShowRepeatTypePicker] = useState(false);
|
||||||
const [reminderTime, setReminderTime] = useState('20:00');
|
const [reminderTime, setReminderTime] = useState('20:00');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [priority, setPriority] = useState<GoalPriority>(5);
|
const [priority, setPriority] = useState<GoalPriority>(5);
|
||||||
@@ -163,12 +168,21 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
|||||||
onRequestClose={handleClose}
|
onRequestClose={handleClose}
|
||||||
>
|
>
|
||||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||||
|
{/* 渐变背景 */}
|
||||||
|
<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.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity onPress={handleClose} disabled={loading}>
|
<TouchableOpacity onPress={handleClose} disabled={loading}>
|
||||||
<Text style={[styles.cancelButton, { color: colorTokens.text }]}>
|
<Ionicons name="close" size={24} color={colorTokens.text} />
|
||||||
←
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.title, { color: colorTokens.text }]}>
|
<Text style={[styles.title, { color: colorTokens.text }]}>
|
||||||
创建新目标
|
创建新目标
|
||||||
@@ -180,13 +194,13 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
|||||||
{/* 目标标题输入 */}
|
{/* 目标标题输入 */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.iconTitleContainer}>
|
<View style={styles.iconTitleContainer}>
|
||||||
<View style={styles.iconPlaceholder}>
|
{/* <View style={styles.iconPlaceholder}>
|
||||||
<Text style={styles.iconText}>图标</Text>
|
<Text style={styles.iconText}>图标</Text>
|
||||||
</View>
|
</View> */}
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.titleInput, { color: colorTokens.text }]}
|
style={[styles.titleInput, { color: colorTokens.text }]}
|
||||||
placeholder="写点什么..."
|
placeholder="写点什么..."
|
||||||
placeholderTextColor={colorTokens.textSecondary}
|
// placeholderTextColor={colorTokens.textSecondary}
|
||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
multiline
|
multiline
|
||||||
@@ -201,44 +215,132 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
|||||||
|
|
||||||
{/* 目标重复周期 */}
|
{/* 目标重复周期 */}
|
||||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||||
<View style={styles.optionHeader}>
|
<TouchableOpacity style={styles.optionValue} onPress={() => setShowRepeatTypePicker(true)}>
|
||||||
<View style={styles.optionIcon}>
|
<View style={styles.optionHeader}>
|
||||||
<Text style={styles.optionIconText}>🔄</Text>
|
<View style={styles.optionIcon}>
|
||||||
</View>
|
<Text style={styles.optionIconText}>🔄</Text>
|
||||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
</View>
|
||||||
目标重复周期
|
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||||
</Text>
|
目标重复周期
|
||||||
<TouchableOpacity style={styles.optionValue}>
|
</Text>
|
||||||
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
||||||
{REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label}
|
{REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
||||||
›
|
›
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 重复周期选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showRepeatTypePicker}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowRepeatTypePicker(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalBackdrop}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowRepeatTypePicker(false)}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<View style={{ height: 200 }}>
|
||||||
|
<WheelPickerExpo
|
||||||
|
height={200}
|
||||||
|
width={150}
|
||||||
|
initialSelectedIndex={REPEAT_TYPE_OPTIONS.findIndex(opt => opt.value === repeatType)}
|
||||||
|
items={REPEAT_TYPE_OPTIONS.map(opt => ({ label: opt.label, value: opt.value }))}
|
||||||
|
onChange={({ item }) => setRepeatType(item.value)}
|
||||||
|
backgroundColor={colorTokens.card}
|
||||||
|
haptics
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn]}
|
||||||
|
onPress={() => setShowRepeatTypePicker(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn, styles.modalBtnPrimary]}
|
||||||
|
onPress={() => setShowRepeatTypePicker(false)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 频率设置 */}
|
{/* 频率设置 */}
|
||||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||||
<View style={styles.optionHeader}>
|
<TouchableOpacity style={styles.optionValue} onPress={() => setShowFrequencyPicker(true)}>
|
||||||
<View style={styles.optionIcon}>
|
|
||||||
<Text style={styles.optionIconText}>📊</Text>
|
<View style={styles.optionHeader}>
|
||||||
</View>
|
<View style={styles.optionIcon}>
|
||||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
<Text style={styles.optionIconText}>📊</Text>
|
||||||
频率
|
</View>
|
||||||
</Text>
|
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||||
<TouchableOpacity style={styles.optionValue}>
|
频率
|
||||||
|
</Text>
|
||||||
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
||||||
{frequency}
|
{frequency}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
||||||
›
|
›
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 频率选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showFrequencyPicker}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowFrequencyPicker(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalBackdrop}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowFrequencyPicker(false)}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<View style={{ height: 200 }}>
|
||||||
|
<WheelPickerExpo
|
||||||
|
height={200}
|
||||||
|
width={150}
|
||||||
|
initialSelectedIndex={frequency - 1}
|
||||||
|
items={FREQUENCY_OPTIONS.map(num => ({ label: num.toString(), value: num }))}
|
||||||
|
onChange={({ item }) => setFrequency(item.value)}
|
||||||
|
backgroundColor={colorTokens.card}
|
||||||
|
// selectedStyle={{ borderColor: colorTokens.primary, borderWidth: 2 }}
|
||||||
|
haptics
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn]}
|
||||||
|
onPress={() => setShowFrequencyPicker(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn, styles.modalBtnPrimary]}
|
||||||
|
onPress={() => setShowFrequencyPicker(false)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 提醒设置 */}
|
{/* 提醒设置 */}
|
||||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||||
<View style={styles.optionHeader}>
|
<View style={styles.optionHeader}>
|
||||||
@@ -354,6 +456,34 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
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,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@@ -378,15 +508,13 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
marginBottom: 24,
|
|
||||||
},
|
},
|
||||||
iconTitleContainer: {
|
iconTitleContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: 20,
|
padding: 16,
|
||||||
marginBottom: 16,
|
|
||||||
},
|
},
|
||||||
iconPlaceholder: {
|
iconPlaceholder: {
|
||||||
width: 60,
|
width: 60,
|
||||||
@@ -492,6 +620,8 @@ const styles = StyleSheet.create({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
},
|
},
|
||||||
|
|||||||
222
components/DateSelector.tsx
Normal file
222
components/DateSelector.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export interface DateSelectorProps {
|
||||||
|
/** 当前选中的日期索引 */
|
||||||
|
selectedIndex?: number;
|
||||||
|
/** 日期选择回调 */
|
||||||
|
onDateSelect?: (index: number, date: Date) => void;
|
||||||
|
/** 是否显示月份标题 */
|
||||||
|
showMonthTitle?: boolean;
|
||||||
|
/** 自定义月份标题 */
|
||||||
|
monthTitle?: string;
|
||||||
|
/** 是否禁用未来日期 */
|
||||||
|
disableFutureDates?: boolean;
|
||||||
|
/** 自定义样式 */
|
||||||
|
style?: any;
|
||||||
|
/** 容器样式 */
|
||||||
|
containerStyle?: any;
|
||||||
|
/** 日期项样式 */
|
||||||
|
dayItemStyle?: any;
|
||||||
|
/** 是否自动滚动到选中项 */
|
||||||
|
autoScrollToSelected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||||
|
selectedIndex: externalSelectedIndex,
|
||||||
|
onDateSelect,
|
||||||
|
showMonthTitle = true,
|
||||||
|
monthTitle: externalMonthTitle,
|
||||||
|
disableFutureDates = true,
|
||||||
|
style,
|
||||||
|
containerStyle,
|
||||||
|
dayItemStyle,
|
||||||
|
autoScrollToSelected = true,
|
||||||
|
}) => {
|
||||||
|
// 内部状态管理
|
||||||
|
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
|
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
|
||||||
|
|
||||||
|
// 获取日期数据
|
||||||
|
const days = getMonthDaysZh();
|
||||||
|
const monthTitle = externalMonthTitle ?? 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 && autoScrollToSelected) {
|
||||||
|
scrollToIndex(selectedIndex, false);
|
||||||
|
}
|
||||||
|
}, [scrollWidth, selectedIndex, autoScrollToSelected]);
|
||||||
|
|
||||||
|
// 当选中索引变化时,滚动到对应位置
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollWidth > 0 && autoScrollToSelected) {
|
||||||
|
scrollToIndex(selectedIndex, true);
|
||||||
|
}
|
||||||
|
}, [selectedIndex, autoScrollToSelected]);
|
||||||
|
|
||||||
|
// 处理日期选择
|
||||||
|
const handleDateSelect = (index: number) => {
|
||||||
|
const targetDate = days[index]?.date?.toDate();
|
||||||
|
if (!targetDate) return;
|
||||||
|
|
||||||
|
// 检查是否为未来日期
|
||||||
|
if (disableFutureDates && days[index].date.isAfter(dayjs(), 'day')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新内部状态(如果使用外部控制则不更新)
|
||||||
|
if (externalSelectedIndex === undefined) {
|
||||||
|
setInternalSelectedIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用回调
|
||||||
|
onDateSelect?.(index, targetDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, containerStyle]}>
|
||||||
|
{showMonthTitle && (
|
||||||
|
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.daysContainer}
|
||||||
|
ref={daysScrollRef}
|
||||||
|
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{days.map((d, i) => {
|
||||||
|
const selected = i === selectedIndex;
|
||||||
|
const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={`${d.dayOfMonth}`} style={[styles.dayItemWrapper, dayItemStyle]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.dayPill,
|
||||||
|
selected ? styles.dayPillSelected : styles.dayPillNormal,
|
||||||
|
isFutureDate && styles.dayPillDisabled
|
||||||
|
]}
|
||||||
|
onPress={() => !isFutureDate && handleDateSelect(i)}
|
||||||
|
activeOpacity={isFutureDate ? 1 : 0.8}
|
||||||
|
disabled={isFutureDate}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.dayLabel,
|
||||||
|
selected && styles.dayLabelSelected,
|
||||||
|
isFutureDate && styles.dayLabelDisabled
|
||||||
|
]}>
|
||||||
|
{d.weekdayZh}
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.dayDate,
|
||||||
|
selected && styles.dayDateSelected,
|
||||||
|
isFutureDate && styles.dayDateDisabled
|
||||||
|
]}>
|
||||||
|
{d.dayOfMonth}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
monthTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
daysContainer: {
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
dayItemWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 48,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
dayPill: {
|
||||||
|
width: 40,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dayPillNormal: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
dayPillSelected: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
dayPillDisabled: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
dayLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'gray',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
dayLabelSelected: {
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
dayLabelDisabled: {
|
||||||
|
color: 'gray',
|
||||||
|
},
|
||||||
|
dayDate: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: 'gray',
|
||||||
|
},
|
||||||
|
dayDateSelected: {
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
dayDateDisabled: {
|
||||||
|
color: 'gray',
|
||||||
|
},
|
||||||
|
});
|
||||||
260
components/TaskCard.tsx
Normal file
260
components/TaskCard.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { TaskListItem } from '@/types/goals';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: TaskListItem;
|
||||||
|
onPress?: (task: TaskListItem) => void;
|
||||||
|
onComplete?: (task: TaskListItem) => void;
|
||||||
|
onSkip?: (task: TaskListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
|
task,
|
||||||
|
onPress,
|
||||||
|
onComplete,
|
||||||
|
onSkip,
|
||||||
|
}) => {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '#10B981';
|
||||||
|
case 'in_progress':
|
||||||
|
return '#F59E0B';
|
||||||
|
case 'overdue':
|
||||||
|
return '#EF4444';
|
||||||
|
case 'skipped':
|
||||||
|
return '#6B7280';
|
||||||
|
default:
|
||||||
|
return '#3B82F6';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '已完成';
|
||||||
|
case 'in_progress':
|
||||||
|
return '进行中';
|
||||||
|
case 'overdue':
|
||||||
|
return '已过期';
|
||||||
|
case 'skipped':
|
||||||
|
return '已跳过';
|
||||||
|
default:
|
||||||
|
return '待开始';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category?: string) => {
|
||||||
|
if (!category) return '#6B7280';
|
||||||
|
if (category.includes('运动') || category.includes('健身')) return '#EF4444';
|
||||||
|
if (category.includes('工作')) return '#3B82F6';
|
||||||
|
if (category.includes('健康')) return '#10B981';
|
||||||
|
if (category.includes('财务')) return '#F59E0B';
|
||||||
|
return '#6B7280';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return '今天';
|
||||||
|
} else if (date.toDateString() === tomorrow.toDateString()) {
|
||||||
|
return '明天';
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.container, { backgroundColor: colorTokens.background }]}
|
||||||
|
onPress={() => onPress?.(task)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleContainer}>
|
||||||
|
<Text style={[styles.title, { color: colorTokens.text }]} numberOfLines={2}>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
{task.goal?.category && (
|
||||||
|
<View style={[styles.categoryTag, { backgroundColor: getCategoryColor(task.goal.category) }]}>
|
||||||
|
<Text style={styles.categoryText}>{task.goal?.category}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statusTag, { backgroundColor: getStatusColor(task.status) }]}>
|
||||||
|
<Text style={styles.statusText}>{getStatusText(task.status)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{task.description && (
|
||||||
|
<Text style={[styles.description, { color: colorTokens.textSecondary }]} numberOfLines={2}>
|
||||||
|
{task.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={styles.progressInfo}>
|
||||||
|
<Text style={[styles.progressText, { color: colorTokens.textSecondary }]}>
|
||||||
|
进度: {task.currentCount}/{task.targetCount}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.progressText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{task.progressPercentage}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.progressBar, { backgroundColor: colorTokens.border }]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressFill,
|
||||||
|
{
|
||||||
|
width: `${task.progressPercentage}%`,
|
||||||
|
backgroundColor: getStatusColor(task.status),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={[styles.dateText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{formatDate(task.startDate)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{task.status === 'pending' || task.status === 'in_progress' ? (
|
||||||
|
<View style={styles.actionButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.skipButton]}
|
||||||
|
onPress={() => onSkip?.(task)}
|
||||||
|
>
|
||||||
|
<Text style={styles.skipButtonText}>跳过</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.completeButton]}
|
||||||
|
onPress={() => onComplete?.(task)}
|
||||||
|
>
|
||||||
|
<Text style={styles.completeButtonText}>完成</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
categoryTag: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
statusTag: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
progressInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
skipButton: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
skipButtonText: {
|
||||||
|
color: '#6B7280',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
completeButton: {
|
||||||
|
backgroundColor: '#10B981',
|
||||||
|
},
|
||||||
|
completeButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
140
components/TaskProgressCard.tsx
Normal file
140
components/TaskProgressCard.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { TaskListItem } from '@/types/goals';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
interface TaskProgressCardProps {
|
||||||
|
tasks: TaskListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskProgressCard: React.FC<TaskProgressCardProps> = ({
|
||||||
|
tasks,
|
||||||
|
}) => {
|
||||||
|
// 计算今日任务完成进度
|
||||||
|
const todayTasks = tasks.filter(task => task.isToday);
|
||||||
|
const completedTodayTasks = todayTasks.filter(task => task.status === 'completed');
|
||||||
|
const progressPercentage = todayTasks.length > 0
|
||||||
|
? Math.round((completedTodayTasks.length / todayTasks.length) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 计算进度角度
|
||||||
|
const progressAngle = (progressPercentage / 100) * 360;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 左侧内容 */}
|
||||||
|
<View style={styles.leftContent}>
|
||||||
|
<View style={styles.textContainer}>
|
||||||
|
<Text style={styles.title}>今日目标</Text>
|
||||||
|
<Text style={styles.subtitle}>加油,快完成啦!</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右侧进度圆环 */}
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
{/* 背景圆环 */}
|
||||||
|
<View style={[styles.progressCircle, styles.progressBackground]} />
|
||||||
|
|
||||||
|
{/* 进度圆环 */}
|
||||||
|
<View style={[styles.progressCircle, styles.progressFill]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressArc,
|
||||||
|
{
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
borderRadius: 34,
|
||||||
|
borderWidth: 6,
|
||||||
|
borderColor: '#8B5CF6',
|
||||||
|
borderTopColor: progressAngle > 0 ? '#8B5CF6' : 'transparent',
|
||||||
|
borderRightColor: progressAngle > 90 ? '#8B5CF6' : 'transparent',
|
||||||
|
borderBottomColor: progressAngle > 180 ? '#8B5CF6' : 'transparent',
|
||||||
|
borderLeftColor: progressAngle > 270 ? '#8B5CF6' : 'transparent',
|
||||||
|
transform: [{ rotate: '-90deg' }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 进度文字 */}
|
||||||
|
<View style={styles.progressTextContainer}>
|
||||||
|
<Text style={styles.progressText}>{progressPercentage}%</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: '#8B5CF6',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
shadowColor: '#8B5CF6',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
leftContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 20,
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '400',
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
progressCircle: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
},
|
||||||
|
progressBackground: {
|
||||||
|
borderWidth: 6,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
borderWidth: 6,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
progressArc: {
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
progressTextContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -111,9 +111,7 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
|||||||
height,
|
height,
|
||||||
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
|
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
|
||||||
width: eventWidth - 8, // 增加卡片间距
|
width: eventWidth - 8, // 增加卡片间距
|
||||||
backgroundColor: event.isCompleted
|
backgroundColor: '#FFFFFF', // 白色背景
|
||||||
? `${categoryColor}40`
|
|
||||||
: `${categoryColor}80`,
|
|
||||||
borderLeftColor: categoryColor,
|
borderLeftColor: categoryColor,
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
@@ -121,29 +119,57 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
|||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.eventContent}>
|
<View style={styles.eventContent}>
|
||||||
<Text
|
{/* 顶部行:标题和分类标签 */}
|
||||||
style={[
|
<View style={styles.eventHeader}>
|
||||||
styles.eventTitle,
|
<Text
|
||||||
{
|
style={[
|
||||||
color: event.isCompleted ? colorTokens.textMuted : colorTokens.text,
|
styles.eventTitle,
|
||||||
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
|
{
|
||||||
}
|
color: event.isCompleted ? colorTokens.textMuted : '#2C3E50',
|
||||||
]}
|
textDecorationLine: event.isCompleted ? 'line-through' : 'none',
|
||||||
numberOfLines={shouldShowTimeRange ? 1 : 2} // 当显示时间时,标题只显示1行
|
flex: 1,
|
||||||
>
|
}
|
||||||
{event.title}
|
]}
|
||||||
</Text>
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{shouldShowTimeRange && (
|
{event.title}
|
||||||
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
|
|
||||||
{dayjs(event.startTime).format('HH:mm')}
|
|
||||||
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={[styles.categoryTag, { backgroundColor: `${categoryColor}20` }]}>
|
||||||
|
<Text style={[styles.categoryText, { color: categoryColor }]}>
|
||||||
|
{event.category === 'workout' ? '运动' :
|
||||||
|
event.category === 'finance' ? '财务' :
|
||||||
|
event.category === 'personal' ? '个人' :
|
||||||
|
event.category === 'work' ? '工作' :
|
||||||
|
event.category === 'health' ? '健康' : '其他'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部行:时间和图标 */}
|
||||||
|
{shouldShowTimeRange && (
|
||||||
|
<View style={styles.eventFooter}>
|
||||||
|
<View style={styles.timeContainer}>
|
||||||
|
<Ionicons name="time-outline" size={14} color="#8E8E93" />
|
||||||
|
<Text style={styles.eventTime}>
|
||||||
|
{dayjs(event.startTime).format('HH:mm A')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
{event.isCompleted ? (
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color="#34C759" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="star" size={16} color="#FF9500" />
|
||||||
|
)}
|
||||||
|
<Ionicons name="attach" size={16} color="#8E8E93" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{event.isCompleted && (
|
{/* 完成状态指示 */}
|
||||||
|
{event.isCompleted && !shouldShowTimeRange && (
|
||||||
<View style={styles.completedIcon}>
|
<View style={styles.completedIcon}>
|
||||||
<Ionicons name="checkmark-circle" size={16} color={categoryColor} />
|
<Ionicons name="checkmark-circle" size={16} color="#34C759" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -301,32 +327,62 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
eventContainer: {
|
eventContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
borderRadius: 8,
|
borderRadius: 12,
|
||||||
borderLeftWidth: 4,
|
borderLeftWidth: 0,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 2,
|
height: 2,
|
||||||
},
|
},
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.08,
|
||||||
shadowRadius: 4,
|
shadowRadius: 6,
|
||||||
elevation: 3,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
eventContent: {
|
eventContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 8,
|
padding: 12,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
eventHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
eventTitle: {
|
eventTitle: {
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 18,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
categoryTag: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
lineHeight: 16,
|
},
|
||||||
|
eventFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
timeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
eventTime: {
|
eventTime: {
|
||||||
fontSize: 10,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
marginTop: 2,
|
marginLeft: 6,
|
||||||
|
color: '#8E8E93',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
completedIcon: {
|
completedIcon: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
82
components/statistic/HealthDataCard.tsx
Normal file
82
components/statistic/HealthDataCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
interface HealthDataCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
unit: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
style?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
icon,
|
||||||
|
style
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(300)}
|
||||||
|
exiting={FadeOut.duration(300)}
|
||||||
|
style={[styles.card, style]}
|
||||||
|
>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<View style={styles.valueContainer}>
|
||||||
|
<Text style={styles.value}>{value}</Text>
|
||||||
|
<Text style={styles.unit}>{unit}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
marginVertical: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
valueContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
marginLeft: 4,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HealthDataCard;
|
||||||
48
components/statistic/HeartRateCard.tsx
Normal file
48
components/statistic/HeartRateCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import HealthDataService from '../../services/healthData';
|
||||||
|
import HealthDataCard from './HealthDataCard';
|
||||||
|
|
||||||
|
interface HeartRateCardProps {
|
||||||
|
resetToken: number;
|
||||||
|
style?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeartRateCard: React.FC<HeartRateCardProps> = ({
|
||||||
|
resetToken,
|
||||||
|
style
|
||||||
|
}) => {
|
||||||
|
const [heartRate, setHeartRate] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHeartRate = async () => {
|
||||||
|
const data = await HealthDataService.getHeartRate();
|
||||||
|
setHeartRate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchHeartRate();
|
||||||
|
}, [resetToken]);
|
||||||
|
|
||||||
|
const heartIcon = (
|
||||||
|
<Ionicons name="heart" size={24} color="#EF4444" />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HealthDataCard
|
||||||
|
title="心率"
|
||||||
|
value={heartRate !== null ? heartRate.toString() : '--'}
|
||||||
|
unit="bpm"
|
||||||
|
icon={heartIcon}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HeartRateCard;
|
||||||
48
components/statistic/OxygenSaturationCard.tsx
Normal file
48
components/statistic/OxygenSaturationCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import HealthDataService from '../../services/healthData';
|
||||||
|
import HealthDataCard from './HealthDataCard';
|
||||||
|
|
||||||
|
interface OxygenSaturationCardProps {
|
||||||
|
resetToken: number;
|
||||||
|
style?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||||
|
resetToken,
|
||||||
|
style
|
||||||
|
}) => {
|
||||||
|
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOxygenSaturation = async () => {
|
||||||
|
const data = await HealthDataService.getOxygenSaturation();
|
||||||
|
setOxygenSaturation(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOxygenSaturation();
|
||||||
|
}, [resetToken]);
|
||||||
|
|
||||||
|
const oxygenIcon = (
|
||||||
|
<Ionicons name="water" size={24} color="#3B82F6" />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HealthDataCard
|
||||||
|
title="血氧饱和度"
|
||||||
|
value={oxygenSaturation !== null ? oxygenSaturation.toString() : '--'}
|
||||||
|
unit="%"
|
||||||
|
icon={oxygenIcon}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default OxygenSaturationCard;
|
||||||
273
docs/tasks-implementation-summary.md
Normal file
273
docs/tasks-implementation-summary.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# 任务功能实现总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
已成功将goals页面从显示目标列表改为显示任务列表,实现了完整的任务管理功能。任务系统基于目标自动生成,支持分步完成和进度追踪。同时保留了原有的目标管理功能,通过新的页面结构提供更好的用户体验。最新更新了任务统计区域和任务卡片设计,使用现代化的UI设计,完全按照设计稿实现高保真界面。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
### 1. 任务页面 (`app/(tabs)/goals.tsx`)
|
||||||
|
- **主要功能**: 显示任务列表,支持完成任务和跳过任务
|
||||||
|
- **入口**: 底部导航栏的"目标"标签
|
||||||
|
- **特色功能**:
|
||||||
|
- 任务进度卡片(高保真设计)
|
||||||
|
- 现代化任务卡片列表
|
||||||
|
- 下拉刷新和上拉加载
|
||||||
|
- 右上角"目标"按钮导航到目标管理页面
|
||||||
|
|
||||||
|
### 2. 目标管理页面 (`app/goals-detail.tsx`)
|
||||||
|
- **主要功能**: 显示原有的目标管理功能,包括滑动模块、日程表格等
|
||||||
|
- **入口**: 任务页面右上角的"目标"按钮
|
||||||
|
- **特色功能**:
|
||||||
|
- 今日待办事项卡片
|
||||||
|
- 时间筛选选项卡(日/周/月)
|
||||||
|
- 日期选择器
|
||||||
|
- 时间轴安排
|
||||||
|
- 创建目标功能
|
||||||
|
|
||||||
|
## 已完成的功能
|
||||||
|
|
||||||
|
### 1. 数据结构定义 (`types/goals.ts`)
|
||||||
|
- ✅ 任务状态类型 (TaskStatus)
|
||||||
|
- ✅ 任务数据结构 (Task, TaskListItem)
|
||||||
|
- ✅ 任务查询参数 (GetTasksQuery)
|
||||||
|
- ✅ 完成任务请求数据 (CompleteTaskRequest)
|
||||||
|
- ✅ 跳过任务请求数据 (SkipTaskRequest)
|
||||||
|
- ✅ 任务统计信息 (TaskStats)
|
||||||
|
|
||||||
|
### 2. API服务层 (`services/tasksApi.ts`)
|
||||||
|
- ✅ 获取任务列表 (GET /goals/tasks)
|
||||||
|
- ✅ 获取特定目标的任务列表 (GET /goals/:goalId/tasks)
|
||||||
|
- ✅ 完成任务 (POST /goals/tasks/:taskId/complete)
|
||||||
|
- ✅ 跳过任务 (POST /goals/tasks/:taskId/skip)
|
||||||
|
- ✅ 获取任务统计 (GET /goals/tasks/stats/overview)
|
||||||
|
|
||||||
|
### 3. Redux状态管理 (`store/tasksSlice.ts`)
|
||||||
|
- ✅ 完整的异步操作 (createAsyncThunk)
|
||||||
|
- ✅ 任务列表状态管理
|
||||||
|
- ✅ 任务统计状态管理
|
||||||
|
- ✅ 完成任务和跳过任务操作
|
||||||
|
- ✅ 乐观更新支持
|
||||||
|
- ✅ 错误处理和加载状态
|
||||||
|
- ✅ 分页数据管理
|
||||||
|
|
||||||
|
### 4. 任务卡片组件 (`components/TaskCard.tsx`)
|
||||||
|
- ✅ 现代化任务卡片UI设计,完全按照设计稿实现
|
||||||
|
- ✅ 右上角分类图标(粉色、红色、绿色、黄色、紫色等)
|
||||||
|
- ✅ 项目/分类标签(小写灰色文字)
|
||||||
|
- ✅ 任务标题(粗体黑色文字)
|
||||||
|
- ✅ 时间显示(紫色时钟图标 + 时间 + 日期)
|
||||||
|
- ✅ 状态按钮(Done紫色、In Progress橙色、To-do蓝色等)
|
||||||
|
- ✅ 进度条显示(仅对多步骤任务显示)
|
||||||
|
- ✅ 阴影效果和圆角设计
|
||||||
|
- ✅ 主题适配(明暗模式)
|
||||||
|
|
||||||
|
### 5. 任务进度卡片组件 (`components/TaskProgressCard.tsx`)
|
||||||
|
- ✅ 高保真设计,完全按照设计稿实现
|
||||||
|
- ✅ 紫色主题配色方案
|
||||||
|
- ✅ 圆形进度条显示今日任务完成进度
|
||||||
|
- ✅ 左侧文字区域:"今日目标" + "加油,快完成啦!"
|
||||||
|
- ✅ 阴影效果和圆角设计
|
||||||
|
- ✅ 响应式进度计算
|
||||||
|
|
||||||
|
### 6. 页面集成
|
||||||
|
- ✅ 任务页面 (`app/(tabs)/goals.tsx`)
|
||||||
|
- Redux状态集成
|
||||||
|
- 任务进度卡片展示
|
||||||
|
- 现代化任务卡片列表展示
|
||||||
|
- 下拉刷新功能
|
||||||
|
- 上拉加载更多
|
||||||
|
- 完成任务功能
|
||||||
|
- 跳过任务功能
|
||||||
|
- 创建目标功能(保留原有功能)
|
||||||
|
- 错误提示和加载状态
|
||||||
|
- 空状态处理
|
||||||
|
- 目标管理页面导航
|
||||||
|
|
||||||
|
- ✅ 目标管理页面 (`app/goals-detail.tsx`)
|
||||||
|
- 保留原有的所有目标管理功能
|
||||||
|
- 今日待办事项卡片
|
||||||
|
- 时间筛选选项卡
|
||||||
|
- 日期选择器
|
||||||
|
- 时间轴安排
|
||||||
|
- 创建目标功能
|
||||||
|
- 返回导航功能
|
||||||
|
|
||||||
|
## 核心功能特性
|
||||||
|
|
||||||
|
### 任务状态管理
|
||||||
|
- **pending**: 待开始 (To-do - 蓝色)
|
||||||
|
- **in_progress**: 进行中 (In Progress - 橙色)
|
||||||
|
- **completed**: 已完成 (Done - 紫色)
|
||||||
|
- **overdue**: 已过期 (Overdue - 红色)
|
||||||
|
- **skipped**: 已跳过 (Skipped - 灰色)
|
||||||
|
|
||||||
|
### 进度追踪
|
||||||
|
- 支持分步完成(如一天要喝8杯水,可以分8次上报)
|
||||||
|
- 自动计算完成进度百分比
|
||||||
|
- 当完成次数达到目标次数时自动标记为完成
|
||||||
|
- 实时更新任务进度卡片
|
||||||
|
|
||||||
|
### 数据流程
|
||||||
|
1. **获取任务**: 页面加载 → 调用API → 更新Redux状态 → 渲染列表
|
||||||
|
2. **完成任务**: 用户点击完成 → 调用API → 乐观更新 → 更新进度
|
||||||
|
3. **跳过任务**: 用户点击跳过 → 调用API → 更新任务状态
|
||||||
|
4. **创建目标**: 用户创建目标 → 系统自动生成任务 → 刷新任务列表
|
||||||
|
5. **页面导航**: 任务页面 ↔ 目标管理页面
|
||||||
|
6. **进度更新**: 任务状态变化 → 自动更新进度卡片
|
||||||
|
|
||||||
|
## API接口对应
|
||||||
|
|
||||||
|
| 功能 | API接口 | 实现状态 |
|
||||||
|
|------|---------|----------|
|
||||||
|
| 获取任务列表 | GET /goals/tasks | ✅ |
|
||||||
|
| 获取特定目标任务 | GET /goals/:goalId/tasks | ✅ |
|
||||||
|
| 完成任务 | POST /goals/tasks/:taskId/complete | ✅ |
|
||||||
|
| 跳过任务 | POST /goals/tasks/:taskId/skip | ✅ |
|
||||||
|
| 获取任务统计 | GET /goals/tasks/stats/overview | ✅ |
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
### 查看任务
|
||||||
|
1. 进入goals页面,自动显示任务进度卡片和任务列表
|
||||||
|
2. 查看今日任务完成进度(圆形进度条显示)
|
||||||
|
3. 下拉刷新获取最新任务
|
||||||
|
4. 上拉加载更多历史任务
|
||||||
|
|
||||||
|
### 完成任务
|
||||||
|
1. 在任务卡片中点击"完成"按钮
|
||||||
|
2. 系统自动记录完成次数
|
||||||
|
3. 更新任务进度和状态
|
||||||
|
4. 进度卡片实时更新完成百分比
|
||||||
|
|
||||||
|
### 跳过任务
|
||||||
|
1. 在任务卡片中点击"跳过"按钮
|
||||||
|
2. 系统记录跳过原因
|
||||||
|
3. 更新任务状态为已跳过
|
||||||
|
|
||||||
|
### 创建目标
|
||||||
|
1. 点击页面右上角的"+"按钮
|
||||||
|
2. 填写目标信息
|
||||||
|
3. 系统自动生成相应的任务
|
||||||
|
|
||||||
|
### 访问目标管理
|
||||||
|
1. 点击页面右上角的"目标"按钮
|
||||||
|
2. 进入目标管理页面
|
||||||
|
3. 查看原有的滑动模块、日程表格等功能
|
||||||
|
|
||||||
|
### 任务进度卡片交互
|
||||||
|
1. 进度条实时显示今日任务完成情况
|
||||||
|
2. 简洁的设计,专注于进度展示
|
||||||
|
|
||||||
|
## 技术特点
|
||||||
|
|
||||||
|
1. **类型安全**: 完整的TypeScript类型定义
|
||||||
|
2. **状态管理**: Redux Toolkit + RTK Query模式
|
||||||
|
3. **乐观更新**: 提升用户体验
|
||||||
|
4. **错误处理**: 完整的错误提示和恢复机制
|
||||||
|
5. **响应式设计**: 适配不同屏幕尺寸
|
||||||
|
6. **主题适配**: 支持明暗模式切换
|
||||||
|
7. **性能优化**: 分页加载,避免一次性加载过多数据
|
||||||
|
8. **页面导航**: 使用expo-router实现页面间导航
|
||||||
|
9. **高保真UI**: 完全按照设计稿实现,包括颜色、字体、阴影等细节
|
||||||
|
10. **现代化设计**: 采用最新的UI设计趋势和最佳实践
|
||||||
|
|
||||||
|
## 页面布局
|
||||||
|
|
||||||
|
### 任务页面布局
|
||||||
|
#### 顶部区域
|
||||||
|
- 页面标题:"任务"
|
||||||
|
- 目标管理按钮(带图标)
|
||||||
|
- 创建目标按钮(+)
|
||||||
|
|
||||||
|
#### 任务进度卡片区域
|
||||||
|
- 紫色背景卡片
|
||||||
|
- 左侧文字:"今日目标" + "加油,快完成啦!"
|
||||||
|
- 右侧圆形进度条(显示完成百分比)
|
||||||
|
|
||||||
|
#### 任务列表区域
|
||||||
|
- 现代化任务卡片列表
|
||||||
|
- 下拉刷新
|
||||||
|
- 上拉加载更多
|
||||||
|
- 空状态提示
|
||||||
|
|
||||||
|
### 目标管理页面布局
|
||||||
|
#### 顶部区域
|
||||||
|
- 返回按钮
|
||||||
|
- 页面标题:"目标管理"
|
||||||
|
- 创建目标按钮(+)
|
||||||
|
|
||||||
|
#### 内容区域
|
||||||
|
- 今日待办事项卡片
|
||||||
|
- 时间筛选选项卡
|
||||||
|
- 日期选择器(周/月模式)
|
||||||
|
- 时间轴安排
|
||||||
|
|
||||||
|
## 设计亮点
|
||||||
|
|
||||||
|
### 任务卡片设计
|
||||||
|
- **现代化布局**: 右上角图标 + 主要内容区域 + 右下角状态按钮
|
||||||
|
- **分类图标**: 根据任务分类显示不同颜色的图标(粉色、红色、绿色、黄色、紫色)
|
||||||
|
- **时间显示**: 紫色时钟图标 + 时间 + 日期,信息层次清晰
|
||||||
|
- **状态按钮**: 不同状态使用不同颜色(Done紫色、In Progress橙色、To-do蓝色等)
|
||||||
|
- **进度条**: 仅对多步骤任务显示,避免界面冗余
|
||||||
|
- **阴影效果**: 轻微的阴影增强立体感
|
||||||
|
- **圆角设计**: 统一的圆角半径,保持设计一致性
|
||||||
|
|
||||||
|
### 任务进度卡片设计
|
||||||
|
- **配色方案**: 使用紫色主题(#8B5CF6),符合现代设计趋势
|
||||||
|
- **圆形进度条**: 使用border和transform实现,性能优秀
|
||||||
|
- **文字层次**: 主标题和副标题的字体大小和权重区分
|
||||||
|
- **阴影效果**: 添加适当的阴影,增强立体感
|
||||||
|
- **圆角设计**: 统一的圆角半径,保持设计一致性
|
||||||
|
|
||||||
|
### 交互体验
|
||||||
|
- **实时更新**: 任务状态变化时进度卡片立即更新
|
||||||
|
- **视觉反馈**: 按钮点击有透明度变化
|
||||||
|
- **流畅动画**: 进度条变化平滑自然
|
||||||
|
- **信息层次**: 清晰的信息架构,重要信息突出显示
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. **任务筛选**: 添加按状态、日期范围筛选功能
|
||||||
|
2. **任务搜索**: 支持按标题搜索任务
|
||||||
|
3. **任务详情**: 添加任务详情页面
|
||||||
|
4. **批量操作**: 支持批量完成任务
|
||||||
|
5. **推送通知**: 集成推送服务,实现任务提醒
|
||||||
|
6. **数据同步**: 实现多设备数据同步
|
||||||
|
7. **统计分析**: 添加更详细的任务完成统计和分析
|
||||||
|
8. **离线支持**: 支持离线完成任务,网络恢复后同步
|
||||||
|
9. **页面动画**: 添加页面切换动画效果
|
||||||
|
10. **手势操作**: 支持滑动完成任务等手势操作
|
||||||
|
11. **进度动画**: 为进度条添加平滑的动画效果
|
||||||
|
12. **主题切换**: 支持多种颜色主题选择
|
||||||
|
13. **卡片动画**: 为任务卡片添加进入和退出动画
|
||||||
|
14. **拖拽排序**: 支持拖拽重新排序任务
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
1. **单元测试**: 测试Redux reducers和API服务
|
||||||
|
2. **集成测试**: 测试完整的数据流程
|
||||||
|
3. **UI测试**: 测试组件交互和状态变化
|
||||||
|
4. **端到端测试**: 测试完整的用户流程
|
||||||
|
5. **性能测试**: 测试大量任务时的性能表现
|
||||||
|
6. **导航测试**: 测试页面间导航功能
|
||||||
|
7. **进度计算测试**: 测试进度条计算的准确性
|
||||||
|
8. **响应式测试**: 测试不同屏幕尺寸下的显示效果
|
||||||
|
9. **主题测试**: 测试明暗模式切换效果
|
||||||
|
10. **可访问性测试**: 测试色盲用户的可访问性
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **API兼容性**: 确保后端API接口与前端调用一致
|
||||||
|
2. **错误处理**: 网络异常时的用户提示和恢复机制
|
||||||
|
3. **数据一致性**: 乐观更新失败时的回滚机制
|
||||||
|
4. **用户体验**: 加载状态和空状态的友好提示
|
||||||
|
5. **性能考虑**: 大量任务时的分页和虚拟化处理
|
||||||
|
6. **导航体验**: 确保页面间导航流畅自然
|
||||||
|
7. **状态保持**: 页面切换时保持用户操作状态
|
||||||
|
8. **设计一致性**: 确保所有UI组件遵循统一的设计规范
|
||||||
|
9. **进度准确性**: 确保进度计算逻辑正确,避免显示错误
|
||||||
|
10. **可访问性**: 考虑色盲用户的可访问性需求
|
||||||
|
11. **国际化**: 考虑多语言支持的需求
|
||||||
|
12. **性能优化**: 大量任务时的渲染性能优化
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -55,6 +55,7 @@
|
|||||||
"react-native-toast-message": "^2.3.3",
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
|
"react-native-wheel-picker-expo": "^0.5.4",
|
||||||
"react-redux": "^9.2.0"
|
"react-redux": "^9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -11700,6 +11701,18 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-wheel-picker-expo": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/react-native-wheel-picker-expo/-/react-native-wheel-picker-expo-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-mTA35pqAGioi7gie+nLF4EcwCj7zU36zzlYZVRS/bZul84zvvoMFKJu6Sm84WuWQiUJ40J1EgYxQ3Ui1pIMJZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo-haptics": ">=11",
|
||||||
|
"expo-linear-gradient": ">=11",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
||||||
"version": "0.79.5",
|
"version": "0.79.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"react-native-toast-message": "^2.3.3",
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
|
"react-native-wheel-picker-expo": "^0.5.4",
|
||||||
"react-redux": "^9.2.0"
|
"react-redux": "^9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
114
services/healthData.ts
Normal file
114
services/healthData.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import {
|
||||||
|
HKQuantityTypeIdentifier,
|
||||||
|
HKQuantitySample,
|
||||||
|
getMostRecentQuantitySample,
|
||||||
|
isAvailable,
|
||||||
|
authorize,
|
||||||
|
} from 'react-native-health';
|
||||||
|
|
||||||
|
interface HealthData {
|
||||||
|
oxygenSaturation: number | null;
|
||||||
|
heartRate: number | null;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HealthDataService {
|
||||||
|
private static instance: HealthDataService;
|
||||||
|
private isAuthorized = false;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): HealthDataService {
|
||||||
|
if (!HealthDataService.instance) {
|
||||||
|
HealthDataService.instance = new HealthDataService();
|
||||||
|
}
|
||||||
|
return HealthDataService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestAuthorization(): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'ios') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const available = await isAvailable();
|
||||||
|
if (!available) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = [
|
||||||
|
{
|
||||||
|
type: HKQuantityTypeIdentifier.OxygenSaturation,
|
||||||
|
access: 'read' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: HKQuantityTypeIdentifier.HeartRate,
|
||||||
|
access: 'read' as const
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const authorized = await authorize(permissions);
|
||||||
|
this.isAuthorized = authorized;
|
||||||
|
return authorized;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Health data authorization error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOxygenSaturation(): Promise<number | null> {
|
||||||
|
if (!this.isAuthorized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sample: HKQuantitySample | null = await getMostRecentQuantitySample(
|
||||||
|
HKQuantityTypeIdentifier.OxygenSaturation
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sample) {
|
||||||
|
return Number(sample.value.toFixed(1));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading oxygen saturation:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHeartRate(): Promise<number | null> {
|
||||||
|
if (!this.isAuthorized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sample: HKQuantitySample | null = await getMostRecentQuantitySample(
|
||||||
|
HKQuantityTypeIdentifier.HeartRate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sample) {
|
||||||
|
return Math.round(sample.value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading heart rate:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHealthData(): Promise<HealthData> {
|
||||||
|
const [oxygenSaturation, heartRate] = await Promise.all([
|
||||||
|
this.getOxygenSaturation(),
|
||||||
|
this.getHeartRate()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
oxygenSaturation,
|
||||||
|
heartRate,
|
||||||
|
lastUpdated: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HealthDataService.getInstance();
|
||||||
83
services/tasksApi.ts
Normal file
83
services/tasksApi.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
CompleteTaskRequest,
|
||||||
|
GetTasksQuery,
|
||||||
|
PaginatedResponse,
|
||||||
|
SkipTaskRequest,
|
||||||
|
Task,
|
||||||
|
TaskListItem,
|
||||||
|
TaskStats,
|
||||||
|
} from '@/types/goals';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
// 任务管理API服务
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务列表
|
||||||
|
*/
|
||||||
|
export const getTasks = async (query: GetTasksQuery = {}): Promise<PaginatedResponse<TaskListItem>> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const path = queryString ? `/goals/tasks?${queryString}` : '/goals/tasks';
|
||||||
|
|
||||||
|
return api.get<PaginatedResponse<TaskListItem>>(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取特定目标的任务列表
|
||||||
|
*/
|
||||||
|
export const getTasksByGoalId = async (goalId: string, query: GetTasksQuery = {}): Promise<PaginatedResponse<TaskListItem>> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const path = queryString ? `/goals/${goalId}/tasks?${queryString}` : `/goals/${goalId}/tasks`;
|
||||||
|
|
||||||
|
return api.get<PaginatedResponse<TaskListItem>>(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成任务
|
||||||
|
*/
|
||||||
|
export const completeTask = async (taskId: string, completionData: CompleteTaskRequest = {}): Promise<Task> => {
|
||||||
|
return api.post<Task>(`/goals/tasks/${taskId}/complete`, completionData);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳过任务
|
||||||
|
*/
|
||||||
|
export const skipTask = async (taskId: string, skipData: SkipTaskRequest = {}): Promise<Task> => {
|
||||||
|
return api.post<Task>(`/goals/tasks/${taskId}/skip`, skipData);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务统计
|
||||||
|
*/
|
||||||
|
export const getTaskStats = async (goalId?: string): Promise<TaskStats> => {
|
||||||
|
const path = goalId ? `/goals/tasks/stats/overview?goalId=${goalId}` : '/goals/tasks/stats/overview';
|
||||||
|
const response = await api.get<ApiResponse<TaskStats>>(path);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出所有API方法
|
||||||
|
export const tasksApi = {
|
||||||
|
getTasks,
|
||||||
|
getTasksByGoalId,
|
||||||
|
completeTask,
|
||||||
|
skipTask,
|
||||||
|
getTaskStats,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tasksApi;
|
||||||
@@ -5,6 +5,7 @@ import exerciseLibraryReducer from './exerciseLibrarySlice';
|
|||||||
import goalsReducer from './goalsSlice';
|
import goalsReducer from './goalsSlice';
|
||||||
import moodReducer from './moodSlice';
|
import moodReducer from './moodSlice';
|
||||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||||
|
import tasksReducer from './tasksSlice';
|
||||||
import trainingPlanReducer from './trainingPlanSlice';
|
import trainingPlanReducer from './trainingPlanSlice';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
import workoutReducer from './workoutSlice';
|
import workoutReducer from './workoutSlice';
|
||||||
@@ -44,6 +45,7 @@ export const store = configureStore({
|
|||||||
checkin: checkinReducer,
|
checkin: checkinReducer,
|
||||||
goals: goalsReducer,
|
goals: goalsReducer,
|
||||||
mood: moodReducer,
|
mood: moodReducer,
|
||||||
|
tasks: tasksReducer,
|
||||||
trainingPlan: trainingPlanReducer,
|
trainingPlan: trainingPlanReducer,
|
||||||
scheduleExercise: scheduleExerciseReducer,
|
scheduleExercise: scheduleExerciseReducer,
|
||||||
exerciseLibrary: exerciseLibraryReducer,
|
exerciseLibrary: exerciseLibraryReducer,
|
||||||
|
|||||||
318
store/tasksSlice.ts
Normal file
318
store/tasksSlice.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { tasksApi } from '@/services/tasksApi';
|
||||||
|
import {
|
||||||
|
CompleteTaskRequest,
|
||||||
|
GetTasksQuery,
|
||||||
|
SkipTaskRequest,
|
||||||
|
TaskListItem,
|
||||||
|
TaskStats,
|
||||||
|
} from '@/types/goals';
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
// 任务管理状态类型
|
||||||
|
export interface TasksState {
|
||||||
|
// 任务列表
|
||||||
|
tasks: TaskListItem[];
|
||||||
|
tasksLoading: boolean;
|
||||||
|
tasksError: string | null;
|
||||||
|
tasksPagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 任务统计
|
||||||
|
stats: TaskStats | null;
|
||||||
|
statsLoading: boolean;
|
||||||
|
statsError: string | null;
|
||||||
|
|
||||||
|
// 完成任务
|
||||||
|
completeLoading: boolean;
|
||||||
|
completeError: string | null;
|
||||||
|
|
||||||
|
// 跳过任务
|
||||||
|
skipLoading: boolean;
|
||||||
|
skipError: string | null;
|
||||||
|
|
||||||
|
// 筛选和搜索
|
||||||
|
filters: GetTasksQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TasksState = {
|
||||||
|
tasks: [],
|
||||||
|
tasksLoading: false,
|
||||||
|
tasksError: null,
|
||||||
|
tasksPagination: {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
stats: null,
|
||||||
|
statsLoading: false,
|
||||||
|
statsError: null,
|
||||||
|
|
||||||
|
completeLoading: false,
|
||||||
|
completeError: null,
|
||||||
|
|
||||||
|
skipLoading: false,
|
||||||
|
skipError: null,
|
||||||
|
|
||||||
|
filters: {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 异步操作
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务列表
|
||||||
|
*/
|
||||||
|
export const fetchTasks = createAsyncThunk(
|
||||||
|
'tasks/fetchTasks',
|
||||||
|
async (query: GetTasksQuery = {}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await tasksApi.getTasks(query);
|
||||||
|
console.log('fetchTasks response', response);
|
||||||
|
return { query, response };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '获取任务列表失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载更多任务
|
||||||
|
*/
|
||||||
|
export const loadMoreTasks = createAsyncThunk(
|
||||||
|
'tasks/loadMoreTasks',
|
||||||
|
async (_, { getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const state = getState() as { tasks: TasksState };
|
||||||
|
const { filters, tasksPagination } = state.tasks;
|
||||||
|
|
||||||
|
if (!tasksPagination.hasMore) {
|
||||||
|
return { tasks: [], pagination: tasksPagination };
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
...filters,
|
||||||
|
page: tasksPagination.page + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await tasksApi.getTasks(query);
|
||||||
|
console.log('loadMoreTasks response', response);
|
||||||
|
|
||||||
|
return { query, response };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '加载更多任务失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成任务
|
||||||
|
*/
|
||||||
|
export const completeTask = createAsyncThunk(
|
||||||
|
'tasks/completeTask',
|
||||||
|
async ({ taskId, completionData }: { taskId: string; completionData?: CompleteTaskRequest }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await tasksApi.completeTask(taskId, completionData);
|
||||||
|
console.log('completeTask response', response);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '完成任务失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳过任务
|
||||||
|
*/
|
||||||
|
export const skipTask = createAsyncThunk(
|
||||||
|
'tasks/skipTask',
|
||||||
|
async ({ taskId, skipData }: { taskId: string; skipData?: SkipTaskRequest }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await tasksApi.skipTask(taskId, skipData);
|
||||||
|
console.log('skipTask response', response);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '跳过任务失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务统计
|
||||||
|
*/
|
||||||
|
export const fetchTaskStats = createAsyncThunk(
|
||||||
|
'tasks/fetchTaskStats',
|
||||||
|
async (goalId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await tasksApi.getTaskStats(goalId);
|
||||||
|
console.log('fetchTaskStats response', response);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '获取任务统计失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redux Slice
|
||||||
|
const tasksSlice = createSlice({
|
||||||
|
name: 'tasks',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
// 清除错误
|
||||||
|
clearErrors: (state) => {
|
||||||
|
state.tasksError = null;
|
||||||
|
state.completeError = null;
|
||||||
|
state.skipError = null;
|
||||||
|
state.statsError = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新筛选条件
|
||||||
|
updateFilters: (state, action: PayloadAction<Partial<GetTasksQuery>>) => {
|
||||||
|
state.filters = { ...state.filters, ...action.payload };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置筛选条件
|
||||||
|
resetFilters: (state) => {
|
||||||
|
state.filters = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 乐观更新任务完成状态
|
||||||
|
optimisticCompleteTask: (state, action: PayloadAction<{ taskId: string; count?: number }>) => {
|
||||||
|
const { taskId, count = 1 } = action.payload;
|
||||||
|
const task = state.tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
const newCount = Math.min(task.currentCount + count, task.targetCount);
|
||||||
|
task.currentCount = newCount;
|
||||||
|
task.progressPercentage = Math.round((newCount / task.targetCount) * 100);
|
||||||
|
|
||||||
|
if (newCount >= task.targetCount) {
|
||||||
|
task.status = 'completed';
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
} else if (newCount > 0) {
|
||||||
|
task.status = 'in_progress';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// 获取任务列表
|
||||||
|
.addCase(fetchTasks.pending, (state) => {
|
||||||
|
state.tasksLoading = true;
|
||||||
|
state.tasksError = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.fulfilled, (state, action) => {
|
||||||
|
state.tasksLoading = false;
|
||||||
|
const { query, response } = action.payload;
|
||||||
|
|
||||||
|
// 如果是第一页,替换数据;否则追加数据
|
||||||
|
state.tasks = response.list;
|
||||||
|
state.tasksPagination = {
|
||||||
|
page: response.page,
|
||||||
|
pageSize: response.pageSize,
|
||||||
|
total: response.total,
|
||||||
|
hasMore: response.page * response.pageSize < response.total,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.rejected, (state, action) => {
|
||||||
|
state.tasksLoading = false;
|
||||||
|
state.tasksError = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载更多任务
|
||||||
|
.addCase(loadMoreTasks.pending, (state) => {
|
||||||
|
state.tasksLoading = true;
|
||||||
|
})
|
||||||
|
.addCase(loadMoreTasks.fulfilled, (state, action) => {
|
||||||
|
state.tasksLoading = false;
|
||||||
|
const { response } = action.payload;
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tasks = [...state.tasks, ...response.list];
|
||||||
|
state.tasksPagination = {
|
||||||
|
page: response.page,
|
||||||
|
pageSize: response.pageSize,
|
||||||
|
total: response.total,
|
||||||
|
hasMore: response.page * response.pageSize < response.total,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.addCase(loadMoreTasks.rejected, (state, action) => {
|
||||||
|
state.tasksLoading = false;
|
||||||
|
state.tasksError = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 完成任务
|
||||||
|
.addCase(completeTask.pending, (state) => {
|
||||||
|
state.completeLoading = true;
|
||||||
|
state.completeError = null;
|
||||||
|
})
|
||||||
|
.addCase(completeTask.fulfilled, (state, action) => {
|
||||||
|
state.completeLoading = false;
|
||||||
|
// 更新任务列表中的对应任务
|
||||||
|
const updatedTask = action.payload;
|
||||||
|
const index = state.tasks.findIndex(t => t.id === updatedTask.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.tasks[index] = updatedTask;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(completeTask.rejected, (state, action) => {
|
||||||
|
state.completeLoading = false;
|
||||||
|
state.completeError = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 跳过任务
|
||||||
|
.addCase(skipTask.pending, (state) => {
|
||||||
|
state.skipLoading = true;
|
||||||
|
state.skipError = null;
|
||||||
|
})
|
||||||
|
.addCase(skipTask.fulfilled, (state, action) => {
|
||||||
|
state.skipLoading = false;
|
||||||
|
// 更新任务列表中的对应任务
|
||||||
|
const updatedTask = action.payload;
|
||||||
|
const index = state.tasks.findIndex(t => t.id === updatedTask.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.tasks[index] = updatedTask;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(skipTask.rejected, (state, action) => {
|
||||||
|
state.skipLoading = false;
|
||||||
|
state.skipError = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取任务统计
|
||||||
|
.addCase(fetchTaskStats.pending, (state) => {
|
||||||
|
state.statsLoading = true;
|
||||||
|
state.statsError = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchTaskStats.fulfilled, (state, action) => {
|
||||||
|
state.statsLoading = false;
|
||||||
|
state.stats = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchTaskStats.rejected, (state, action) => {
|
||||||
|
state.statsLoading = false;
|
||||||
|
state.statsError = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
clearErrors,
|
||||||
|
updateFilters,
|
||||||
|
resetFilters,
|
||||||
|
optimisticCompleteTask,
|
||||||
|
} = tasksSlice.actions;
|
||||||
|
|
||||||
|
export default tasksSlice.reducer;
|
||||||
@@ -178,3 +178,75 @@ export interface GoalListItem extends Goal {
|
|||||||
progressPercentage: number;
|
progressPercentage: number;
|
||||||
daysRemaining?: number;
|
daysRemaining?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务相关类型定义
|
||||||
|
|
||||||
|
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'overdue' | 'skipped';
|
||||||
|
|
||||||
|
// 任务数据结构
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
goalId: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
startDate: string; // ISO date string
|
||||||
|
endDate: string; // ISO date string
|
||||||
|
targetCount: number;
|
||||||
|
currentCount: number;
|
||||||
|
status: TaskStatus;
|
||||||
|
progressPercentage: number;
|
||||||
|
completedAt?: string; // ISO datetime string
|
||||||
|
notes?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
daysRemaining: number;
|
||||||
|
isToday: boolean;
|
||||||
|
goal?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
repeatType: RepeatType;
|
||||||
|
frequency: number;
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取任务列表的查询参数
|
||||||
|
export interface GetTasksQuery {
|
||||||
|
goalId?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成任务的请求数据
|
||||||
|
export interface CompleteTaskRequest {
|
||||||
|
count?: number;
|
||||||
|
notes?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过任务的请求数据
|
||||||
|
export interface SkipTaskRequest {
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务统计信息
|
||||||
|
export interface TaskStats {
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
inProgress: number;
|
||||||
|
completed: number;
|
||||||
|
overdue: number;
|
||||||
|
skipped: number;
|
||||||
|
totalProgress: number;
|
||||||
|
todayTasks: number;
|
||||||
|
weekTasks: number;
|
||||||
|
monthTasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务列表项响应
|
||||||
|
export interface TaskListItem extends Task {
|
||||||
|
// 继承Task的所有属性
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user