Files
digital-pilates/components/TimelineSchedule.tsx
richarjiang 231620d778 feat: 完善目标管理功能及相关组件
- 新增创建目标弹窗,支持用户输入目标信息并提交
- 实现目标数据的转换,支持将目标转换为待办事项和时间轴事件
- 优化目标页面,集成Redux状态管理,处理目标的创建、完成和错误提示
- 更新时间轴组件,支持动态显示目标安排
- 编写目标管理功能实现文档,详细描述功能和组件架构
2025-08-22 12:05:27 +08:00

363 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 计算top位置时需要加上时间标签的paddingTop偏移(8px)
const top = (startMinutes / 60) * HOUR_HEIGHT + 8;
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 availableWidth = screenWidth - TIME_LABEL_WIDTH - 48; // 减少一些边距
const eventWidth = availableWidth / Math.max(groupSize, 1);
const leftOffset = index * eventWidth;
// 判断是否应该显示时间段 - 当卡片高度小于50或宽度小于80时隐藏时间段
const shouldShowTimeRange = height >= 50 && eventWidth >= 80;
return (
<TouchableOpacity
key={event.id}
style={[
styles.eventContainer,
{
top,
height,
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
width: eventWidth - 8, // 增加卡片间距
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={shouldShowTimeRange ? 1 : 2} // 当显示时间时标题只显示1行
>
{event.title}
</Text>
{shouldShowTimeRange && (
<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}>
{/* 时间轴 */}
<ScrollView
style={styles.timelineContainer}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 50 }}
>
{/* 日期标题 */}
<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>
<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();
// 当前时间线也需要加上时间标签的paddingTop偏移(8px)
const top = (currentMinutes / 60) * HOUR_HEIGHT + 8;
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,
},
dateText: {
fontSize: 16,
fontWeight: '700',
marginBottom: 4,
},
eventCount: {
fontSize: 12,
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: 24,
},
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 + 24,
right: 24,
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,
},
});