feat: 新增任务管理功能及相关组件
- 将目标页面改为任务列表,支持任务的创建、完成和跳过功能 - 新增任务卡片和任务进度卡片组件,展示任务状态和进度 - 实现任务数据的获取和管理,集成Redux状态管理 - 更新API服务,支持任务相关的CRUD操作 - 编写任务管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
@@ -1827,12 +1827,17 @@ export default function CoachScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={[theme.backgroundGradientStart, theme.backgroundGradientEnd]}
|
||||
colors={['#F0F9FF', '#E0F2FE']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
|
||||
<View
|
||||
style={[styles.header, { paddingTop: insets.top + 10 }]}
|
||||
@@ -2124,6 +2129,34 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 16,
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -2673,13 +2706,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#FF4444',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
|
||||
// 饮食方案卡片样式
|
||||
dietPlanContainer: {
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
|
||||
@@ -1,121 +1,111 @@
|
||||
import CreateGoalModal from '@/components/CreateGoalModal';
|
||||
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
|
||||
import { TimelineSchedule } from '@/components/TimelineSchedule';
|
||||
import { TodoItem } from '@/components/TodoCard';
|
||||
import { TodoCarousel } from '@/components/TodoCarousel';
|
||||
import { TaskCard } from '@/components/TaskCard';
|
||||
import { TaskProgressCard } from '@/components/TaskProgressCard';
|
||||
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 { clearErrors, createGoal } from '@/store/goalsSlice';
|
||||
import { clearErrors as clearTaskErrors, completeTask, fetchTasks, loadMoreTasks, skipTask } from '@/store/tasksSlice';
|
||||
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
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',
|
||||
};
|
||||
};
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
|
||||
// Redux状态
|
||||
const {
|
||||
goals,
|
||||
goalsLoading,
|
||||
goalsError,
|
||||
tasks,
|
||||
tasksLoading,
|
||||
tasksError,
|
||||
tasksPagination,
|
||||
completeLoading,
|
||||
completeError,
|
||||
skipLoading,
|
||||
skipError,
|
||||
} = useAppSelector((state) => state.tasks);
|
||||
|
||||
const {
|
||||
createLoading,
|
||||
createError
|
||||
} = useAppSelector((state) => state.goals);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 页面聚焦时重新加载数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('useFocusEffect');
|
||||
// 只在需要时刷新数据,比如从后台返回或从其他页面返回
|
||||
dispatch(fetchGoals({
|
||||
status: 'active',
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}));
|
||||
console.log('useFocusEffect - loading tasks');
|
||||
loadTasks();
|
||||
}, [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(() => {
|
||||
console.log('goalsError', goalsError);
|
||||
console.log('tasksError', tasksError);
|
||||
console.log('createError', createError);
|
||||
if (goalsError) {
|
||||
Alert.alert('错误', goalsError);
|
||||
dispatch(clearErrors());
|
||||
console.log('completeError', completeError);
|
||||
console.log('skipError', skipError);
|
||||
|
||||
if (tasksError) {
|
||||
Alert.alert('错误', tasksError);
|
||||
dispatch(clearTaskErrors());
|
||||
}
|
||||
if (createError) {
|
||||
Alert.alert('创建失败', createError);
|
||||
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) => {
|
||||
@@ -123,156 +113,85 @@ export default function GoalsScreen() {
|
||||
await dispatch(createGoal(goalData)).unwrap();
|
||||
setShowCreateModal(false);
|
||||
Alert.alert('成功', '目标创建成功!');
|
||||
// 创建目标后重新加载任务列表
|
||||
loadTasks();
|
||||
} 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());
|
||||
}
|
||||
// 任务点击处理
|
||||
const handleTaskPress = (task: TaskListItem) => {
|
||||
console.log('Task pressed:', task.title);
|
||||
// 这里可以导航到任务详情页面
|
||||
};
|
||||
|
||||
// 日期选择器相关状态 (参考 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) => {
|
||||
// 完成任务处理
|
||||
const handleCompleteTask = async (task: TaskListItem) => {
|
||||
try {
|
||||
await dispatch(completeGoal({
|
||||
goalId: item.id,
|
||||
await dispatch(completeTask({
|
||||
taskId: task.id,
|
||||
completionData: {
|
||||
completionCount: 1,
|
||||
notes: '通过待办卡片完成'
|
||||
count: 1,
|
||||
notes: '通过任务卡片完成'
|
||||
}
|
||||
})).unwrap();
|
||||
} 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 (
|
||||
@@ -284,103 +203,61 @@ export default function GoalsScreen() {
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
colorTokens.backgroundGradientStart,
|
||||
colorTokens.backgroundGradientEnd,
|
||||
]}
|
||||
style={styles.backgroundGradient}
|
||||
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}>
|
||||
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||||
今日
|
||||
任务
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.goalsButton}
|
||||
onPress={handleNavigateToGoals}
|
||||
>
|
||||
<MaterialIcons name="flag" size={16} color="#0EA5E9" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 今日待办事项卡片 */}
|
||||
<TodoCarousel
|
||||
todos={todayTodos}
|
||||
onTodoPress={handleTodoPress}
|
||||
onToggleComplete={handleToggleComplete}
|
||||
/>
|
||||
{/* 时间筛选选项卡 */}
|
||||
<TimeTabSelector
|
||||
selectedTab={selectedTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
{/* 任务进度卡片 */}
|
||||
<TaskProgressCard tasks={tasks} />
|
||||
|
||||
{/* 日期选择器 - 在周和月模式下显示 */}
|
||||
{(selectedTab === 'week' || selectedTab === 'month') && (
|
||||
<View style={styles.dateSelector}>
|
||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>
|
||||
{monthTitle}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.daysContainer}
|
||||
ref={daysScrollRef}
|
||||
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{days.map((d, i) => {
|
||||
const selected = i === selectedIndex;
|
||||
const isFutureDate = d.date.isAfter(dayjs(), 'day');
|
||||
|
||||
// 根据选择的tab模式决定是否显示该日期
|
||||
let shouldShow = true;
|
||||
if (selectedTab === 'week') {
|
||||
// 周模式:只显示选中日期所在周的日期
|
||||
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 style={styles.taskListContainer}>
|
||||
<FlatList
|
||||
data={tasks}
|
||||
renderItem={renderTaskItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.taskList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#0EA5E9']}
|
||||
tintColor="#0EA5E9"
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMoreTasks}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListFooterComponent={renderLoadMore}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -400,12 +277,33 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backgroundGradient: {
|
||||
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,
|
||||
@@ -418,16 +316,35 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 20,
|
||||
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: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
addButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#6366F1',
|
||||
backgroundColor: '#0EA5E9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
@@ -438,80 +355,44 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 24,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
lineHeight: 24,
|
||||
lineHeight: 22,
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
timelineSection: {
|
||||
|
||||
taskListContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// 日期选择器样式 (参考 statistics.tsx)
|
||||
dateSelector: {
|
||||
taskList: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
marginBottom: 14,
|
||||
},
|
||||
daysContainer: {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
dayItemWrapper: {
|
||||
alignItems: 'center',
|
||||
width: 48,
|
||||
marginRight: 8,
|
||||
},
|
||||
dayPill: {
|
||||
width: 40,
|
||||
height: 60,
|
||||
borderRadius: 24,
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
dayPillNormal: {
|
||||
backgroundColor: 'transparent',
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
dayPillSelected: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
emptyStateSubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
dayPillDisabled: {
|
||||
backgroundColor: 'transparent',
|
||||
opacity: 0.4,
|
||||
loadMoreContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: 'gray',
|
||||
marginBottom: 2,
|
||||
},
|
||||
dayLabelSelected: {
|
||||
color: '#192126',
|
||||
},
|
||||
dayLabelDisabled: {
|
||||
},
|
||||
dayDate: {
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
color: 'gray',
|
||||
},
|
||||
dayDateSelected: {
|
||||
color: '#192126',
|
||||
},
|
||||
dayDateDisabled: {
|
||||
loadMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
||||
import HeartRateCard from '@/components/statistic/HeartRateCard';
|
||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
@@ -13,7 +16,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||||
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 { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
@@ -27,8 +30,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
@@ -92,7 +94,6 @@ export default function ExploreScreen() {
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中"今天"
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -100,43 +101,6 @@ export default function ExploreScreen() {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [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: 每次页面聚焦都拉取今日数据
|
||||
const [stepCount, setStepCount] = useState<number | null>(null);
|
||||
const [activeCalories, setActiveCalories] = useState<number | null>(null);
|
||||
@@ -170,16 +134,22 @@ export default function ExploreScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
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 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) => {
|
||||
if (!isLoggedIn) return;
|
||||
@@ -192,7 +162,7 @@ export default function ExploreScreen() {
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
derivedDate = getCurrentSelectedDate();
|
||||
}
|
||||
|
||||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||
@@ -223,7 +193,7 @@ export default function ExploreScreen() {
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
derivedDate = getCurrentSelectedDate();
|
||||
}
|
||||
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
@@ -278,7 +248,7 @@ export default function ExploreScreen() {
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
derivedDate = getCurrentSelectedDate();
|
||||
}
|
||||
|
||||
console.log('加载营养数据...', derivedDate);
|
||||
@@ -306,7 +276,7 @@ export default function ExploreScreen() {
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||||
const currentDate = days[selectedIndex]?.date?.toDate();
|
||||
const currentDate = getCurrentSelectedDate();
|
||||
if (currentDate) {
|
||||
loadHealthData(currentDate);
|
||||
if (isLoggedIn) {
|
||||
@@ -318,15 +288,12 @@ export default function ExploreScreen() {
|
||||
);
|
||||
|
||||
// 日期点击时,加载对应日期数据
|
||||
const onSelectDate = (index: number) => {
|
||||
const onSelectDate = (index: number, date: Date) => {
|
||||
setSelectedIndex(index);
|
||||
const target = days[index]?.date?.toDate();
|
||||
if (target) {
|
||||
loadHealthData(target);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(target);
|
||||
loadMoodData(target);
|
||||
}
|
||||
loadHealthData(date);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(date);
|
||||
loadMoodData(date);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,12 +302,18 @@ export default function ExploreScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={backgroundGradientColors}
|
||||
colors={['#F0F9FF', '#E0F2FE']}
|
||||
style={styles.gradientBackground}
|
||||
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}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
@@ -351,46 +324,13 @@ export default function ExploreScreen() {
|
||||
<Text style={styles.sectionTitle}>健康数据</Text>
|
||||
<WeightHistoryCard />
|
||||
|
||||
{/* 标题与日期选择 */}
|
||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.daysContainer}
|
||||
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>
|
||||
{/* 日期选择器 */}
|
||||
<DateSelector
|
||||
selectedIndex={selectedIndex}
|
||||
onDateSelect={onSelectDate}
|
||||
showMonthTitle={true}
|
||||
disableFutureDates={true}
|
||||
/>
|
||||
|
||||
{/* 营养摄入雷达图卡片 */}
|
||||
<NutritionRadarCard
|
||||
@@ -491,6 +431,22 @@ export default function ExploreScreen() {
|
||||
/>
|
||||
</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>
|
||||
</ScrollView>
|
||||
@@ -514,6 +470,26 @@ const styles = StyleSheet.create({
|
||||
top: 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: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -521,70 +497,8 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
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: {
|
||||
fontSize: 24,
|
||||
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.leftSection}>
|
||||
|
||||
<Text style={styles.title}>基础代谢</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import WheelPickerExpo from 'react-native-wheel-picker-expo';
|
||||
|
||||
interface CreateGoalModalProps {
|
||||
visible: boolean;
|
||||
@@ -30,7 +33,7 @@ const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [
|
||||
{ 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> = ({
|
||||
visible,
|
||||
@@ -47,6 +50,8 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
||||
const [repeatType, setRepeatType] = useState<RepeatType>('daily');
|
||||
const [frequency, setFrequency] = useState(1);
|
||||
const [hasReminder, setHasReminder] = useState(false);
|
||||
const [showFrequencyPicker, setShowFrequencyPicker] = useState(false);
|
||||
const [showRepeatTypePicker, setShowRepeatTypePicker] = useState(false);
|
||||
const [reminderTime, setReminderTime] = useState('20:00');
|
||||
const [category, setCategory] = useState('');
|
||||
const [priority, setPriority] = useState<GoalPriority>(5);
|
||||
@@ -163,12 +168,21 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<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}>
|
||||
<TouchableOpacity onPress={handleClose} disabled={loading}>
|
||||
<Text style={[styles.cancelButton, { color: colorTokens.text }]}>
|
||||
←
|
||||
</Text>
|
||||
<Ionicons name="close" size={24} color={colorTokens.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>
|
||||
创建新目标
|
||||
@@ -180,13 +194,13 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
||||
{/* 目标标题输入 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.iconTitleContainer}>
|
||||
<View style={styles.iconPlaceholder}>
|
||||
{/* <View style={styles.iconPlaceholder}>
|
||||
<Text style={styles.iconText}>图标</Text>
|
||||
</View>
|
||||
</View> */}
|
||||
<TextInput
|
||||
style={[styles.titleInput, { color: colorTokens.text }]}
|
||||
placeholder="写点什么..."
|
||||
placeholderTextColor={colorTokens.textSecondary}
|
||||
// placeholderTextColor={colorTokens.textSecondary}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
multiline
|
||||
@@ -201,44 +215,132 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
||||
|
||||
{/* 目标重复周期 */}
|
||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>🔄</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
目标重复周期
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.optionValue}>
|
||||
<TouchableOpacity style={styles.optionValue} onPress={() => setShowRepeatTypePicker(true)}>
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>🔄</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
目标重复周期
|
||||
</Text>
|
||||
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
||||
{REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label}
|
||||
</Text>
|
||||
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
||||
›
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</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.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>📊</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
频率
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.optionValue}>
|
||||
<TouchableOpacity style={styles.optionValue} onPress={() => setShowFrequencyPicker(true)}>
|
||||
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>📊</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
频率
|
||||
</Text>
|
||||
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
||||
{frequency}
|
||||
</Text>
|
||||
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
||||
›
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</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.optionHeader}>
|
||||
@@ -354,6 +456,34 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
||||
};
|
||||
|
||||
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: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -378,15 +508,13 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
iconTitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
},
|
||||
iconPlaceholder: {
|
||||
width: 60,
|
||||
@@ -492,6 +620,8 @@ const styles = StyleSheet.create({
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderTopLeftRadius: 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,
|
||||
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
|
||||
width: eventWidth - 8, // 增加卡片间距
|
||||
backgroundColor: event.isCompleted
|
||||
? `${categoryColor}40`
|
||||
: `${categoryColor}80`,
|
||||
backgroundColor: '#FFFFFF', // 白色背景
|
||||
borderLeftColor: categoryColor,
|
||||
}
|
||||
]}
|
||||
@@ -121,29 +119,57 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.eventContent}>
|
||||
<Text
|
||||
style={[
|
||||
styles.eventTitle,
|
||||
{
|
||||
color: event.isCompleted ? colorTokens.textMuted : colorTokens.text,
|
||||
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
|
||||
}
|
||||
]}
|
||||
numberOfLines={shouldShowTimeRange ? 1 : 2} // 当显示时间时,标题只显示1行
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
|
||||
{shouldShowTimeRange && (
|
||||
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
|
||||
{dayjs(event.startTime).format('HH:mm')}
|
||||
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
|
||||
{/* 顶部行:标题和分类标签 */}
|
||||
<View style={styles.eventHeader}>
|
||||
<Text
|
||||
style={[
|
||||
styles.eventTitle,
|
||||
{
|
||||
color: event.isCompleted ? colorTokens.textMuted : '#2C3E50',
|
||||
textDecorationLine: event.isCompleted ? 'line-through' : 'none',
|
||||
flex: 1,
|
||||
}
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{event.title}
|
||||
</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}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={categoryColor} />
|
||||
<Ionicons name="checkmark-circle" size={16} color="#34C759" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -301,32 +327,62 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
eventContainer: {
|
||||
position: 'absolute',
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 4,
|
||||
borderRadius: 12,
|
||||
borderLeftWidth: 0,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 6,
|
||||
elevation: 4,
|
||||
},
|
||||
eventContent: {
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
padding: 12,
|
||||
justifyContent: 'space-between',
|
||||
|
||||
},
|
||||
eventHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
lineHeight: 18,
|
||||
flex: 1,
|
||||
},
|
||||
categoryTag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
lineHeight: 16,
|
||||
},
|
||||
eventFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
timeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
eventTime: {
|
||||
fontSize: 10,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginTop: 2,
|
||||
marginLeft: 6,
|
||||
color: '#8E8E93',
|
||||
},
|
||||
iconContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
completedIcon: {
|
||||
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-web": "~0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-native-wheel-picker-expo": "^0.5.4",
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -11700,6 +11701,18 @@
|
||||
"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": {
|
||||
"version": "0.79.5",
|
||||
"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-web": "~0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-native-wheel-picker-expo": "^0.5.4",
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"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 moodReducer from './moodSlice';
|
||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||
import tasksReducer from './tasksSlice';
|
||||
import trainingPlanReducer from './trainingPlanSlice';
|
||||
import userReducer from './userSlice';
|
||||
import workoutReducer from './workoutSlice';
|
||||
@@ -44,6 +45,7 @@ export const store = configureStore({
|
||||
checkin: checkinReducer,
|
||||
goals: goalsReducer,
|
||||
mood: moodReducer,
|
||||
tasks: tasksReducer,
|
||||
trainingPlan: trainingPlanReducer,
|
||||
scheduleExercise: scheduleExerciseReducer,
|
||||
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;
|
||||
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