feat: 新增目标管理功能及相关组件

- 创建目标管理演示页面,展示高保真的目标管理界面
- 实现待办事项卡片的横向滑动展示,支持时间筛选功能
- 新增时间轴组件,展示选中日期的具体任务
- 更新底部导航,添加目标管理和演示页面的路由
- 优化个人页面菜单项,提供目标管理的快速访问
- 编写目标管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
richarjiang
2025-08-22 09:31:35 +08:00
parent 22142d587d
commit 136c800084
10 changed files with 1666 additions and 2 deletions

View File

@@ -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 (
<View style={[styles.container]}>
<View style={[styles.tabContainer]}>
{tabOptions.map((option) => {
const isSelected = selectedTab === option.key;
return (
<TouchableOpacity
key={option.key}
style={[
styles.tab,
{
backgroundColor: isSelected
? colorTokens.primary
: 'transparent',
}
]}
onPress={() => onTabChange(option.key)}
activeOpacity={0.7}
>
<Text
style={[
styles.tabText,
{
color: isSelected
? 'white'
: colorTokens.textSecondary,
fontWeight: isSelected ? '700' : '500',
}
]}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
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',
},
});

View File

@@ -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 (
<TouchableOpacity
key={event.id}
style={[
styles.eventContainer,
{
top,
height,
left: TIME_LABEL_WIDTH + 20 + leftOffset,
width: eventWidth - 4,
backgroundColor: event.isCompleted
? `${categoryColor}40`
: `${categoryColor}80`,
borderLeftColor: categoryColor,
}
]}
onPress={() => onEventPress?.(event)}
activeOpacity={0.7}
>
<View style={styles.eventContent}>
<Text
style={[
styles.eventTitle,
{
color: event.isCompleted ? colorTokens.textMuted : colorTokens.text,
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
}
]}
numberOfLines={2}
>
{event.title}
</Text>
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
{dayjs(event.startTime).format('HH:mm')}
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
</Text>
{event.isCompleted && (
<View style={styles.completedIcon}>
<Ionicons name="checkmark-circle" size={16} color={categoryColor} />
</View>
)}
</View>
</TouchableOpacity>
);
};
return (
<View style={styles.container}>
{/* 日期标题 */}
<View style={styles.dateHeader}>
<Text style={[styles.dateText, { color: colorTokens.text }]}>
{dayjs(selectedDate).format('YYYY年M月D日 dddd')}
</Text>
<Text style={[styles.eventCount, { color: colorTokens.textSecondary }]}>
{events.length}
</Text>
</View>
{/* 时间轴 */}
<ScrollView
style={styles.timelineContainer}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 50 }}
>
<View style={styles.timeline}>
{/* 时间标签 */}
<View style={styles.timeLabelsContainer}>
{timeLabels.map((label, index) => (
<View key={index} style={[styles.timeLabelRow, { height: HOUR_HEIGHT }]}>
<Text style={[styles.timeLabel, { color: colorTokens.textMuted }]}>
{label}
</Text>
<View
style={[
styles.timeLine,
{ borderBottomColor: colorTokens.separator }
]}
/>
</View>
))}
</View>
{/* 事件容器 */}
<View style={styles.eventsContainer}>
{Object.entries(groupedEvents).map(([time, timeEvents]) =>
timeEvents.map((event, index) =>
renderTimelineEvent(event, index, timeEvents.length)
)
)}
</View>
{/* 当前时间线 */}
{dayjs(selectedDate).isSame(dayjs(), 'day') && (
<CurrentTimeLine />
)}
</View>
</ScrollView>
{/* 空状态 */}
{events.length === 0 && (
<View style={styles.emptyState}>
<Ionicons
name="calendar-outline"
size={48}
color={colorTokens.textMuted}
/>
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>
</Text>
</View>
)}
</View>
);
}
// 当前时间指示线组件
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 (
<View
style={[
styles.currentTimeLine,
{
top,
backgroundColor: colorTokens.primary,
}
]}
>
<View style={[styles.currentTimeDot, { backgroundColor: colorTokens.primary }]} />
</View>
);
}
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,
},
});

248
components/TodoCard.tsx Normal file
View File

@@ -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 (
<TouchableOpacity
style={[styles.container, { backgroundColor: colorTokens.card }]}
onPress={() => onPress?.(item)}
activeOpacity={0.8}
>
{/* 顶部标签和优先级 */}
<View style={styles.header}>
<View style={[styles.categoryBadge, { backgroundColor: categoryColor }]}>
<Ionicons name={categoryIcon as any} size={12} color="#fff" />
<Text style={styles.categoryText}>{item.category}</Text>
</View>
{item.priority && (
<View style={[styles.priorityDot, { backgroundColor: priorityColor }]} />
)}
</View>
{/* 主要内容 */}
<View style={styles.content}>
<Text style={[styles.title, { color: colorTokens.text }]} numberOfLines={2}>
{item.title}
</Text>
{item.description && (
<Text style={[styles.description, { color: colorTokens.textSecondary }]} numberOfLines={2}>
{item.description}
</Text>
)}
</View>
{/* 底部时间和完成状态 */}
<View style={styles.footer}>
<View style={styles.timeContainer}>
<Ionicons
name={isToday ? "time" : "calendar-outline"}
size={14}
color={colorTokens.textMuted}
/>
<Text style={[styles.timeText, { color: colorTokens.textMuted }]}>
{timeFormatted}
</Text>
</View>
<TouchableOpacity
style={[
styles.completeButton,
{
backgroundColor: item.isCompleted ? colorTokens.primary : 'transparent',
borderColor: item.isCompleted ? colorTokens.primary : colorTokens.border
}
]}
onPress={() => onToggleComplete?.(item)}
>
{item.isCompleted && (
<Ionicons name="checkmark" size={16} color={colorTokens.onPrimary} />
)}
</TouchableOpacity>
</View>
{/* 完成状态遮罩 */}
{item.isCompleted && (
<View style={styles.completedOverlay}>
<Ionicons name="checkmark-circle" size={24} color={colorTokens.primary} />
</View>
)}
</TouchableOpacity>
);
}
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',
},
});

105
components/TodoCarousel.tsx Normal file
View File

@@ -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<ScrollView>(null);
if (!todos || todos.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>
</Text>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView
ref={scrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={(screenWidth - 60) * 0.65 + 16} // 卡片宽度 + 间距
snapToAlignment="start"
decelerationRate="fast"
contentContainerStyle={styles.scrollContent}
style={styles.scrollView}
>
{todos.map((item, index) => (
<TodoCard
key={item.id}
item={item}
onPress={onTodoPress}
onToggleComplete={onToggleComplete}
/>
))}
{/* 占位符,确保最后一张卡片有足够的滑动空间 */}
<View style={{ width: 20 }} />
</ScrollView>
{/* 底部指示器 */}
<View style={styles.indicatorContainer}>
{todos.map((_, index) => (
<View
key={index}
style={[
styles.indicator,
{ backgroundColor: index === 0 ? colorTokens.primary : colorTokens.border }
]}
/>
))}
</View>
</View>
);
}
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,
},
});