feat: 新增目标管理功能及相关组件
- 创建目标管理演示页面,展示高保真的目标管理界面 - 实现待办事项卡片的横向滑动展示,支持时间筛选功能 - 新增时间轴组件,展示选中日期的具体任务 - 更新底部导航,添加目标管理和演示页面的路由 - 优化个人页面菜单项,提供目标管理的快速访问 - 编写目标管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
452
app/(tabs)/goals.tsx
Normal file
452
app/(tabs)/goals.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
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 { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import dayjs from 'dayjs';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
dayjs.extend(isBetween);
|
||||
|
||||
// 模拟数据
|
||||
const mockTodos: TodoItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '每日健身训练',
|
||||
description: '完成30分钟普拉提训练',
|
||||
time: dayjs().hour(8).minute(0).toISOString(),
|
||||
category: 'workout',
|
||||
priority: 'high',
|
||||
isCompleted: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '支付信用卡账单',
|
||||
description: '本月信用卡账单到期',
|
||||
time: dayjs().hour(10).minute(0).toISOString(),
|
||||
category: 'finance',
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '参加瑜伽课程',
|
||||
description: '晚上瑜伽课程预约',
|
||||
time: dayjs().hour(19).minute(0).toISOString(),
|
||||
category: 'personal',
|
||||
priority: 'low',
|
||||
isCompleted: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockTimelineEvents = [
|
||||
{
|
||||
id: '1',
|
||||
title: '每日健身训练',
|
||||
startTime: dayjs().hour(8).minute(0).toISOString(),
|
||||
endTime: dayjs().hour(8).minute(30).toISOString(),
|
||||
category: 'workout' as const,
|
||||
isCompleted: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '支付信用卡账单',
|
||||
startTime: dayjs().hour(10).minute(0).toISOString(),
|
||||
endTime: dayjs().hour(10).minute(15).toISOString(),
|
||||
category: 'finance' as const,
|
||||
isCompleted: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '团队会议',
|
||||
startTime: dayjs().hour(10).minute(0).toISOString(),
|
||||
endTime: dayjs().hour(11).minute(0).toISOString(),
|
||||
category: 'work' as const,
|
||||
isCompleted: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '午餐时间',
|
||||
startTime: dayjs().hour(12).minute(0).toISOString(),
|
||||
endTime: dayjs().hour(13).minute(0).toISOString(),
|
||||
category: 'personal' as const,
|
||||
isCompleted: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: '健康检查',
|
||||
startTime: dayjs().hour(14).minute(30).toISOString(),
|
||||
endTime: dayjs().hour(15).minute(30).toISOString(),
|
||||
category: 'health' as const,
|
||||
isCompleted: false,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: '参加瑜伽课程',
|
||||
startTime: dayjs().hour(19).minute(0).toISOString(),
|
||||
endTime: dayjs().hour(20).minute(0).toISOString(),
|
||||
category: 'personal' as const,
|
||||
isCompleted: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [todos, setTodos] = useState<TodoItem[]>(mockTodos);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 上半部分待办卡片始终只显示当日数据
|
||||
const todayTodos = useMemo(() => {
|
||||
const today = dayjs();
|
||||
return todos.filter(todo =>
|
||||
dayjs(todo.time).isSame(today, 'day')
|
||||
);
|
||||
}, [todos]);
|
||||
|
||||
// 下半部分时间轴根据选择的时间范围和日期过滤数据
|
||||
const filteredTimelineEvents = useMemo(() => {
|
||||
const selected = dayjs(selectedDate);
|
||||
|
||||
switch (selectedTab) {
|
||||
case 'day':
|
||||
return mockTimelineEvents.filter(event =>
|
||||
dayjs(event.startTime).isSame(selected, 'day')
|
||||
);
|
||||
case 'week':
|
||||
return mockTimelineEvents.filter(event =>
|
||||
dayjs(event.startTime).isSame(selected, 'week')
|
||||
);
|
||||
case 'month':
|
||||
return mockTimelineEvents.filter(event =>
|
||||
dayjs(event.startTime).isSame(selected, 'month')
|
||||
);
|
||||
default:
|
||||
return mockTimelineEvents;
|
||||
}
|
||||
}, [selectedTab, selectedDate]);
|
||||
|
||||
|
||||
|
||||
const handleTodoPress = (item: TodoItem) => {
|
||||
console.log('Todo pressed:', item.title);
|
||||
// 这里可以导航到详情页面或展示编辑模态框
|
||||
};
|
||||
|
||||
const handleToggleComplete = (item: TodoItem) => {
|
||||
setTodos(prevTodos =>
|
||||
prevTodos.map(todo =>
|
||||
todo.id === item.id
|
||||
? { ...todo, isCompleted: !todo.isCompleted }
|
||||
: todo
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleEventPress = (event: any) => {
|
||||
console.log('Event pressed:', event.title);
|
||||
// 这里可以处理时间轴事件点击
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
colorTokens.backgroundGradientStart,
|
||||
colorTokens.backgroundGradientEnd,
|
||||
]}
|
||||
style={styles.backgroundGradient}
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 标题区域 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||||
今日
|
||||
</Text>
|
||||
<Text style={[styles.pageSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{dayjs().format('YYYY年M月D日 dddd')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 今日待办事项卡片 */}
|
||||
<TodoCarousel
|
||||
todos={todayTodos}
|
||||
onTodoPress={handleTodoPress}
|
||||
onToggleComplete={handleToggleComplete}
|
||||
/>
|
||||
{/* 时间筛选选项卡 */}
|
||||
<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', '[]');
|
||||
}
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return (
|
||||
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.dayPill,
|
||||
selected ? styles.dayPillSelected : styles.dayPillNormal,
|
||||
isFutureDate && styles.dayPillDisabled
|
||||
]}
|
||||
onPress={() => !isFutureDate && onSelectDate(i)}
|
||||
activeOpacity={isFutureDate ? 1 : 0.8}
|
||||
disabled={isFutureDate}
|
||||
>
|
||||
<Text style={[
|
||||
styles.dayLabel,
|
||||
selected && styles.dayLabelSelected,
|
||||
isFutureDate && styles.dayLabelDisabled
|
||||
]}> {d.weekdayZh} </Text>
|
||||
<Text style={[
|
||||
styles.dayDate,
|
||||
selected && styles.dayDateSelected,
|
||||
isFutureDate && styles.dayDateDisabled
|
||||
]}>{d.dayOfMonth}</Text>
|
||||
</TouchableOpacity>
|
||||
{selected && <View style={[styles.selectedDot, { backgroundColor: colorTokens.primary }]} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 时间轴安排 */}
|
||||
<View style={styles.timelineSection}>
|
||||
<TimelineSchedule
|
||||
events={filteredTimelineEvents}
|
||||
selectedDate={selectedDate}
|
||||
onEventPress={handleEventPress}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backgroundGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
timelineSection: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
marginTop: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// 日期选择器样式 (参考 statistics.tsx)
|
||||
dateSelector: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
marginBottom: 14,
|
||||
},
|
||||
daysContainer: {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
dayItemWrapper: {
|
||||
alignItems: 'center',
|
||||
width: 48,
|
||||
marginRight: 8,
|
||||
},
|
||||
dayPill: {
|
||||
width: 48,
|
||||
height: 72,
|
||||
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: 12,
|
||||
fontWeight: '700',
|
||||
color: 'gray',
|
||||
marginBottom: 2,
|
||||
},
|
||||
dayLabelSelected: {
|
||||
color: '#192126',
|
||||
},
|
||||
dayLabelDisabled: {
|
||||
},
|
||||
dayDate: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: 'gray',
|
||||
},
|
||||
dayDateSelected: {
|
||||
color: '#192126',
|
||||
},
|
||||
dayDateDisabled: {
|
||||
},
|
||||
selectedDot: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
marginTop: 6,
|
||||
marginBottom: 2,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user