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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user