feat: 新增任务管理功能及相关组件

- 将目标页面改为任务列表,支持任务的创建、完成和跳过功能
- 新增任务卡片和任务进度卡片组件,展示任务状态和进度
- 实现任务数据的获取和管理,集成Redux状态管理
- 更新API服务,支持任务相关的CRUD操作
- 编写任务管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
richarjiang
2025-08-22 17:30:14 +08:00
parent 231620d778
commit 259f10540e
21 changed files with 2756 additions and 608 deletions

View File

@@ -1827,12 +1827,17 @@ export default function CoachScreen() {
return ( return (
<View style={styles.screen}> <View style={styles.screen}>
{/* 背景渐变 */}
<LinearGradient <LinearGradient
colors={[theme.backgroundGradientStart, theme.backgroundGradientEnd]} colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground} style={styles.gradientBackground}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }} end={{ x: 1, y: 1 }}
/> />
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */} {/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
<View <View
style={[styles.header, { paddingTop: insets.top + 10 }]} style={[styles.header, { paddingTop: insets.top + 10 }]}
@@ -2124,6 +2129,34 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 10, paddingBottom: 10,
}, },
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
headerLeft: { headerLeft: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -2673,13 +2706,7 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: '#FF4444', color: '#FF4444',
}, },
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
// 饮食方案卡片样式 // 饮食方案卡片样式
dietPlanContainer: { dietPlanContainer: {
backgroundColor: 'rgba(255,255,255,0.95)', backgroundColor: 'rgba(255,255,255,0.95)',

View File

@@ -1,121 +1,111 @@
import CreateGoalModal from '@/components/CreateGoalModal'; import CreateGoalModal from '@/components/CreateGoalModal';
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector'; import { TaskCard } from '@/components/TaskCard';
import { TimelineSchedule } from '@/components/TimelineSchedule'; import { TaskProgressCard } from '@/components/TaskProgressCard';
import { TodoItem } from '@/components/TodoCard';
import { TodoCarousel } from '@/components/TodoCarousel';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { clearErrors, completeGoal, createGoal, fetchGoals } from '@/store/goalsSlice'; import { clearErrors, createGoal } from '@/store/goalsSlice';
import { CreateGoalRequest, GoalListItem } from '@/types/goals'; import { clearErrors as clearTaskErrors, completeTask, fetchTasks, loadMoreTasks, skipTask } from '@/store/tasksSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { CreateGoalRequest, TaskListItem } from '@/types/goals';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'expo-router';
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import React, { useCallback, useEffect, useState } from 'react';
import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
dayjs.extend(isBetween);
// 将目标转换为TodoItem的辅助函数
const goalToTodoItem = (goal: GoalListItem): TodoItem => {
return {
id: goal.id,
title: goal.title,
description: goal.description || `${goal.frequency}次/${getRepeatTypeLabel(goal.repeatType)}`,
time: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString() || '',
category: getCategoryFromGoal(goal.category),
priority: getPriorityFromGoal(goal.priority),
isCompleted: goal.status === 'completed',
};
};
// 获取重复类型标签
const getRepeatTypeLabel = (repeatType: string): string => {
switch (repeatType) {
case 'daily': return '每日';
case 'weekly': return '每周';
case 'monthly': return '每月';
default: return '自定义';
}
};
// 从目标分类获取TodoItem分类
const getCategoryFromGoal = (category?: string): TodoItem['category'] => {
if (!category) return 'personal';
if (category.includes('运动') || category.includes('健身')) return 'workout';
if (category.includes('工作')) return 'work';
if (category.includes('健康')) return 'health';
if (category.includes('财务')) return 'finance';
return 'personal';
};
// 从目标优先级获取TodoItem优先级
const getPriorityFromGoal = (priority: number): TodoItem['priority'] => {
if (priority >= 8) return 'high';
if (priority >= 5) return 'medium';
return 'low';
};
// 将目标转换为时间轴事件的辅助函数
const goalToTimelineEvent = (goal: GoalListItem) => {
return {
id: goal.id,
title: goal.title,
startTime: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString(),
endTime: goal.endTime ? dayjs().startOf('day').add(goal.endTime, 'minute').toISOString() : undefined,
category: getCategoryFromGoal(goal.category),
isCompleted: goal.status === 'completed',
};
};
export default function GoalsScreen() { export default function GoalsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const router = useRouter();
// Redux状态 // Redux状态
const { const {
goals, tasks,
goalsLoading, tasksLoading,
goalsError, tasksError,
tasksPagination,
completeLoading,
completeError,
skipLoading,
skipError,
} = useAppSelector((state) => state.tasks);
const {
createLoading, createLoading,
createError createError
} = useAppSelector((state) => state.goals); } = useAppSelector((state) => state.goals);
const [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// 页面聚焦时重新加载数据 // 页面聚焦时重新加载数据
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
console.log('useFocusEffect'); console.log('useFocusEffect - loading tasks');
// 只在需要时刷新数据,比如从后台返回或从其他页面返回 loadTasks();
dispatch(fetchGoals({
status: 'active',
page: 1,
pageSize: 200,
}));
}, [dispatch]) }, [dispatch])
); );
// 加载任务列表
const loadTasks = async () => {
try {
await dispatch(fetchTasks({
startDate: dayjs().startOf('day').toISOString(),
endDate: dayjs().endOf('day').toISOString(),
})).unwrap();
} catch (error) {
console.error('Failed to load tasks:', error);
}
};
// 下拉刷新
const onRefresh = async () => {
setRefreshing(true);
try {
await loadTasks();
} finally {
setRefreshing(false);
}
};
// 加载更多任务
const handleLoadMoreTasks = async () => {
if (tasksPagination.hasMore && !tasksLoading) {
try {
await dispatch(loadMoreTasks()).unwrap();
} catch (error) {
console.error('Failed to load more tasks:', error);
}
}
};
// 处理错误提示 // 处理错误提示
useEffect(() => { useEffect(() => {
console.log('goalsError', goalsError); console.log('tasksError', tasksError);
console.log('createError', createError); console.log('createError', createError);
if (goalsError) { console.log('completeError', completeError);
Alert.alert('错误', goalsError); console.log('skipError', skipError);
dispatch(clearErrors());
if (tasksError) {
Alert.alert('错误', tasksError);
dispatch(clearTaskErrors());
} }
if (createError) { if (createError) {
Alert.alert('创建失败', createError); Alert.alert('创建失败', createError);
dispatch(clearErrors()); dispatch(clearErrors());
} }
}, [goalsError, createError, dispatch]); if (completeError) {
Alert.alert('完成失败', completeError);
dispatch(clearTaskErrors());
}
if (skipError) {
Alert.alert('跳过失败', skipError);
dispatch(clearTaskErrors());
}
}, [tasksError, createError, completeError, skipError, dispatch]);
// 创建目标处理函数 // 创建目标处理函数
const handleCreateGoal = async (goalData: CreateGoalRequest) => { const handleCreateGoal = async (goalData: CreateGoalRequest) => {
@@ -123,156 +113,85 @@ export default function GoalsScreen() {
await dispatch(createGoal(goalData)).unwrap(); await dispatch(createGoal(goalData)).unwrap();
setShowCreateModal(false); setShowCreateModal(false);
Alert.alert('成功', '目标创建成功!'); Alert.alert('成功', '目标创建成功!');
// 创建目标后重新加载任务列表
loadTasks();
} catch (error) { } catch (error) {
// 错误已在useEffect中处理 // 错误已在useEffect中处理
} }
}; };
// tab切换处理函数 // 任务点击处理
const handleTabChange = (tab: TimeTabType) => { const handleTaskPress = (task: TaskListItem) => {
setSelectedTab(tab); console.log('Task pressed:', task.title);
// 这里可以导航到任务详情页面
// 当切换到周或月模式时,如果当前选择的日期不是今天,则重置为今天
const today = new Date();
const currentDate = selectedDate;
if (tab === 'week' || tab === 'month') {
// 如果当前选择的日期不是今天,重置为今天
if (!dayjs(currentDate).isSame(dayjs(today), 'day')) {
setSelectedDate(today);
setSelectedIndex(getTodayIndexInMonth());
}
} else if (tab === 'day') {
// 天模式下也重置为今天
setSelectedDate(today);
setSelectedIndex(getTodayIndexInMonth());
}
}; };
// 日期选择器相关状态 (参考 statistics.tsx) // 完成任务处理
const days = getMonthDaysZh(); const handleCompleteTask = async (task: TaskListItem) => {
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 日期条自动滚动到选中项
const daysScrollRef = useRef<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 48;
const DAY_PILL_SPACING = 8;
const scrollToIndex = (index: number, animated = true) => {
if (!daysScrollRef.current || scrollWidth === 0) return;
const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING;
const baseOffset = index * itemWidth;
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
// 确保不会滚动超出边界
const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth);
const finalOffset = Math.min(centerOffset, maxScrollOffset);
daysScrollRef.current.scrollTo({ x: finalOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// 当选中索引变化时,滚动到对应位置
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// 日期选择处理
const onSelectDate = (index: number) => {
setSelectedIndex(index);
const targetDate = days[index]?.date?.toDate();
if (targetDate) {
setSelectedDate(targetDate);
// 在周模式下,如果用户选择了新日期,更新周的显示范围
if (selectedTab === 'week') {
// 自动滚动到新选择的日期
setTimeout(() => {
scrollToIndex(index, true);
}, 100);
}
}
};
// 将目标转换为TodoItem数据
const todayTodos = useMemo(() => {
const today = dayjs();
const activeGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' ||
(goal.repeatType === 'weekly' && today.day() !== 0) ||
(goal.repeatType === 'monthly' && today.date() <= 28))
);
return activeGoals.map(goalToTodoItem);
}, [goals]);
// 将目标转换为时间轴事件数据
const filteredTimelineEvents = useMemo(() => {
const selected = dayjs(selectedDate);
let filteredGoals: GoalListItem[] = [];
switch (selectedTab) {
case 'day':
filteredGoals = goals.filter(goal => {
if (goal.status !== 'active') return false;
if (goal.repeatType === 'daily') return true;
if (goal.repeatType === 'weekly') return selected.day() !== 0;
if (goal.repeatType === 'monthly') return selected.date() <= 28;
return false;
});
break;
case 'week':
filteredGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' || goal.repeatType === 'weekly')
);
break;
case 'month':
filteredGoals = goals.filter(goal => goal.status === 'active');
break;
default:
filteredGoals = goals.filter(goal => goal.status === 'active');
}
return filteredGoals.map(goalToTimelineEvent);
}, [selectedTab, selectedDate, goals]);
console.log('filteredTimelineEvents', filteredTimelineEvents);
const handleTodoPress = (item: TodoItem) => {
console.log('Goal pressed:', item.title);
// 这里可以导航到目标详情页面
};
const handleToggleComplete = async (item: TodoItem) => {
try { try {
await dispatch(completeGoal({ await dispatch(completeTask({
goalId: item.id, taskId: task.id,
completionData: { completionData: {
completionCount: 1, count: 1,
notes: '通过待办卡片完成' notes: '通过任务卡片完成'
} }
})).unwrap(); })).unwrap();
} catch (error) { } catch (error) {
Alert.alert('错误', '记录完成失败'); Alert.alert('错误', '完成任务失败');
} }
}; };
const handleEventPress = (event: any) => { // 跳过任务处理
console.log('Event pressed:', event.title); const handleSkipTask = async (task: TaskListItem) => {
// 这里可以处理时间轴事件点击 try {
await dispatch(skipTask({
taskId: task.id,
skipData: {
reason: '用户主动跳过'
}
})).unwrap();
} catch (error) {
Alert.alert('错误', '跳过任务失败');
}
};
// 导航到目标管理页面
const handleNavigateToGoals = () => {
router.push('/goals-detail');
};
// 渲染任务项
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
<TaskCard
task={item}
onPress={handleTaskPress}
onComplete={handleCompleteTask}
onSkip={handleSkipTask}
/>
);
// 渲染空状态
const renderEmptyState = () => (
<View style={styles.emptyState}>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
</Text>
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
</Text>
</View>
);
// 渲染加载更多
const renderLoadMore = () => {
if (!tasksPagination.hasMore) return null;
return (
<View style={styles.loadMoreContainer}>
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
{tasksLoading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
);
}; };
return ( return (
@@ -284,19 +203,29 @@ export default function GoalsScreen() {
{/* 背景渐变 */} {/* 背景渐变 */}
<LinearGradient <LinearGradient
colors={[ colors={['#F0F9FF', '#E0F2FE']}
colorTokens.backgroundGradientStart, style={styles.gradientBackground}
colorTokens.backgroundGradientEnd, start={{ x: 0, y: 0 }}
]} end={{ x: 1, y: 1 }}
style={styles.backgroundGradient}
/> />
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<View style={styles.content}> <View style={styles.content}>
{/* 标题区域 */} {/* 标题区域 */}
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.pageTitle, { color: colorTokens.text }]}> <Text style={[styles.pageTitle, { color: colorTokens.text }]}>
</Text> </Text>
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.goalsButton}
onPress={handleNavigateToGoals}
>
<MaterialIcons name="flag" size={16} color="#0EA5E9" />
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.addButton} style={styles.addButton}
onPress={() => setShowCreateModal(true)} onPress={() => setShowCreateModal(true)}
@@ -304,83 +233,31 @@ export default function GoalsScreen() {
<Text style={styles.addButtonText}>+</Text> <Text style={styles.addButtonText}>+</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
{/* 今日待办事项卡片 */} {/* 任务进度卡片 */}
<TodoCarousel <TaskProgressCard tasks={tasks} />
todos={todayTodos}
onTodoPress={handleTodoPress} {/* 任务列表 */}
onToggleComplete={handleToggleComplete} <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"
/> />
{/* 时间筛选选项卡 */}
<TimeTabSelector
selectedTab={selectedTab}
onTabChange={handleTabChange}
/>
{/* 日期选择器 - 在周和月模式下显示 */}
{(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', '[]');
} }
onEndReached={handleLoadMoreTasks}
if (!shouldShow) return null; onEndReachedThreshold={0.1}
ListEmptyComponent={renderEmptyState}
return ( ListFooterComponent={renderLoadMore}
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[
styles.dayPill,
selected ? styles.dayPillSelected : styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}
onPress={() => !isFutureDate && onSelectDate(i)}
activeOpacity={isFutureDate ? 1 : 0.8}
disabled={isFutureDate}
>
<Text style={[
styles.dayLabel,
selected && styles.dayLabelSelected,
isFutureDate && styles.dayLabelDisabled
]}> {d.weekdayZh} </Text>
<Text style={[
styles.dayDate,
selected && styles.dayDateSelected,
isFutureDate && styles.dayDateDisabled
]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
</View>
);
})}
</ScrollView>
</View>
)}
{/* 时间轴安排 */}
<View style={styles.timelineSection}>
<TimelineSchedule
events={filteredTimelineEvents}
selectedDate={selectedDate}
onEventPress={handleEventPress}
/> />
</View> </View>
@@ -400,12 +277,33 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
backgroundGradient: { gradientBackground: {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
}, },
content: { content: {
flex: 1, flex: 1,
@@ -418,16 +316,35 @@ const styles = StyleSheet.create({
paddingTop: 20, paddingTop: 20,
paddingBottom: 16, paddingBottom: 16,
}, },
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
goalsButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
pageTitle: { pageTitle: {
fontSize: 28, fontSize: 28,
fontWeight: '800', fontWeight: '800',
marginBottom: 4, marginBottom: 4,
}, },
addButton: { addButton: {
width: 40, width: 30,
height: 40, height: 30,
borderRadius: 20, borderRadius: 20,
backgroundColor: '#6366F1', backgroundColor: '#0EA5E9',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
shadowColor: '#000', shadowColor: '#000',
@@ -438,80 +355,44 @@ const styles = StyleSheet.create({
}, },
addButtonText: { addButtonText: {
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 24, fontSize: 22,
fontWeight: '600', fontWeight: '600',
lineHeight: 24, lineHeight: 22,
}, },
pageSubtitle: {
fontSize: 16, taskListContainer: {
fontWeight: '500',
},
timelineSection: {
flex: 1, flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderTopLeftRadius: 24, borderTopLeftRadius: 24,
borderTopRightRadius: 24, borderTopRightRadius: 24,
overflow: 'hidden', overflow: 'hidden',
}, },
// 日期选择器样式 (参考 statistics.tsx) taskList: {
dateSelector: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 16, paddingTop: 20,
paddingBottom: 20,
}, },
monthTitle: { emptyState: {
fontSize: 24,
fontWeight: '800',
marginBottom: 14,
},
daysContainer: {
paddingBottom: 8,
},
dayItemWrapper: {
alignItems: 'center',
width: 48,
marginRight: 8,
},
dayPill: {
width: 40,
height: 60,
borderRadius: 24,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 60,
}, },
dayPillNormal: { emptyStateTitle: {
backgroundColor: 'transparent', fontSize: 18,
fontWeight: '600',
marginBottom: 8,
}, },
dayPillSelected: { emptyStateSubtitle: {
backgroundColor: '#FFFFFF', fontSize: 14,
shadowColor: '#000', textAlign: 'center',
shadowOffset: { width: 0, height: 2 }, lineHeight: 20,
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
}, },
dayPillDisabled: { loadMoreContainer: {
backgroundColor: 'transparent', alignItems: 'center',
opacity: 0.4, paddingVertical: 20,
}, },
dayLabel: { loadMoreText: {
fontSize: 11, fontSize: 14,
fontWeight: '700', fontWeight: '500',
color: 'gray',
marginBottom: 2,
},
dayLabelSelected: {
color: '#192126',
},
dayLabelDisabled: {
},
dayDate: {
fontSize: 12,
fontWeight: '800',
color: 'gray',
},
dayDateSelected: {
color: '#192126',
},
dayDateDisabled: {
}, },
}); });

View File

@@ -1,11 +1,14 @@
import { AnimatedNumber } from '@/components/AnimatedNumber'; import { AnimatedNumber } from '@/components/AnimatedNumber';
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard'; import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MoodCard } from '@/components/MoodCard'; import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import { ProgressBar } from '@/components/ProgressBar'; import { ProgressBar } from '@/components/ProgressBar';
import { StressMeter } from '@/components/StressMeter'; import { StressMeter } from '@/components/StressMeter';
import { WeightHistoryCard } from '@/components/WeightHistoryCard'; import { WeightHistoryCard } from '@/components/WeightHistoryCard';
import HeartRateCard from '@/components/statistic/HeartRateCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -13,7 +16,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords'; import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
@@ -27,8 +30,7 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, View
View,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -92,7 +94,6 @@ export default function ExploreScreen() {
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
// 使用 dayjs当月日期与默认选中"今天" // 使用 dayjs当月日期与默认选中"今天"
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const tabBarHeight = useBottomTabBarHeight(); const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -100,43 +101,6 @@ export default function ExploreScreen() {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]); }, [tabBarHeight, insets?.bottom]);
const monthTitle = getMonthTitleZh();
// 日期条自动滚动到选中项
const daysScrollRef = useRef<import('react-native').ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 48;
const DAY_PILL_SPACING = 8;
const scrollToIndex = (index: number, animated = true) => {
if (!daysScrollRef.current || scrollWidth === 0) return;
const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING;
const baseOffset = index * itemWidth;
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
// 确保不会滚动超出边界
const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth);
const finalOffset = Math.min(centerOffset, maxScrollOffset);
daysScrollRef.current.scrollTo({ x: finalOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// 当选中索引变化时,滚动到对应位置
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// HealthKit: 每次页面聚焦都拉取今日数据 // HealthKit: 每次页面聚焦都拉取今日数据
const [stepCount, setStepCount] = useState<number | null>(null); const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null); const [activeCalories, setActiveCalories] = useState<number | null>(null);
@@ -170,16 +134,22 @@ export default function ExploreScreen() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isMoodLoading, setIsMoodLoading] = useState(false); const [isMoodLoading, setIsMoodLoading] = useState(false);
// 从 Redux 获取当前日期的心情记录
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
days[selectedIndex]?.date?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD')
));
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果 // 记录最近一次请求的"日期键",避免旧请求覆盖新结果
const latestRequestKeyRef = useRef<string | null>(null); const latestRequestKeyRef = useRef<string | null>(null);
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`; const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
// 获取当前选中日期
const getCurrentSelectedDate = () => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
};
// 从 Redux 获取当前日期的心情记录
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
dayjs(getCurrentSelectedDate()).format('YYYY-MM-DD')
));
// 加载心情数据 // 加载心情数据
const loadMoodData = async (targetDate?: Date) => { const loadMoodData = async (targetDate?: Date) => {
if (!isLoggedIn) return; if (!isLoggedIn) return;
@@ -192,7 +162,7 @@ export default function ExploreScreen() {
if (targetDate) { if (targetDate) {
derivedDate = targetDate; derivedDate = targetDate;
} else { } else {
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); derivedDate = getCurrentSelectedDate();
} }
const dateString = dayjs(derivedDate).format('YYYY-MM-DD'); const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
@@ -223,7 +193,7 @@ export default function ExploreScreen() {
if (targetDate) { if (targetDate) {
derivedDate = targetDate; derivedDate = targetDate;
} else { } else {
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); derivedDate = getCurrentSelectedDate();
} }
const requestKey = getDateKey(derivedDate); const requestKey = getDateKey(derivedDate);
@@ -278,7 +248,7 @@ export default function ExploreScreen() {
if (targetDate) { if (targetDate) {
derivedDate = targetDate; derivedDate = targetDate;
} else { } else {
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); derivedDate = getCurrentSelectedDate();
} }
console.log('加载营养数据...', derivedDate); console.log('加载营养数据...', derivedDate);
@@ -306,7 +276,7 @@ export default function ExploreScreen() {
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致 // 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
const currentDate = days[selectedIndex]?.date?.toDate(); const currentDate = getCurrentSelectedDate();
if (currentDate) { if (currentDate) {
loadHealthData(currentDate); loadHealthData(currentDate);
if (isLoggedIn) { if (isLoggedIn) {
@@ -318,15 +288,12 @@ export default function ExploreScreen() {
); );
// 日期点击时,加载对应日期数据 // 日期点击时,加载对应日期数据
const onSelectDate = (index: number) => { const onSelectDate = (index: number, date: Date) => {
setSelectedIndex(index); setSelectedIndex(index);
const target = days[index]?.date?.toDate(); loadHealthData(date);
if (target) {
loadHealthData(target);
if (isLoggedIn) { if (isLoggedIn) {
loadNutritionData(target); loadNutritionData(date);
loadMoodData(target); loadMoodData(date);
}
} }
}; };
@@ -335,12 +302,18 @@ export default function ExploreScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient <LinearGradient
colors={backgroundGradientColors} colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground} style={styles.gradientBackground}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }} end={{ x: 1, y: 1 }}
/> />
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<SafeAreaView style={styles.safeArea}> <SafeAreaView style={styles.safeArea}>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
@@ -351,46 +324,13 @@ export default function ExploreScreen() {
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}></Text>
<WeightHistoryCard /> <WeightHistoryCard />
{/* 标题与日期选择 */} {/* 日期选择 */}
<Text style={styles.monthTitle}>{monthTitle}</Text> <DateSelector
<ScrollView selectedIndex={selectedIndex}
horizontal onDateSelect={onSelectDate}
showsHorizontalScrollIndicator={false} showMonthTitle={true}
contentContainerStyle={styles.daysContainer} disableFutureDates={true}
ref={daysScrollRef} />
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
const isFutureDate = d.date.isAfter(dayjs(), 'day');
return (
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[
styles.dayPill,
selected ? styles.dayPillSelected : styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}
onPress={() => !isFutureDate && onSelectDate(i)}
activeOpacity={isFutureDate ? 1 : 0.8}
disabled={isFutureDate}
>
<Text style={[
styles.dayLabel,
selected && styles.dayLabelSelected,
isFutureDate && styles.dayLabelDisabled
]}> {d.weekdayZh} </Text>
<Text style={[
styles.dayDate,
selected && styles.dayDateSelected,
isFutureDate && styles.dayDateDisabled
]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
{selected && <View style={styles.selectedDot} />}
</View>
);
})}
</ScrollView>
{/* 营养摄入雷达图卡片 */} {/* 营养摄入雷达图卡片 */}
<NutritionRadarCard <NutritionRadarCard
@@ -491,6 +431,22 @@ export default function ExploreScreen() {
/> />
</FloatingCard> </FloatingCard>
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard} delay={1750}>
<OxygenSaturationCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
{/* 心率卡片 */}
<FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>
@@ -514,6 +470,26 @@ const styles = StyleSheet.create({
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
safeArea: { safeArea: {
flex: 1, flex: 1,
}, },
@@ -521,70 +497,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
monthTitle: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
marginTop: 8,
marginBottom: 14,
},
daysContainer: {
paddingBottom: 8,
},
dayItemWrapper: {
alignItems: 'center',
width: 48,
marginRight: 8,
},
dayPill: {
width: 48,
height: 48,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
dayPillNormal: {
backgroundColor: lightColors.datePickerNormal,
},
dayPillSelected: {
backgroundColor: lightColors.datePickerSelected,
},
dayPillDisabled: {
backgroundColor: '#F5F5F5',
opacity: 0.5,
},
dayLabel: {
fontSize: 12,
fontWeight: '700',
color: '#192126',
marginBottom: 1,
},
dayLabelSelected: {
color: '#FFFFFF',
},
dayLabelDisabled: {
color: '#9AA3AE',
},
dayDate: {
fontSize: 12,
fontWeight: '800',
color: '#192126',
},
dayDateSelected: {
color: '#FFFFFF',
},
dayDateDisabled: {
color: '#9AA3AE',
},
selectedDot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: lightColors.datePickerSelected,
marginTop: 6,
marginBottom: 2,
alignSelf: 'center',
},
sectionTitle: { sectionTitle: {
fontSize: 24, fontSize: 24,
fontWeight: '800', fontWeight: '800',

465
app/goals-detail.tsx Normal file
View 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,
},
});

View File

@@ -47,7 +47,6 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis
{/* 头部区域 */} {/* 头部区域 */}
<View style={styles.header}> <View style={styles.header}>
<View style={styles.leftSection}> <View style={styles.leftSection}>
<Text style={styles.title}></Text> <Text style={styles.title}></Text>
</View> </View>
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}> <View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>

View File

@@ -1,7 +1,9 @@
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals'; import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Alert, Alert,
@@ -15,6 +17,7 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import WheelPickerExpo from 'react-native-wheel-picker-expo';
interface CreateGoalModalProps { interface CreateGoalModalProps {
visible: boolean; visible: boolean;
@@ -30,7 +33,7 @@ const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [
{ value: 'custom', label: '自定义' }, { value: 'custom', label: '自定义' },
]; ];
const FREQUENCY_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const FREQUENCY_OPTIONS = Array.from({ length: 30 }, (_, i) => i + 1);
export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
visible, visible,
@@ -47,6 +50,8 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
const [repeatType, setRepeatType] = useState<RepeatType>('daily'); const [repeatType, setRepeatType] = useState<RepeatType>('daily');
const [frequency, setFrequency] = useState(1); const [frequency, setFrequency] = useState(1);
const [hasReminder, setHasReminder] = useState(false); const [hasReminder, setHasReminder] = useState(false);
const [showFrequencyPicker, setShowFrequencyPicker] = useState(false);
const [showRepeatTypePicker, setShowRepeatTypePicker] = useState(false);
const [reminderTime, setReminderTime] = useState('20:00'); const [reminderTime, setReminderTime] = useState('20:00');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [priority, setPriority] = useState<GoalPriority>(5); const [priority, setPriority] = useState<GoalPriority>(5);
@@ -163,12 +168,21 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
onRequestClose={handleClose} onRequestClose={handleClose}
> >
<View style={[styles.container, { backgroundColor: colorTokens.background }]}> <View style={[styles.container, { backgroundColor: colorTokens.background }]}>
{/* 渐变背景 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
{/* 头部 */} {/* 头部 */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={handleClose} disabled={loading}> <TouchableOpacity onPress={handleClose} disabled={loading}>
<Text style={[styles.cancelButton, { color: colorTokens.text }]}> <Ionicons name="close" size={24} color={colorTokens.text} />
</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.title, { color: colorTokens.text }]}> <Text style={[styles.title, { color: colorTokens.text }]}>
@@ -180,13 +194,13 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
{/* 目标标题输入 */} {/* 目标标题输入 */}
<View style={styles.section}> <View style={styles.section}>
<View style={styles.iconTitleContainer}> <View style={styles.iconTitleContainer}>
<View style={styles.iconPlaceholder}> {/* <View style={styles.iconPlaceholder}>
<Text style={styles.iconText}>图标</Text> <Text style={styles.iconText}>图标</Text>
</View> </View> */}
<TextInput <TextInput
style={[styles.titleInput, { color: colorTokens.text }]} style={[styles.titleInput, { color: colorTokens.text }]}
placeholder="写点什么..." placeholder="写点什么..."
placeholderTextColor={colorTokens.textSecondary} // placeholderTextColor={colorTokens.textSecondary}
value={title} value={title}
onChangeText={setTitle} onChangeText={setTitle}
multiline multiline
@@ -201,6 +215,7 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
{/* 目标重复周期 */} {/* 目标重复周期 */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}> <View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
<TouchableOpacity style={styles.optionValue} onPress={() => setShowRepeatTypePicker(true)}>
<View style={styles.optionHeader}> <View style={styles.optionHeader}>
<View style={styles.optionIcon}> <View style={styles.optionIcon}>
<Text style={styles.optionIconText}>🔄</Text> <Text style={styles.optionIconText}>🔄</Text>
@@ -208,19 +223,63 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
<Text style={[styles.optionLabel, { color: colorTokens.text }]}> <Text style={[styles.optionLabel, { color: colorTokens.text }]}>
</Text> </Text>
<TouchableOpacity style={styles.optionValue}>
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}> <Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
{REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label} {REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label}
</Text> </Text>
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}> <Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
</Text> </Text>
</View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* 重复周期选择器弹窗 */}
<Modal
visible={showRepeatTypePicker}
transparent
animationType="fade"
onRequestClose={() => setShowRepeatTypePicker(false)}
>
<TouchableOpacity
style={styles.modalBackdrop}
activeOpacity={1}
onPress={() => setShowRepeatTypePicker(false)}
/>
<View style={styles.modalSheet}>
<View style={{ height: 200 }}>
<WheelPickerExpo
height={200}
width={150}
initialSelectedIndex={REPEAT_TYPE_OPTIONS.findIndex(opt => opt.value === repeatType)}
items={REPEAT_TYPE_OPTIONS.map(opt => ({ label: opt.label, value: opt.value }))}
onChange={({ item }) => setRepeatType(item.value)}
backgroundColor={colorTokens.card}
haptics
/>
</View> </View>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtn]}
onPress={() => setShowRepeatTypePicker(false)}
>
<Text style={styles.modalBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalBtn, styles.modalBtnPrimary]}
onPress={() => setShowRepeatTypePicker(false)}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</TouchableOpacity>
</View>
)}
</View>
</Modal>
{/* 频率设置 */} {/* 频率设置 */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}> <View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
<TouchableOpacity style={styles.optionValue} onPress={() => setShowFrequencyPicker(true)}>
<View style={styles.optionHeader}> <View style={styles.optionHeader}>
<View style={styles.optionIcon}> <View style={styles.optionIcon}>
<Text style={styles.optionIconText}>📊</Text> <Text style={styles.optionIconText}>📊</Text>
@@ -228,16 +287,59 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
<Text style={[styles.optionLabel, { color: colorTokens.text }]}> <Text style={[styles.optionLabel, { color: colorTokens.text }]}>
</Text> </Text>
<TouchableOpacity style={styles.optionValue}>
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}> <Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
{frequency} {frequency}
</Text> </Text>
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}> <Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
</Text> </Text>
</View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* 频率选择器弹窗 */}
<Modal
visible={showFrequencyPicker}
transparent
animationType="fade"
onRequestClose={() => setShowFrequencyPicker(false)}
>
<TouchableOpacity
style={styles.modalBackdrop}
activeOpacity={1}
onPress={() => setShowFrequencyPicker(false)}
/>
<View style={styles.modalSheet}>
<View style={{ height: 200 }}>
<WheelPickerExpo
height={200}
width={150}
initialSelectedIndex={frequency - 1}
items={FREQUENCY_OPTIONS.map(num => ({ label: num.toString(), value: num }))}
onChange={({ item }) => setFrequency(item.value)}
backgroundColor={colorTokens.card}
// selectedStyle={{ borderColor: colorTokens.primary, borderWidth: 2 }}
haptics
/>
</View> </View>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtn]}
onPress={() => setShowFrequencyPicker(false)}
>
<Text style={styles.modalBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalBtn, styles.modalBtnPrimary]}
onPress={() => setShowFrequencyPicker(false)}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</TouchableOpacity>
</View>
)}
</View>
</Modal>
{/* 提醒设置 */} {/* 提醒设置 */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}> <View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
@@ -354,6 +456,34 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
container: { container: {
flex: 1, flex: 1,
}, },
@@ -378,15 +508,13 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
section: { section: {
marginBottom: 24,
}, },
iconTitleContainer: { iconTitleContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 16,
marginBottom: 16,
}, },
iconPlaceholder: { iconPlaceholder: {
width: 60, width: 60,
@@ -492,6 +620,8 @@ const styles = StyleSheet.create({
bottom: 0, bottom: 0,
padding: 16, padding: 16,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
justifyContent: 'center',
alignItems: 'center',
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderTopRightRadius: 16, borderTopRightRadius: 16,
}, },

222
components/DateSelector.tsx Normal file
View 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
View 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',
},
});

View 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',
},
});

View File

@@ -111,9 +111,7 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
height, height,
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐 left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
width: eventWidth - 8, // 增加卡片间距 width: eventWidth - 8, // 增加卡片间距
backgroundColor: event.isCompleted backgroundColor: '#FFFFFF', // 白色背景
? `${categoryColor}40`
: `${categoryColor}80`,
borderLeftColor: categoryColor, borderLeftColor: categoryColor,
} }
]} ]}
@@ -121,29 +119,57 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.eventContent}> <View style={styles.eventContent}>
{/* 顶部行:标题和分类标签 */}
<View style={styles.eventHeader}>
<Text <Text
style={[ style={[
styles.eventTitle, styles.eventTitle,
{ {
color: event.isCompleted ? colorTokens.textMuted : colorTokens.text, color: event.isCompleted ? colorTokens.textMuted : '#2C3E50',
textDecorationLine: event.isCompleted ? 'line-through' : 'none' textDecorationLine: event.isCompleted ? 'line-through' : 'none',
flex: 1,
} }
]} ]}
numberOfLines={shouldShowTimeRange ? 1 : 2} // 当显示时间时标题只显示1行 numberOfLines={1}
> >
{event.title} {event.title}
</Text> </Text>
<View style={[styles.categoryTag, { backgroundColor: `${categoryColor}20` }]}>
{shouldShowTimeRange && ( <Text style={[styles.categoryText, { color: categoryColor }]}>
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}> {event.category === 'workout' ? '运动' :
{dayjs(event.startTime).format('HH:mm')} event.category === 'finance' ? '财务' :
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`} event.category === 'personal' ? '个人' :
event.category === 'work' ? '工作' :
event.category === 'health' ? '健康' : '其他'}
</Text> </Text>
</View>
</View>
{/* 底部行:时间和图标 */}
{shouldShowTimeRange && (
<View style={styles.eventFooter}>
<View style={styles.timeContainer}>
<Ionicons name="time-outline" size={14} color="#8E8E93" />
<Text style={styles.eventTime}>
{dayjs(event.startTime).format('HH:mm A')}
</Text>
</View>
<View style={styles.iconContainer}>
{event.isCompleted ? (
<Ionicons name="checkmark-circle" size={16} color="#34C759" />
) : (
<Ionicons name="star" size={16} color="#FF9500" />
)}
<Ionicons name="attach" size={16} color="#8E8E93" />
</View>
</View>
)} )}
{event.isCompleted && ( {/* 完成状态指示 */}
{event.isCompleted && !shouldShowTimeRange && (
<View style={styles.completedIcon}> <View style={styles.completedIcon}>
<Ionicons name="checkmark-circle" size={16} color={categoryColor} /> <Ionicons name="checkmark-circle" size={16} color="#34C759" />
</View> </View>
)} )}
</View> </View>
@@ -301,32 +327,62 @@ const styles = StyleSheet.create({
}, },
eventContainer: { eventContainer: {
position: 'absolute', position: 'absolute',
borderRadius: 8, borderRadius: 12,
borderLeftWidth: 4, borderLeftWidth: 0,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { shadowOffset: {
width: 0, width: 0,
height: 2, height: 2,
}, },
shadowOpacity: 0.1, shadowOpacity: 0.08,
shadowRadius: 4, shadowRadius: 6,
elevation: 3, elevation: 4,
}, },
eventContent: { eventContent: {
flex: 1, flex: 1,
padding: 8, padding: 12,
justifyContent: 'space-between', justifyContent: 'space-between',
},
eventHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}, },
eventTitle: { eventTitle: {
fontSize: 12, fontSize: 14,
fontWeight: '700',
lineHeight: 18,
flex: 1,
},
categoryTag: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
categoryText: {
fontSize: 11,
fontWeight: '600', fontWeight: '600',
lineHeight: 16, },
eventFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
timeContainer: {
flexDirection: 'row',
alignItems: 'center',
}, },
eventTime: { eventTime: {
fontSize: 10, fontSize: 12,
fontWeight: '500', fontWeight: '500',
marginTop: 2, marginLeft: 6,
color: '#8E8E93',
},
iconContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}, },
completedIcon: { completedIcon: {
position: 'absolute', position: 'absolute',

View 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;

View 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;

View 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;

View 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
View File

@@ -55,6 +55,7 @@
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"react-native-wheel-picker-expo": "^0.5.4",
"react-redux": "^9.2.0" "react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -11700,6 +11701,18 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-wheel-picker-expo": {
"version": "0.5.4",
"resolved": "https://mirrors.tencent.com/npm/react-native-wheel-picker-expo/-/react-native-wheel-picker-expo-0.5.4.tgz",
"integrity": "sha512-mTA35pqAGioi7gie+nLF4EcwCj7zU36zzlYZVRS/bZul84zvvoMFKJu6Sm84WuWQiUJ40J1EgYxQ3Ui1pIMJZg==",
"license": "MIT",
"peerDependencies": {
"expo-haptics": ">=11",
"expo-linear-gradient": ">=11",
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native/node_modules/@react-native/virtualized-lists": { "node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.79.5", "version": "0.79.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz",

View File

@@ -58,6 +58,7 @@
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"react-native-wheel-picker-expo": "^0.5.4",
"react-redux": "^9.2.0" "react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {

114
services/healthData.ts Normal file
View 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
View 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;

View File

@@ -5,6 +5,7 @@ import exerciseLibraryReducer from './exerciseLibrarySlice';
import goalsReducer from './goalsSlice'; import goalsReducer from './goalsSlice';
import moodReducer from './moodSlice'; import moodReducer from './moodSlice';
import scheduleExerciseReducer from './scheduleExerciseSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice';
import tasksReducer from './tasksSlice';
import trainingPlanReducer from './trainingPlanSlice'; import trainingPlanReducer from './trainingPlanSlice';
import userReducer from './userSlice'; import userReducer from './userSlice';
import workoutReducer from './workoutSlice'; import workoutReducer from './workoutSlice';
@@ -44,6 +45,7 @@ export const store = configureStore({
checkin: checkinReducer, checkin: checkinReducer,
goals: goalsReducer, goals: goalsReducer,
mood: moodReducer, mood: moodReducer,
tasks: tasksReducer,
trainingPlan: trainingPlanReducer, trainingPlan: trainingPlanReducer,
scheduleExercise: scheduleExerciseReducer, scheduleExercise: scheduleExerciseReducer,
exerciseLibrary: exerciseLibraryReducer, exerciseLibrary: exerciseLibraryReducer,

318
store/tasksSlice.ts Normal file
View 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;

View File

@@ -178,3 +178,75 @@ export interface GoalListItem extends Goal {
progressPercentage: number; progressPercentage: number;
daysRemaining?: number; daysRemaining?: number;
} }
// 任务相关类型定义
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'overdue' | 'skipped';
// 任务数据结构
export interface Task {
id: string;
goalId: string;
userId: string;
title: string;
description?: string;
startDate: string; // ISO date string
endDate: string; // ISO date string
targetCount: number;
currentCount: number;
status: TaskStatus;
progressPercentage: number;
completedAt?: string; // ISO datetime string
notes?: string;
metadata?: Record<string, any>;
daysRemaining: number;
isToday: boolean;
goal?: {
id: string;
title: string;
repeatType: RepeatType;
frequency: number;
category?: string;
};
}
// 获取任务列表的查询参数
export interface GetTasksQuery {
goalId?: string;
status?: TaskStatus;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}
// 完成任务的请求数据
export interface CompleteTaskRequest {
count?: number;
notes?: string;
completedAt?: string;
}
// 跳过任务的请求数据
export interface SkipTaskRequest {
reason?: string;
}
// 任务统计信息
export interface TaskStats {
total: number;
pending: number;
inProgress: number;
completed: number;
overdue: number;
skipped: number;
totalProgress: number;
todayTasks: number;
weekTasks: number;
monthTasks: number;
}
// 任务列表项响应
export interface TaskListItem extends Task {
// 继承Task的所有属性
}