Updated calendar to have CRUD functionality (mostly).

This commit is contained in:
c-d-p
2025-04-18 19:30:02 +02:00
parent 8d884111fd
commit ee86374da6
15 changed files with 700 additions and 21 deletions

View File

@@ -1,8 +1,9 @@
// src/screens/CalendarScreen.tsx
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { View, StyleSheet, FlatList } from 'react-native';
import { Calendar, DateData, LocaleConfig, CalendarProps, MarkingProps } from 'react-native-calendars'; // Import MarkingProps
import { Text, useTheme, ActivityIndicator, List, Divider } from 'react-native-paper';
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native'; // Import TouchableOpacity
import { Calendar, DateData, LocaleConfig, CalendarProps, MarkingProps } from 'react-native-calendars';
import { Text, useTheme, ActivityIndicator, List, Divider, FAB } from 'react-native-paper'; // Import FAB
import { useNavigation } from '@react-navigation/native'; // Import useNavigation
import {
format,
parseISO,
@@ -17,14 +18,20 @@ import {
import { getCalendarEvents } from '../api/calendar';
import { CalendarEvent } from '../types/calendar'; // Use updated type
import { AppStackParamList } from '../navigation/AppNavigator'; // Import navigation param list type
import { StackNavigationProp } from '@react-navigation/stack'; // Import StackNavigationProp
// Optional: Configure locale
// LocaleConfig.locales['en'] = { ... }; LocaleConfig.defaultLocale = 'en';
// Define navigation prop type
type CalendarScreenNavigationProp = StackNavigationProp<AppStackParamList, 'Calendar'>; // Adjust 'Calendar' if your route name is different
const getTodayDateString = () => format(new Date(), 'yyyy-MM-dd');
const CalendarScreen = () => {
const theme = useTheme();
const navigation = useNavigation<CalendarScreenNavigationProp>(); // Use the hook with the correct type
const todayString = useMemo(getTodayDateString, []);
const [selectedDate, setSelectedDate] = useState<string>(todayString);
@@ -315,15 +322,17 @@ const CalendarScreen = () => {
}
return (
<List.Item
title={item.title}
description={description}
left={props => <List.Icon {...props} icon="circle-slice-8" color={item.color || theme.colors.primary} />} // Use a filled circle or similar
style={styles.eventItem}
titleStyle={{ color: theme.colors.text }}
descriptionStyle={{ color: theme.colors.textSecondary }}
descriptionNumberOfLines={3}
/>
<TouchableOpacity onPress={() => navigation.navigate('EventForm', { eventId: item.id })}>
<List.Item
title={item.title}
description={description}
left={props => <List.Icon {...props} icon="circle-slice-8" color={item.color || theme.colors.primary} />}
style={styles.eventItem}
titleStyle={{ color: theme.colors.text }}
descriptionStyle={{ color: theme.colors.textSecondary }}
descriptionNumberOfLines={3}
/>
</TouchableOpacity>
);
}
@@ -337,6 +346,13 @@ const CalendarScreen = () => {
eventItem: { backgroundColor: theme.colors.surface, marginBottom: 8, borderRadius: theme.roundness },
noEventsText: { textAlign: 'center', marginTop: 20, color: theme.colors.textSecondary, fontSize: 16 },
errorText: { textAlign: 'center', marginTop: 20, color: theme.colors.error, fontSize: 16 },
fab: { // Style for the FAB
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: theme.colors.primary, // Use theme color
},
});
// --- Calendar Theme ---
@@ -407,6 +423,14 @@ const CalendarScreen = () => {
)}
</View>
)}
{/* Add FAB for creating new events */}
<FAB
style={styles.fab}
icon="plus"
onPress={() => navigation.navigate('EventForm')} // Navigate without eventId for creation
color={theme.colors.onPrimary || '#ffffff'} // Ensure icon color contrasts with background
/>
</View>
);
};

View File

@@ -0,0 +1,519 @@
// src/screens/EventFormScreen.tsx
import React, { useState, useEffect, useCallback } from 'react';
// Add Platform import
import { View, StyleSheet, ScrollView, Alert, TouchableOpacity, Platform } from 'react-native';
import { TextInput, Button, useTheme, Text, ActivityIndicator, HelperText } from 'react-native-paper';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
// Conditionally import DateTimePickerModal only if not on web
// Note: This dynamic import might not work as expected depending on the bundler setup.
// A more robust approach might involve platform-specific files or checking Platform.OS before rendering.
// For now, we'll check Platform.OS before using it.
import DateTimePickerModal from "react-native-modal-datetime-picker";
import { format, parseISO, isValid, parse } from 'date-fns'; // Added parse
import { getCalendarEventById, createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from '../api/calendar';
// Import the new types
import { CalendarEventCreate, CalendarEventUpdate } from '../types/calendar';
import { WebContentStackParamList } from '../types/navigation'; // Adjust path if needed
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
type EventFormRouteProp = RouteProp<WebContentStackParamList, 'EventForm'>;
type EventFormNavigationProp = NativeStackNavigationProp<WebContentStackParamList, 'EventForm'>;
const EventFormScreen = () => {
const theme = useTheme();
const navigation = useNavigation<EventFormNavigationProp>();
const route = useRoute<EventFormRouteProp>();
const eventId = route.params?.eventId; // Get eventId from navigation params
const selectedDate = route.params?.selectedDate; // Get pre-selected date
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [color, setColor] = useState(''); // Basic color input for now
const [location, setLocation] = useState(''); // Add location state
// Add state for raw web date input
const [webStartDateInput, setWebStartDateInput] = useState<string>('');
const [webEndDateInput, setWebEndDateInput] = useState<string>('');
const [isStartDatePickerVisible, setStartDatePickerVisibility] = useState(false);
const [isEndDatePickerVisible, setEndDatePickerVisibility] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formErrors, setFormErrors] = useState<{ title?: string; start?: string; end?: string }>({});
// --- Fetch Event Data on Edit ---
useEffect(() => {
if (eventId) {
const fetchEvent = async () => {
setIsLoading(true);
setError(null);
try {
const event = await getCalendarEventById(eventId);
setTitle(event.title);
setDescription(event.description || '');
setColor(event.color || ''); // Use optional color
setLocation(event.location || ''); // Set location state
// Ensure dates are Date objects
if (event.start && isValid(parseISO(event.start))) {
const parsedDate = parseISO(event.start);
setStartDate(parsedDate);
setWebStartDateInput(formatForWebInput(parsedDate)); // Init web input
} else {
console.warn("Fetched event has invalid start date:", event.start);
const now = new Date();
setStartDate(now);
setWebStartDateInput(formatForWebInput(now)); // Init web input
}
if (event.end && isValid(parseISO(event.end))) {
const parsedDate = parseISO(event.end);
setEndDate(parsedDate);
setWebEndDateInput(formatForWebInput(parsedDate)); // Init web input
} else {
setEndDate(null);
setWebEndDateInput(''); // Init web input
}
} catch (err) {
setError('Failed to load event details.');
console.error(err);
} finally {
setIsLoading(false);
}
};
fetchEvent();
} else if (selectedDate) {
// Pre-fill start date if creating from a specific day
try {
const initialDate = parseISO(selectedDate);
if (isValid(initialDate)) {
// Set start time to a default (e.g., 9 AM) on the selected date
initialDate.setHours(9, 0, 0, 0);
setStartDate(initialDate);
setWebStartDateInput(formatForWebInput(initialDate)); // Init web input
setEndDate(null); // Ensure end date starts null
setWebEndDateInput('');
} else {
const now = new Date();
setStartDate(now);
setWebStartDateInput(formatForWebInput(now)); // Init web input
setEndDate(null);
setWebEndDateInput('');
}
} catch {
const now = new Date();
setStartDate(now);
setWebStartDateInput(formatForWebInput(now)); // Init web input
setEndDate(null);
setWebEndDateInput('');
}
} else {
// Default start date to now if creating without a selected date
const now = new Date();
setStartDate(now);
setWebStartDateInput(formatForWebInput(now)); // Init web input
setEndDate(null);
setWebEndDateInput('');
}
}, [eventId, selectedDate]);
// --- Date Picker Logic ---
const showStartDatePicker = () => {
if (Platform.OS !== 'web') {
setStartDatePickerVisibility(true);
}
// On web, clicking the input field allows manual editing
};
const hideStartDatePicker = () => setStartDatePickerVisibility(false);
const handleStartDateConfirm = (date: Date) => {
setStartDate(date);
setWebStartDateInput(formatForWebInput(date)); // Update web input state
// Optional: Auto-set end date if it's before start date or null
if (!endDate || endDate < date) {
const newEndDate = new Date(date);
newEndDate.setHours(date.getHours() + 1); // Default to 1 hour later
setEndDate(newEndDate);
setWebEndDateInput(formatForWebInput(newEndDate)); // Update web input state
}
validateForm({ start: date }); // Validate after setting
hideStartDatePicker();
};
const showEndDatePicker = () => {
if (Platform.OS !== 'web') {
setEndDatePickerVisibility(true);
}
// On web, clicking the input field allows manual editing
};
const hideEndDatePicker = () => setEndDatePickerVisibility(false);
const handleEndDateConfirm = (date: Date) => {
setEndDate(date);
setWebEndDateInput(formatForWebInput(date)); // Update web input state
validateForm({ end: date }); // Validate after setting
hideEndDatePicker();
};
// --- Web Date Input Handling ---
const handleWebDateInputChange = (text: string, type: 'start' | 'end') => {
// 1. Update the raw input state immediately
if (type === 'start') {
setWebStartDateInput(text);
} else {
setWebEndDateInput(text);
}
// 2. Attempt to parse the input text
// Allow partial input (e.g., "yyyy-MM-dd") for validation purposes later if needed,
// but only update the Date state if the format is fully matched.
try {
// Use a reference date (like 'new Date()') for parsing robustness
const parsedDate = parse(text, 'yyyy-MM-dd HH:mm', new Date());
// 3. Check if the parsed date is valid *and* the input string roughly matches the expected format length
// This prevents updating the Date state too early with partial input like "202"
if (isValid(parsedDate) && text.length >= 15) { // Basic length check for 'yyyy-MM-dd HH:mm'
if (type === 'start') {
setStartDate(parsedDate);
// Optional: Auto-set end date
if (!endDate || endDate < parsedDate) {
const newEndDate = new Date(parsedDate);
newEndDate.setHours(parsedDate.getHours() + 1);
setEndDate(newEndDate);
setWebEndDateInput(formatForWebInput(newEndDate)); // Update other web input too
}
validateForm({ start: parsedDate }); // Validate with the actual Date
} else {
setEndDate(parsedDate);
validateForm({ end: parsedDate }); // Validate with the actual Date
}
} else {
// Input is incomplete or invalid format, don't update Date state yet.
// Validation should reflect that the required Date state might be missing or invalid.
if (type === 'start') {
// If text is empty, treat as null for validation. If partially filled,
// it's technically invalid until complete. Keep startDate as is for now.
validateForm({ start: text.trim() === '' ? null : startDate });
} else {
validateForm({ end: text.trim() === '' ? null : endDate });
}
}
} catch (e) {
// Parsing likely failed due to highly invalid format
console.error("Error parsing date string:", e);
if (type === 'start') {
validateForm({ start: null }); // Treat as invalid for validation
} else {
validateForm({ end: null }); // Treat as invalid for validation
}
}
};
// --- Form Validation ---
const validateForm = (changedValues: { title?: string; start?: Date | null; end?: Date | null } = {}): boolean => {
const errors: { title?: string; start?: string; end?: string } = { ...formErrors }; // Start with existing errors
let isValidForm = true;
const currentTitle = changedValues.title !== undefined ? changedValues.title : title;
const currentStart = changedValues.start !== undefined ? changedValues.start : startDate;
const currentEnd = changedValues.end !== undefined ? changedValues.end : endDate;
// Validate Title
if (currentTitle.trim() === '') {
errors.title = 'Title is required.';
isValidForm = false;
} else {
delete errors.title; // Clear error if valid
}
// Validate Start Date
if (!currentStart) {
errors.start = 'Start date/time is required.';
isValidForm = false;
} else {
delete errors.start; // Clear error if valid
}
// Validate End Date (must be after start date if provided)
if (currentStart && currentEnd && currentEnd < currentStart) {
errors.end = 'End time cannot be before start time.';
isValidForm = false;
} else {
delete errors.end; // Clear error if valid
}
setFormErrors(errors);
return isValidForm;
};
// --- Save Event ---
const handleSave = async () => {
if (!validateForm({ title, start: startDate, end: endDate })) {
setError("Please fix the errors in the form.");
return;
}
if (!startDate) { // Should be caught by validation, but double-check
setError("Start date is missing.");
return;
}
setIsLoading(true);
setError(null);
// Construct data based on CalendarEventCreate/Update types
const eventData: CalendarEventCreate | CalendarEventUpdate = {
title: title.trim(),
description: description.trim() || null,
start: startDate.toISOString(),
end: endDate ? endDate.toISOString() : null,
location: location.trim() || null, // Include location
color: color.trim() || null, // Include color
};
try {
if (eventId) {
await updateCalendarEvent(eventId, eventData as CalendarEventUpdate);
} else {
await createCalendarEvent(eventData as CalendarEventCreate);
}
navigation.goBack(); // Go back to calendar screen on success
} catch (err) {
setError(eventId ? 'Failed to update event.' : 'Failed to create event.');
console.error(err);
} finally {
setIsLoading(false);
}
};
// --- Delete Event ---
const handleDelete = () => {
if (!eventId) return;
Alert.alert(
"Confirm Deletion",
"Are you sure you want to delete this event?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
setIsDeleting(true);
setError(null);
try {
await deleteCalendarEvent(eventId);
navigation.goBack(); // Go back on successful deletion
} catch (err) {
setError('Failed to delete event.');
console.error(err);
} finally {
setIsDeleting(false);
}
},
},
]
);
};
// --- Format Date/Time for Display ---
// Keep this for native display or potentially other uses
const formatDateTime = (date: Date | null): string => {
if (!date) return '';
try {
// Native uses 'MMM d, yyyy, p', web input is handled separately
return format(date, "MMM d, yyyy, p");
} catch {
return "Invalid Date";
}
};
// Helper to format date specifically for web input state
const formatForWebInput = (date: Date | null): string => {
if (!date || !isValid(date)) return '';
try {
return format(date, "yyyy-MM-dd HH:mm");
} catch {
return ''; // Return empty if formatting fails
}
};
if (isLoading && !title) { // Show loading indicator only during initial fetch
return <ActivityIndicator animating={true} style={styles.loading} />;
}
return (
<ScrollView style={[styles.container, { backgroundColor: theme.colors.background }]}>
<View style={styles.content}>
<Text style={styles.header}>{eventId ? 'Edit Event' : 'Create Event'}</Text>
{error && <Text style={styles.errorText}>{error}</Text>}
<TextInput
label="Title *"
value={title}
onChangeText={(text) => { setTitle(text); validateForm({ title: text }); }}
mode="outlined"
style={styles.input}
error={!!formErrors.title}
/>
<HelperText type="error" visible={!!formErrors.title}>
{formErrors.title}
</HelperText>
{/* Start Date Input - Conditional Logic */}
<TouchableOpacity onPress={showStartDatePicker} disabled={Platform.OS === 'web'}>
<TextInput
label="Start Date & Time *"
// Use raw web input state for value on web, formatted Date otherwise
value={Platform.OS === 'web' ? webStartDateInput : formatDateTime(startDate)}
editable={Platform.OS === 'web'}
onChangeText={Platform.OS === 'web' ? (text) => handleWebDateInputChange(text, 'start') : undefined}
placeholder={Platform.OS === 'web' ? 'YYYY-MM-DD HH:mm' : ''}
mode="outlined"
style={styles.input}
right={Platform.OS !== 'web' ? <TextInput.Icon icon="calendar-clock" onPress={showStartDatePicker} /> : null}
error={!!formErrors.start}
/>
</TouchableOpacity>
<HelperText type="error" visible={!!formErrors.start}>
{formErrors.start}
</HelperText>
{/* End Date Input - Conditional Logic */}
<TouchableOpacity onPress={showEndDatePicker} disabled={Platform.OS === 'web'}>
<TextInput
label="End Date & Time"
// Use raw web input state for value on web, formatted Date otherwise
value={Platform.OS === 'web' ? webEndDateInput : formatDateTime(endDate)}
editable={Platform.OS === 'web'}
onChangeText={Platform.OS === 'web' ? (text) => handleWebDateInputChange(text, 'end') : undefined}
placeholder={Platform.OS === 'web' ? 'YYYY-MM-DD HH:mm' : ''}
mode="outlined"
style={styles.input}
right={Platform.OS !== 'web' ? <TextInput.Icon icon="calendar-clock" onPress={showEndDatePicker} /> : null}
error={!!formErrors.end}
/>
</TouchableOpacity>
<HelperText type="error" visible={!!formErrors.end}>
{formErrors.end}
</HelperText>
{/* Add Location Input */}
<TextInput
label="Location"
value={location}
onChangeText={setLocation}
mode="outlined"
style={styles.input}
/>
<TextInput
label="Description"
value={description}
onChangeText={setDescription}
mode="outlined"
style={styles.input}
multiline
numberOfLines={3}
/>
<TextInput
label="Color (e.g., #ff0000)" // Simple text input for color for now
value={color}
onChangeText={setColor}
mode="outlined"
style={styles.input}
placeholder={theme.colors.primary} // Show default color hint
/>
<Button
mode="contained"
onPress={handleSave}
style={styles.button}
loading={isLoading && !!title} // Show loading on button during save/update
disabled={isLoading || isDeleting}
>
{eventId ? 'Update Event' : 'Create Event'}
</Button>
{eventId && (
<Button
mode="outlined"
onPress={handleDelete}
style={styles.button}
textColor={theme.colors.error}
loading={isDeleting}
disabled={isLoading || isDeleting}
>
Delete Event
</Button>
)}
{/* Conditionally render DateTimePickerModal only on native platforms */}
{Platform.OS !== 'web' && (
<>
<DateTimePickerModal
isVisible={isStartDatePickerVisible}
mode="datetime"
date={startDate || new Date()} // Default picker to current start date or now
onConfirm={handleStartDateConfirm}
onCancel={hideStartDatePicker}
is24Hour={false} // Adjust based on preference
/>
<DateTimePickerModal
isVisible={isEndDatePickerVisible}
mode="datetime"
date={endDate || startDate || new Date()} // Default picker to end, start, or now
minimumDate={startDate || undefined} // Prevent end date being before start date
onConfirm={handleEndDateConfirm}
onCancel={hideEndDatePicker}
is24Hour={false}
/>
</>
)}
</View>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 20,
},
header: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
input: {
marginBottom: 5, // Reduced margin as HelperText adds space
},
button: {
marginTop: 15,
},
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
color: 'red', // Use theme.colors.error in practice
textAlign: 'center',
marginBottom: 10,
},
});
export default EventFormScreen;