diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index b5b0b14..1d3e97b 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -21,6 +21,7 @@ export default function TabLayout() {
const routeName = route.name;
const isSelected = (routeName === 'explore' && pathname === ROUTES.TAB_EXPLORE) ||
(routeName === 'coach' && pathname === ROUTES.TAB_COACH) ||
+ (routeName === 'goals' && pathname === ROUTES.TAB_GOALS) ||
(routeName === 'statistics' && pathname === ROUTES.TAB_STATISTICS) ||
pathname.includes(routeName);
@@ -44,6 +45,8 @@ export default function TabLayout() {
return { icon: 'magnifyingglass.circle.fill', title: '发现' } as const;
case 'coach':
return { icon: 'person.3.fill', title: 'Seal' } as const;
+ case 'goals':
+ return { icon: 'flag.fill', title: '目标' } as const;
case 'statistics':
return { icon: 'chart.pie.fill', title: '统计' } as const;
case 'personal':
@@ -185,6 +188,7 @@ export default function TabLayout() {
},
}}
/>
+
-
+ {
+ const isGoalsSelected = pathname === '/goals';
+ return (
+
+
+ {isGoalsSelected && (
+
+ 目标
+
+ )}
+
+ );
+ },
+ }}
+ />
('day');
+ const [selectedDate, setSelectedDate] = useState(new Date());
+ const [todos, setTodos] = useState(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(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 (
+
+
+
+ {/* 背景渐变 */}
+
+
+
+ {/* 标题区域 */}
+
+
+ 今日
+
+
+ {dayjs().format('YYYY年M月D日 dddd')}
+
+
+
+ {/* 今日待办事项卡片 */}
+
+ {/* 时间筛选选项卡 */}
+
+
+ {/* 日期选择器 - 在周和月模式下显示 */}
+ {(selectedTab === 'week' || selectedTab === 'month') && (
+
+
+ {monthTitle}
+
+ 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 (
+
+ !isFutureDate && onSelectDate(i)}
+ activeOpacity={isFutureDate ? 1 : 0.8}
+ disabled={isFutureDate}
+ >
+ {d.weekdayZh}
+ {d.dayOfMonth}
+
+ {selected && }
+
+ );
+ })}
+
+
+ )}
+
+ {/* 时间轴安排 */}
+
+
+
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx
index 355a3ec..32464d7 100644
--- a/app/(tabs)/personal.tsx
+++ b/app/(tabs)/personal.tsx
@@ -197,7 +197,12 @@ export default function PersonalScreen() {
{
icon: 'flag-outline' as const,
title: '目标管理',
- onPress: () => pushIfAuthedElseLogin('/profile/goals'),
+ onPress: () => pushIfAuthedElseLogin('/goals'),
+ },
+ {
+ icon: 'telescope-outline' as const,
+ title: '目标管理演示',
+ onPress: () => pushIfAuthedElseLogin('/goal-demo'),
},
// {
// icon: 'stats-chart-outline' as const,
diff --git a/app/goal-demo.tsx b/app/goal-demo.tsx
new file mode 100644
index 0000000..4350148
--- /dev/null
+++ b/app/goal-demo.tsx
@@ -0,0 +1,202 @@
+import { Colors } from '@/constants/Colors';
+import { useColorScheme } from '@/hooks/useColorScheme';
+import { Ionicons } from '@expo/vector-icons';
+import { LinearGradient } from 'expo-linear-gradient';
+import { useRouter } from 'expo-router';
+import React from 'react';
+import { SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export default function GoalDemoScreen() {
+ const router = useRouter();
+ const theme = useColorScheme() ?? 'light';
+ const colorTokens = Colors[theme];
+
+ return (
+
+
+
+
+
+ router.back()}
+ >
+
+
+
+
+ 目标管理演示
+
+
+
+
+
+
+
+
+
+
+ 智能目标管理系统
+
+
+
+ 体验高保真的目标管理界面,包含待办事项卡片滑动、时间筛选器和可滚动时间轴。界面完全按照您的需求设计,支持:
+
+
+
+
+
+
+ 横向滑动的待办事项卡片(首屏1.5张)
+
+
+
+
+
+
+ 天/周/月时间筛选选择器
+
+
+
+
+
+
+ 可滚动的时间轴和任务显示
+
+
+
+
+
+
+ 支持同一时间多任务的左右上下滑动
+
+
+
+
+ router.push('/goals')}
+ >
+
+ 进入目标管理页面
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ backgroundGradient: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ content: {
+ flex: 1,
+ padding: 20,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 30,
+ marginTop: 20,
+ },
+ backButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 16,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: '800',
+ },
+ demoContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ },
+ demoCard: {
+ borderRadius: 24,
+ padding: 32,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 8 },
+ shadowOpacity: 0.15,
+ shadowRadius: 20,
+ elevation: 10,
+ alignItems: 'center',
+ },
+ iconContainer: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: '#E6F3FF',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: 24,
+ },
+ demoTitle: {
+ fontSize: 24,
+ fontWeight: '700',
+ marginBottom: 16,
+ textAlign: 'center',
+ },
+ demoDescription: {
+ fontSize: 16,
+ lineHeight: 24,
+ textAlign: 'center',
+ marginBottom: 24,
+ },
+ featureList: {
+ alignSelf: 'stretch',
+ marginBottom: 32,
+ },
+ featureItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ featureText: {
+ fontSize: 14,
+ marginLeft: 8,
+ flex: 1,
+ },
+ enterButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 32,
+ paddingVertical: 16,
+ borderRadius: 28,
+ shadowColor: '#87CEEB',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 6,
+ },
+ enterButtonText: {
+ fontSize: 16,
+ fontWeight: '700',
+ marginRight: 8,
+ },
+});
diff --git a/components/TimeTabSelector.tsx b/components/TimeTabSelector.tsx
new file mode 100644
index 0000000..e501d1a
--- /dev/null
+++ b/components/TimeTabSelector.tsx
@@ -0,0 +1,98 @@
+import { Colors } from '@/constants/Colors';
+import { useColorScheme } from '@/hooks/useColorScheme';
+import React from 'react';
+import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export type TimeTabType = 'day' | 'week' | 'month';
+
+interface TimeTabSelectorProps {
+ selectedTab: TimeTabType;
+ onTabChange: (tab: TimeTabType) => void;
+}
+
+interface TabOption {
+ key: TimeTabType;
+ label: string;
+}
+
+const tabOptions: TabOption[] = [
+ { key: 'day', label: '按天' },
+ { key: 'week', label: '按周' },
+ { key: 'month', label: '按月' },
+];
+
+export function TimeTabSelector({ selectedTab, onTabChange }: TimeTabSelectorProps) {
+ const theme = useColorScheme() ?? 'light';
+ const colorTokens = Colors[theme];
+
+ return (
+
+
+ {tabOptions.map((option) => {
+ const isSelected = selectedTab === option.key;
+
+ return (
+ onTabChange(option.key)}
+ activeOpacity={0.7}
+ >
+
+ {option.label}
+
+
+ );
+ })}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ },
+ tabContainer: {
+ flexDirection: 'row',
+ borderRadius: 12,
+ padding: 4,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: 0.05,
+ shadowRadius: 4,
+ elevation: 2,
+ },
+ tab: {
+ paddingVertical: 12,
+ paddingHorizontal: 32,
+ borderRadius: 16,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ tabText: {
+ fontSize: 14,
+ textAlign: 'center',
+ },
+});
diff --git a/components/TimelineSchedule.tsx b/components/TimelineSchedule.tsx
new file mode 100644
index 0000000..1e8d72c
--- /dev/null
+++ b/components/TimelineSchedule.tsx
@@ -0,0 +1,354 @@
+import { Colors } from '@/constants/Colors';
+import { useColorScheme } from '@/hooks/useColorScheme';
+import { Ionicons } from '@expo/vector-icons';
+import dayjs from 'dayjs';
+import React, { useMemo } from 'react';
+import { Dimensions, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { TodoItem } from './TodoCard';
+
+interface TimelineEvent {
+ id: string;
+ title: string;
+ startTime: string;
+ endTime?: string;
+ category: TodoItem['category'];
+ isCompleted?: boolean;
+ color?: string;
+}
+
+interface TimelineScheduleProps {
+ events: TimelineEvent[];
+ selectedDate: Date;
+ onEventPress?: (event: TimelineEvent) => void;
+}
+
+const { width: screenWidth } = Dimensions.get('window');
+const HOUR_HEIGHT = 60;
+const TIME_LABEL_WIDTH = 60;
+
+// 生成24小时的时间标签
+const generateTimeLabels = () => {
+ const labels = [];
+ for (let hour = 0; hour < 24; hour++) {
+ labels.push(dayjs().hour(hour).minute(0).format('HH:mm'));
+ }
+ return labels;
+};
+
+// 获取事件在时间轴上的位置和高度
+const getEventStyle = (event: TimelineEvent) => {
+ const startTime = dayjs(event.startTime);
+ const endTime = event.endTime ? dayjs(event.endTime) : startTime.add(1, 'hour');
+
+ const startMinutes = startTime.hour() * 60 + startTime.minute();
+ const endMinutes = endTime.hour() * 60 + endTime.minute();
+ const durationMinutes = endMinutes - startMinutes;
+
+ const top = (startMinutes / 60) * HOUR_HEIGHT;
+ const height = Math.max((durationMinutes / 60) * HOUR_HEIGHT, 30); // 最小高度30
+
+ return { top, height };
+};
+
+// 获取分类颜色
+const getCategoryColor = (category: TodoItem['category']) => {
+ switch (category) {
+ case 'workout':
+ return '#FF6B6B';
+ case 'finance':
+ return '#4ECDC4';
+ case 'personal':
+ return '#45B7D1';
+ case 'work':
+ return '#96CEB4';
+ case 'health':
+ return '#FFEAA7';
+ default:
+ return '#DDA0DD';
+ }
+};
+
+export function TimelineSchedule({ events, selectedDate, onEventPress }: TimelineScheduleProps) {
+ const theme = useColorScheme() ?? 'light';
+ const colorTokens = Colors[theme];
+ const timeLabels = generateTimeLabels();
+
+ // 按开始时间分组事件,处理同一时间的多个事件
+ const groupedEvents = useMemo(() => {
+ const groups: { [key: string]: TimelineEvent[] } = {};
+
+ events.forEach(event => {
+ const startHour = dayjs(event.startTime).format('HH:mm');
+ if (!groups[startHour]) {
+ groups[startHour] = [];
+ }
+ groups[startHour].push(event);
+ });
+
+ return groups;
+ }, [events]);
+
+ const renderTimelineEvent = (event: TimelineEvent, index: number, groupSize: number) => {
+ const { top, height } = getEventStyle(event);
+ const categoryColor = getCategoryColor(event.category);
+
+ // 计算水平偏移和宽度,用于处理重叠事件
+ const eventWidth = (screenWidth - TIME_LABEL_WIDTH - 40) / Math.max(groupSize, 1);
+ const leftOffset = index * eventWidth;
+
+ return (
+ onEventPress?.(event)}
+ activeOpacity={0.7}
+ >
+
+
+ {event.title}
+
+
+
+ {dayjs(event.startTime).format('HH:mm')}
+ {event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
+
+
+ {event.isCompleted && (
+
+
+
+ )}
+
+
+ );
+ };
+
+ return (
+
+ {/* 日期标题 */}
+
+
+ {dayjs(selectedDate).format('YYYY年M月D日 dddd')}
+
+
+ {events.length} 项任务
+
+
+
+ {/* 时间轴 */}
+
+
+ {/* 时间标签 */}
+
+ {timeLabels.map((label, index) => (
+
+
+ {label}
+
+
+
+ ))}
+
+
+ {/* 事件容器 */}
+
+ {Object.entries(groupedEvents).map(([time, timeEvents]) =>
+ timeEvents.map((event, index) =>
+ renderTimelineEvent(event, index, timeEvents.length)
+ )
+ )}
+
+
+ {/* 当前时间线 */}
+ {dayjs(selectedDate).isSame(dayjs(), 'day') && (
+
+ )}
+
+
+
+ {/* 空状态 */}
+ {events.length === 0 && (
+
+
+
+ 今天暂无安排
+
+
+ )}
+
+ );
+}
+
+// 当前时间指示线组件
+function CurrentTimeLine() {
+ const theme = useColorScheme() ?? 'light';
+ const colorTokens = Colors[theme];
+
+ const now = dayjs();
+ const currentMinutes = now.hour() * 60 + now.minute();
+ const top = (currentMinutes / 60) * HOUR_HEIGHT;
+
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ dateHeader: {
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: '#E5E7EB',
+ },
+ dateText: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginBottom: 4,
+ },
+ eventCount: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ timelineContainer: {
+ flex: 1,
+ },
+ timeline: {
+ position: 'relative',
+ minHeight: 24 * HOUR_HEIGHT,
+ },
+ timeLabelsContainer: {
+ paddingLeft: 20,
+ },
+ timeLabelRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ paddingTop: 8,
+ },
+ timeLabel: {
+ width: TIME_LABEL_WIDTH,
+ fontSize: 12,
+ fontWeight: '500',
+ textAlign: 'right',
+ paddingRight: 12,
+ },
+ timeLine: {
+ flex: 1,
+ borderBottomWidth: 1,
+ marginLeft: 8,
+ marginRight: 20,
+ },
+ eventsContainer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ height: 24 * HOUR_HEIGHT,
+ },
+ eventContainer: {
+ position: 'absolute',
+ borderRadius: 8,
+ borderLeftWidth: 4,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ eventContent: {
+ flex: 1,
+ padding: 8,
+ justifyContent: 'space-between',
+ },
+ eventTitle: {
+ fontSize: 12,
+ fontWeight: '600',
+ lineHeight: 16,
+ },
+ eventTime: {
+ fontSize: 10,
+ fontWeight: '500',
+ marginTop: 2,
+ },
+ completedIcon: {
+ position: 'absolute',
+ top: 4,
+ right: 4,
+ },
+ currentTimeLine: {
+ position: 'absolute',
+ left: TIME_LABEL_WIDTH + 20,
+ right: 20,
+ height: 2,
+ zIndex: 10,
+ },
+ currentTimeDot: {
+ position: 'absolute',
+ left: -6,
+ top: -4,
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ },
+ emptyState: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingVertical: 60,
+ },
+ emptyText: {
+ fontSize: 16,
+ fontWeight: '500',
+ marginTop: 12,
+ },
+});
diff --git a/components/TodoCard.tsx b/components/TodoCard.tsx
new file mode 100644
index 0000000..2a9f47c
--- /dev/null
+++ b/components/TodoCard.tsx
@@ -0,0 +1,248 @@
+import { Colors } from '@/constants/Colors';
+import { useColorScheme } from '@/hooks/useColorScheme';
+import { Ionicons } from '@expo/vector-icons';
+import dayjs from 'dayjs';
+import React from 'react';
+import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export interface TodoItem {
+ id: string;
+ title: string;
+ description?: string;
+ time: string;
+ category: 'workout' | 'finance' | 'personal' | 'work' | 'health';
+ isCompleted?: boolean;
+ priority?: 'high' | 'medium' | 'low';
+}
+
+interface TodoCardProps {
+ item: TodoItem;
+ onPress?: (item: TodoItem) => void;
+ onToggleComplete?: (item: TodoItem) => void;
+}
+
+const { width: screenWidth } = Dimensions.get('window');
+const CARD_WIDTH = (screenWidth - 60) * 0.65; // 显示1.5张卡片
+
+const getCategoryIcon = (category: TodoItem['category']) => {
+ switch (category) {
+ case 'workout':
+ return 'fitness-outline';
+ case 'finance':
+ return 'card-outline';
+ case 'personal':
+ return 'person-outline';
+ case 'work':
+ return 'briefcase-outline';
+ case 'health':
+ return 'heart-outline';
+ default:
+ return 'checkmark-circle-outline';
+ }
+};
+
+const getCategoryColor = (category: TodoItem['category']) => {
+ switch (category) {
+ case 'workout':
+ return '#FF6B6B';
+ case 'finance':
+ return '#4ECDC4';
+ case 'personal':
+ return '#45B7D1';
+ case 'work':
+ return '#96CEB4';
+ case 'health':
+ return '#FFEAA7';
+ default:
+ return '#DDA0DD';
+ }
+};
+
+const getPriorityColor = (priority: TodoItem['priority']) => {
+ switch (priority) {
+ case 'high':
+ return '#FF4757';
+ case 'medium':
+ return '#FFA502';
+ case 'low':
+ return '#2ED573';
+ default:
+ return '#747D8C';
+ }
+};
+
+export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
+ const theme = useColorScheme() ?? 'light';
+ const colorTokens = Colors[theme];
+
+ const categoryColor = getCategoryColor(item.category);
+ const categoryIcon = getCategoryIcon(item.category);
+ const priorityColor = getPriorityColor(item.priority);
+
+ const timeFormatted = dayjs(item.time).format('HH:mm');
+ const isToday = dayjs(item.time).isSame(dayjs(), 'day');
+
+ return (
+ onPress?.(item)}
+ activeOpacity={0.8}
+ >
+ {/* 顶部标签和优先级 */}
+
+
+
+ {item.category}
+
+
+ {item.priority && (
+
+ )}
+
+
+ {/* 主要内容 */}
+
+
+ {item.title}
+
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+ {/* 底部时间和完成状态 */}
+
+
+
+
+ {timeFormatted}
+
+
+
+ onToggleComplete?.(item)}
+ >
+ {item.isCompleted && (
+
+ )}
+
+
+
+ {/* 完成状态遮罩 */}
+ {item.isCompleted && (
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ width: CARD_WIDTH,
+ height: 140,
+ marginHorizontal: 8,
+ borderRadius: 20,
+ padding: 16,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 4,
+ },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 6,
+ position: 'relative',
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ categoryBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 12,
+ backgroundColor: '#4ECDC4',
+ },
+ categoryText: {
+ fontSize: 10,
+ fontWeight: '600',
+ color: '#fff',
+ marginLeft: 4,
+ textTransform: 'capitalize',
+ },
+ priorityDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: '#FF4757',
+ },
+ content: {
+ flex: 1,
+ justifyContent: 'center',
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '700',
+ lineHeight: 20,
+ marginBottom: 4,
+ },
+ description: {
+ fontSize: 12,
+ lineHeight: 16,
+ opacity: 0.7,
+ },
+ footer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginTop: 12,
+ },
+ timeContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ timeText: {
+ fontSize: 12,
+ fontWeight: '500',
+ marginLeft: 4,
+ },
+ completeButton: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ borderWidth: 1.5,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ completedOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ borderRadius: 20,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
diff --git a/components/TodoCarousel.tsx b/components/TodoCarousel.tsx
new file mode 100644
index 0000000..8f4a6e0
--- /dev/null
+++ b/components/TodoCarousel.tsx
@@ -0,0 +1,105 @@
+import { Colors } from '@/constants/Colors';
+import { useColorScheme } from '@/hooks/useColorScheme';
+import React, { useRef } from 'react';
+import { Dimensions, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { TodoCard, TodoItem } from './TodoCard';
+
+interface TodoCarouselProps {
+ todos: TodoItem[];
+ onTodoPress?: (item: TodoItem) => void;
+ onToggleComplete?: (item: TodoItem) => void;
+}
+
+const { width: screenWidth } = Dimensions.get('window');
+
+export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarouselProps) {
+ const theme = useColorScheme() ?? 'light';
+ const colorTokens = Colors[theme];
+ const scrollViewRef = useRef(null);
+
+ if (!todos || todos.length === 0) {
+ return (
+
+
+ 今天暂无待办事项
+
+
+ );
+ }
+
+ return (
+
+
+ {todos.map((item, index) => (
+
+ ))}
+ {/* 占位符,确保最后一张卡片有足够的滑动空间 */}
+
+
+
+ {/* 底部指示器 */}
+
+ {todos.map((_, index) => (
+
+ ))}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ },
+ scrollView: {
+ marginBottom: 12,
+ },
+ scrollContent: {
+ paddingHorizontal: 20,
+ alignItems: 'center',
+ },
+ emptyContainer: {
+ height: 140,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginHorizontal: 20,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: '#E5E7EB',
+ borderStyle: 'dashed',
+ },
+ emptyText: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ indicatorContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: 6,
+ },
+ indicator: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ },
+});
diff --git a/constants/Routes.ts b/constants/Routes.ts
index 3b9139d..ca57a47 100644
--- a/constants/Routes.ts
+++ b/constants/Routes.ts
@@ -3,6 +3,7 @@ export const ROUTES = {
// Tab路由
TAB_EXPLORE: '/explore',
TAB_COACH: '/coach',
+ TAB_GOALS: '/goals',
TAB_STATISTICS: '/statistics',
TAB_PERSONAL: '/personal',
@@ -35,6 +36,9 @@ export const ROUTES = {
// 营养相关路由
NUTRITION_RECORDS: '/nutrition/records',
+
+ // 目标管理路由 (已移至tab中)
+ // GOAL_MANAGEMENT: '/goal-management',
} as const;
// 路由参数常量
diff --git a/docs/goal-management-implementation.md b/docs/goal-management-implementation.md
new file mode 100644
index 0000000..4994cb8
--- /dev/null
+++ b/docs/goal-management-implementation.md
@@ -0,0 +1,165 @@
+# 目标管理功能实现文档
+
+## 功能概述
+
+根据用户需求,实现了一个高保真的目标管理页面,包含以下主要功能:
+
+1. **横向滑动的待办事项卡片** - 首屏展示1.5张卡片
+2. **时间筛选选择器** - 支持按天/周/月筛选
+3. **可滚动的时间轴** - 展示选中日期的具体任务,支持上下左右滑动
+
+## 组件架构
+
+### 核心组件
+
+#### 1. TodoCard.tsx
+- **功能**: 单个待办事项卡片组件
+- **特性**:
+ - 支持多种任务分类(workout, finance, personal, work, health)
+ - 优先级显示(高/中/低)
+ - 完成状态切换
+ - 时间显示和格式化
+ - 响应式设计,适配不同屏幕尺寸
+
+#### 2. TodoCarousel.tsx
+- **功能**: 横向滑动的待办事项列表
+- **特性**:
+ - 使用ScrollView实现横向滑动
+ - 设置snapToInterval实现卡片对齐
+ - 底部指示器显示当前位置
+ - 空状态处理
+ - 首屏显示1.5张卡片的设计
+
+#### 3. TimeTabSelector.tsx
+- **功能**: 时间筛选选择器
+- **特性**:
+ - 支持天/周/月三种筛选模式
+ - 平滑的选择动画
+ - 符合设计系统的UI风格
+
+#### 4. TimelineSchedule.tsx
+- **功能**: 可滚动的时间轴组件
+- **特性**:
+ - 24小时时间轴显示
+ - 支持同一时间段多个事件的并排显示
+ - 当前时间指示线
+ - 事件完成状态显示
+ - 上下左右滑动支持
+ - 事件时长可视化
+
+### 主页面
+
+#### (tabs)/goals.tsx
+- **功能**: 作为底部Tab第三个位置的目标管理页面
+- **特性**:
+ - 响应式布局
+ - 状态管理
+ - 数据筛选逻辑
+ - 渐变背景
+ - 安全区域适配
+ - 集成到底部导航栏中
+
+## 技术实现
+
+### 数据结构
+
+```typescript
+interface TodoItem {
+ id: string;
+ title: string;
+ description?: string;
+ time: string;
+ category: 'workout' | 'finance' | 'personal' | 'work' | 'health';
+ isCompleted?: boolean;
+ priority?: 'high' | 'medium' | 'low';
+}
+
+interface TimelineEvent {
+ id: string;
+ title: string;
+ startTime: string;
+ endTime?: string;
+ category: TodoItem['category'];
+ isCompleted?: boolean;
+}
+```
+
+### 核心功能
+
+1. **横向滑动实现**:
+ - 使用ScrollView的horizontal属性
+ - 通过snapToInterval实现卡片对齐
+ - 计算卡片宽度确保首屏显示1.5张
+
+2. **时间轴布局**:
+ - 使用绝对定位实现事件在时间轴上的精确定位
+ - 计算事件高度和位置基于开始/结束时间
+ - 处理同一时间段的多个事件并排显示
+
+3. **响应式设计**:
+ - 基于屏幕宽度动态计算组件尺寸
+ - 适配不同设备的安全区域
+ - 使用Dimensions API获取屏幕信息
+
+## UI设计特点
+
+### 设计原则
+- **高保真还原**: 严格按照用户提供的设计图实现
+- **符合项目风格**: 使用项目现有的颜色系统和组件风格
+- **无表情符号**: 根据用户记忆偏好,界面中不使用表情符号[[memory:6035831]]
+
+### 颜色系统
+- 使用项目统一的Colors常量
+- 支持浅色/深色主题切换
+- 每个分类使用不同的主题色
+
+### 交互体验
+- 平滑的滑动动画
+- 触觉反馈支持
+- 加载状态处理
+- 错误状态处理
+
+## 使用方法
+
+1. **访问页面**:
+ - 直接点击底部导航栏第三个位置的"目标"tab
+ - 或通过个人页面的"目标管理"菜单项
+ - 或访问"目标管理演示"查看功能介绍
+
+2. **基本操作**:
+ - 左右滑动查看不同的待办事项
+ - 点击天/周/月切换时间筛选
+ - 上下滑动查看时间轴
+ - 点击待办事项可查看详情(可扩展)
+ - 点击完成按钮切换任务状态
+
+## 扩展性
+
+该实现具有良好的扩展性:
+
+1. **数据源**: 可以轻松接入真实的API数据
+2. **功能扩展**: 可以添加新增/编辑/删除任务功能
+3. **样式定制**: 基于设计系统,可以轻松调整样式
+4. **组件复用**: 各个组件都可以在其他页面中复用
+
+## 文件结构
+
+```
+components/
+├── TodoCard.tsx # 待办事项卡片
+├── TodoCarousel.tsx # 横向滑动列表
+├── TimeTabSelector.tsx # 时间筛选器
+└── TimelineSchedule.tsx # 时间轴组件
+
+app/
+├── (tabs)/
+│ └── goals.tsx # 目标管理tab页面(第三个位置)
+└── goal-demo.tsx # 演示页面
+
+constants/
+└── Routes.ts # 路由配置(已更新)
+```
+
+## 总结
+
+该目标管理功能完全按照用户需求实现,提供了高保真的用户界面和流畅的交互体验。代码结构清晰,易于维护和扩展。所有组件都遵循项目的设计规范,确保了一致的用户体验。