399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
import React, { useState, useCallback, useEffect } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
Alert,
|
|
FlatList,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
|
import { theme } from '../theme';
|
|
import { Event } from '../types';
|
|
import { eventApi } from '../api';
|
|
import EventModal from '../components/EventModal';
|
|
import EventCard from '../components/EventCard';
|
|
import { shouldShowEventOnDate, safeParseDate } from '../utils/recurrence';
|
|
|
|
const showError = (title: string, error: any) => {
|
|
const message = error instanceof Error ? error.message : '加载事件失败';
|
|
Alert.alert(title, message);
|
|
};
|
|
|
|
export default function CalendarScreen() {
|
|
const navigation = useNavigation();
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [events, setEvents] = useState<Event[]>([]);
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
|
|
|
const loadEvents = useCallback(async () => {
|
|
try {
|
|
const allEvents: Event[] = [];
|
|
const seenIds = new Set<string>();
|
|
|
|
// 加载前 6 个月 + 当前月 + 后 1 个月的数据,确保覆盖所有持续事件
|
|
for (let i = -6; i <= 1; i++) {
|
|
let targetDate = new Date(currentDate);
|
|
targetDate.setMonth(targetDate.getMonth() + i);
|
|
const year = targetDate.getFullYear();
|
|
const month = targetDate.getMonth() + 1;
|
|
|
|
const monthEvents = await eventApi.getEvents(year, month);
|
|
for (const event of monthEvents) {
|
|
if (!seenIds.has(event.id)) {
|
|
seenIds.add(event.id);
|
|
allEvents.push(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
setEvents(allEvents.filter(e => !e.deleted));
|
|
} catch (error) {
|
|
showError('加载失败', error);
|
|
}
|
|
}, [currentDate]);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
loadEvents();
|
|
}, [loadEvents])
|
|
);
|
|
|
|
const getDaysInMonth = (date: Date) => {
|
|
const year = date.getFullYear();
|
|
const month = date.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const days = [];
|
|
|
|
// 添加上个月的日期
|
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
|
for (let i = firstDay.getDay() - 1; i >= 0; i--) {
|
|
const prevMonthDay = new Date(year, month - 1, prevMonthLastDay - i);
|
|
days.push({ date: prevMonthDay, isCurrentMonth: false });
|
|
}
|
|
|
|
// 添加当前月的日期
|
|
for (let i = 1; i <= lastDay.getDate(); i++) {
|
|
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
|
|
}
|
|
|
|
// 添加下个月的日期
|
|
const remainingDays = 42 - days.length;
|
|
for (let i = 1; i <= remainingDays; i++) {
|
|
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
|
|
}
|
|
|
|
return days;
|
|
};
|
|
|
|
const hasEventOnDate = (date: Date) => {
|
|
return events.some(event => {
|
|
if (event.deleted) return false;
|
|
return shouldShowEventOnDate(event, date);
|
|
});
|
|
};
|
|
|
|
const isToday = (date: Date) => {
|
|
const today = new Date();
|
|
return date.toDateString() === today.toDateString();
|
|
};
|
|
|
|
const isSelected = (date: Date) => {
|
|
if (!selectedDate) return false;
|
|
return date.toDateString() === selectedDate.toDateString();
|
|
};
|
|
|
|
const getEventsForSelectedDate = () => {
|
|
if (!selectedDate) return [];
|
|
return events
|
|
.filter(event => {
|
|
if (event.deleted) return false;
|
|
return shouldShowEventOnDate(event, selectedDate);
|
|
})
|
|
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
|
};
|
|
|
|
const previousMonth = () => {
|
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
|
setSelectedDate(null);
|
|
};
|
|
|
|
const nextMonth = () => {
|
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
|
setSelectedDate(null);
|
|
};
|
|
|
|
const handleAddEvent = () => {
|
|
setSelectedEvent(null);
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleEditEvent = (event: Event) => {
|
|
setSelectedEvent(event);
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleToggleComplete = async (event: Event) => {
|
|
try {
|
|
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
|
await loadEvents();
|
|
} catch (error) {
|
|
showError('更新失败', error);
|
|
}
|
|
};
|
|
|
|
const handleDeleteEvent = async (event: Event) => {
|
|
Alert.alert(
|
|
'确认删除',
|
|
'确定要删除这个事件吗?',
|
|
[
|
|
{ text: '取消', style: 'cancel' },
|
|
{
|
|
text: '删除',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
try {
|
|
await eventApi.deleteEvent(event.id);
|
|
await loadEvents();
|
|
} catch (error) {
|
|
showError('删除失败', error);
|
|
}
|
|
},
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
|
try {
|
|
if (selectedEvent) {
|
|
await eventApi.updateEvent(selectedEvent.id, eventData);
|
|
} else {
|
|
await eventApi.createEvent({
|
|
...eventData,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
deleted: false,
|
|
} as Event);
|
|
}
|
|
setModalVisible(false);
|
|
await loadEvents();
|
|
} catch (error) {
|
|
showError('保存失败', error);
|
|
}
|
|
};
|
|
|
|
const days = getDaysInMonth(currentDate);
|
|
const selectedDateEvents = getEventsForSelectedDate();
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.header}>
|
|
<TouchableOpacity onPress={previousMonth}>
|
|
<Ionicons name="chevron-back" size={28} color={theme.colors.text} />
|
|
</TouchableOpacity>
|
|
<Text style={styles.monthTitle}>
|
|
{currentDate.toLocaleDateString('zh-CN', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
})}
|
|
</Text>
|
|
<TouchableOpacity onPress={nextMonth}>
|
|
<Ionicons name="chevron-forward" size={28} color={theme.colors.text} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.weekdays}>
|
|
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
|
|
<Text key={index} style={styles.weekday}>
|
|
{day}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
|
|
<View style={styles.calendarGrid}>
|
|
{days.map(({ date, isCurrentMonth }, index) => (
|
|
<TouchableOpacity
|
|
key={index}
|
|
style={[
|
|
styles.dayCell,
|
|
isSelected(date) && styles.selectedDay,
|
|
isToday(date) && styles.today,
|
|
]}
|
|
onPress={() => setSelectedDate(date)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.dayText,
|
|
!isCurrentMonth && styles.otherMonthText,
|
|
isSelected(date) && styles.selectedDayText,
|
|
isToday(date) && styles.todayText,
|
|
]}
|
|
>
|
|
{date.getDate()}
|
|
</Text>
|
|
{hasEventOnDate(date) && <View style={styles.eventDot} />}
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
{selectedDate && (
|
|
<>
|
|
<View style={styles.eventsHeader}>
|
|
<Text style={styles.eventsHeaderTitle}>
|
|
{selectedDate.toLocaleDateString('zh-CN', {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
weekday: 'long',
|
|
})}
|
|
</Text>
|
|
<TouchableOpacity onPress={handleAddEvent}>
|
|
<Ionicons name="add-circle" size={28} color={theme.colors.primary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={selectedDateEvents}
|
|
keyExtractor={(item) => item.id}
|
|
style={styles.eventsListContainer}
|
|
renderItem={({ item }) => (
|
|
<EventCard
|
|
event={item}
|
|
onEdit={handleEditEvent}
|
|
onToggleComplete={handleToggleComplete}
|
|
onDelete={handleDeleteEvent}
|
|
/>
|
|
)}
|
|
contentContainerStyle={styles.eventsList}
|
|
showsVerticalScrollIndicator={false}
|
|
ListEmptyComponent={
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyText}>这一天没有事件</Text>
|
|
</View>
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{!selectedDate && (
|
|
<View style={styles.noDateSelected}>
|
|
<Text style={styles.noDateText}>点击日期查看事项</Text>
|
|
</View>
|
|
)}
|
|
|
|
<EventModal
|
|
visible={modalVisible}
|
|
event={selectedEvent}
|
|
onClose={() => setModalVisible(false)}
|
|
onSave={handleSaveEvent}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.background,
|
|
padding: theme.spacing.md,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: theme.spacing.lg,
|
|
},
|
|
monthTitle: {
|
|
...theme.typography.h2,
|
|
color: theme.colors.text,
|
|
},
|
|
weekdays: {
|
|
flexDirection: 'row',
|
|
marginBottom: theme.spacing.sm,
|
|
},
|
|
weekday: {
|
|
flex: 1,
|
|
textAlign: 'center',
|
|
...theme.typography.caption,
|
|
color: theme.colors.textSecondary,
|
|
},
|
|
calendarGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
marginBottom: theme.spacing.sm,
|
|
},
|
|
dayCell: {
|
|
width: '14.28%',
|
|
aspectRatio: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
borderRadius: theme.borderRadius.md,
|
|
},
|
|
selectedDay: {
|
|
backgroundColor: theme.colors.surface,
|
|
borderWidth: 2,
|
|
borderColor: theme.colors.primary,
|
|
},
|
|
today: {
|
|
backgroundColor: theme.colors.surface,
|
|
},
|
|
dayText: {
|
|
...theme.typography.body,
|
|
color: theme.colors.text,
|
|
},
|
|
otherMonthText: {
|
|
color: theme.colors.textSecondary,
|
|
},
|
|
selectedDayText: {
|
|
color: theme.colors.primary,
|
|
fontWeight: 'bold',
|
|
},
|
|
todayText: {
|
|
color: theme.colors.primary,
|
|
},
|
|
eventDot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: theme.colors.primary,
|
|
marginTop: 4,
|
|
},
|
|
eventsHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: theme.spacing.sm,
|
|
},
|
|
eventsHeaderTitle: {
|
|
...theme.typography.h3,
|
|
color: theme.colors.text,
|
|
},
|
|
eventsListContainer: {
|
|
flex: 1,
|
|
},
|
|
eventsList: {
|
|
paddingBottom: 20,
|
|
},
|
|
emptyContainer: {
|
|
padding: theme.spacing.xl,
|
|
alignItems: 'center',
|
|
},
|
|
emptyText: {
|
|
...theme.typography.body,
|
|
color: theme.colors.textSecondary,
|
|
},
|
|
noDateSelected: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
noDateText: {
|
|
...theme.typography.body,
|
|
color: theme.colors.textSecondary,
|
|
},
|
|
});
|