- 新增目标通知功能,支持根据用户创建目标时选择的频率和开始时间自动创建本地定时推送通知 - 实现每日、每周和每月的重复类型,用户可自定义选择提醒时间和重复规则 - 集成目标通知测试组件,方便开发者测试不同类型的通知 - 更新相关文档,详细描述目标通知功能的实现和使用方法 - 优化目标页面,确保用户体验和界面一致性
419 lines
11 KiB
TypeScript
419 lines
11 KiB
TypeScript
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 { GoalItem } from './GoalCard';
|
|
|
|
interface TimelineEvent {
|
|
id: string;
|
|
title: string;
|
|
startTime: string;
|
|
endTime?: string;
|
|
category: GoalItem['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: GoalItem['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: '#FFFFFF', // 白色背景
|
|
borderLeftColor: categoryColor,
|
|
}
|
|
]}
|
|
onPress={() => onEventPress?.(event)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={styles.eventContent}>
|
|
{/* 顶部行:标题和分类标签 */}
|
|
<View style={styles.eventHeader}>
|
|
<Text
|
|
style={[
|
|
styles.eventTitle,
|
|
{
|
|
color: event.isCompleted ? colorTokens.textMuted : '#2C3E50',
|
|
textDecorationLine: event.isCompleted ? 'line-through' : 'none',
|
|
flex: 1,
|
|
}
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{event.title}
|
|
</Text>
|
|
<View style={[styles.categoryTag, { backgroundColor: `${categoryColor}20` }]}>
|
|
<Text style={[styles.categoryText, { color: categoryColor }]}>
|
|
{event.category === 'workout' ? '运动' :
|
|
event.category === 'finance' ? '财务' :
|
|
event.category === 'personal' ? '个人' :
|
|
event.category === 'work' ? '工作' :
|
|
event.category === 'health' ? '健康' : '其他'}
|
|
</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 && !shouldShowTimeRange && (
|
|
<View style={styles.completedIcon}>
|
|
<Ionicons name="checkmark-circle" size={16} color="#34C759" />
|
|
</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: 12,
|
|
borderLeftWidth: 0,
|
|
shadowColor: '#000',
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 2,
|
|
},
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 6,
|
|
elevation: 4,
|
|
},
|
|
eventContent: {
|
|
flex: 1,
|
|
padding: 12,
|
|
justifyContent: 'space-between',
|
|
},
|
|
eventHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 8,
|
|
},
|
|
eventTitle: {
|
|
fontSize: 14,
|
|
fontWeight: '700',
|
|
lineHeight: 18,
|
|
flex: 1,
|
|
},
|
|
categoryTag: {
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
borderRadius: 12,
|
|
},
|
|
categoryText: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
},
|
|
eventFooter: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
timeContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
eventTime: {
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
marginLeft: 6,
|
|
color: '#8E8E93',
|
|
},
|
|
iconContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
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,
|
|
},
|
|
});
|