[V0.2] WORKING Working calendar and AI with full frontend.

This commit is contained in:
c-d-p
2025-04-20 12:12:35 +02:00
parent ee86374da6
commit 6cee996fb3
27 changed files with 996 additions and 488 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/datetimepicker": "8.2.0",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
@@ -3037,10 +3038,9 @@
}
},
"node_modules/@react-native-community/datetimepicker": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.3.0.tgz",
"integrity": "sha512-K/KgaJbLtjMpx4PaG4efrVIcSe6+DbLufeX1lwPB5YY8i3sq9dOh6WcAcMTLbaRTUpurebQTkl7puHPFm9GalA==",
"peer": true,
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.2.0.tgz",
"integrity": "sha512-qrUPhiBvKGuG9Y+vOqsc56RPFcHa1SU2qbAMT0hfGkoFIj3FodE0VuPVrEa8fgy7kcD5NQmkZIKgHOBLV0+hWg==",
"dependencies": {
"invariant": "^2.2.4"
},

View File

@@ -30,7 +30,8 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-vector-icons": "^10.2.0",
"react-native-web": "~0.19.13"
"react-native-web": "~0.19.13",
"@react-native-community/datetimepicker": "8.2.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View File

@@ -5,7 +5,8 @@ import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8000/api';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://192.168.1.9:8000/api'; // Use your machine's IP
// const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8000/api'; // Use your machine's IP
const TOKEN_KEY = 'maia_access_token';
console.log("Using API Base URL:", API_BASE_URL);

View File

@@ -0,0 +1,63 @@
// src/components/calendar/CalendarDayCell.tsx
import React from 'react';
import { View, StyleSheet, Dimensions, ScrollView } from 'react-native';
import { Text, useTheme } from 'react-native-paper';
import { format, isToday } from 'date-fns';
import { CalendarEvent } from '../../types/calendar';
import EventItem from './EventItem';
interface CalendarDayCellProps {
date: Date;
events: CalendarEvent[];
isCurrentMonth?: boolean; // Optional, mainly for month view styling
width?: number; // Optional fixed width
}
const CalendarDayCell: React.FC<CalendarDayCellProps> = ({ date, events, isCurrentMonth = true, height, width }) => {
const theme = useTheme();
const today = isToday(date);
const styles = StyleSheet.create({
cell: {
flex: width ? undefined : 1, // Use flex=1 if no width is provided
width: width,
height: height,
borderWidth: 0.5,
borderColor: theme.colors.outlineVariant,
padding: 2,
backgroundColor: isCurrentMonth ? theme.colors.surface : theme.colors.surfaceDisabled, // Dim non-month days
overflow: 'hidden', // Prevent events overflowing cell boundaries
},
dateNumberContainer: {
alignItems: 'center',
marginBottom: 2,
},
dateNumber: {
fontSize: 10,
fontWeight: today ? 'bold' : 'normal',
color: today ? theme.colors.primary : (isCurrentMonth ? theme.colors.onSurface : theme.colors.onSurfaceDisabled),
},
eventsContainer: {
flex: 1, // Take remaining space in the cell
},
});
return (
<View style={styles.cell}>
<View style={styles.dateNumberContainer}>
<Text style={styles.dateNumber}>{format(date, 'd')}</Text>
</View>
{/* Use ScrollView for month view where events might exceed fixed height */}
<ScrollView style={styles.eventsContainer} nestedScrollEnabled={true}>
{events
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()) // Sort events
.map(event => (
<EventItem key={event.id} event={event} showTime={false} /> // Don't show time in month cell
))}
</ScrollView>
</View>
);
};
export default React.memo(CalendarDayCell); // Memoize for performance

View File

@@ -0,0 +1,51 @@
// src/components/calendar/CalendarHeader.tsx
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Text, IconButton, useTheme } from 'react-native-paper';
interface CalendarHeaderProps {
currentRangeText: string;
onPrev: () => void;
onNext: () => void;
}
const CalendarHeader: React.FC<CalendarHeaderProps> = ({ currentRangeText, onPrev, onNext }) => {
const theme = useTheme();
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
borderBottomWidth: 1,
borderBottomColor: theme.colors.outlineVariant,
backgroundColor: theme.colors.surface, // Match background
},
title: {
fontSize: 18,
fontWeight: 'bold',
color: theme.colors.onSurface, // Use theme text color
},
});
return (
<View style={styles.container}>
<IconButton
icon="chevron-left"
onPress={onPrev}
size={24}
iconColor={theme.colors.primary} // Use theme color
/>
<Text style={styles.title}>{currentRangeText}</Text>
<IconButton
icon="chevron-right"
onPress={onNext}
size={24}
iconColor={theme.colors.primary} // Use theme color
/>
</View>
);
};
export default CalendarHeader;

View File

@@ -0,0 +1,170 @@
// src/components/calendar/CustomCalendarView.tsx
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { View, StyleSheet, ActivityIndicator } from 'react-native';
import { useTheme, Text } from 'react-native-paper';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths,
addWeeks, subWeeks, addDays, subDays, eachDayOfInterval, format, getMonth, getYear, isSameMonth,
parseISO, isValid, isSameDay, startOfDay, endOfDay
} from 'date-fns';
import CalendarHeader from './CalendarHeader';
import ViewSwitcher from './ViewSwitcher';
import MonthView from './MonthView';
import WeekView from './WeekView';
import ThreeDayView from './ThreeDayView';
import { getCalendarEvents } from '../../api/calendar';
import { CalendarEvent } from '../../types/calendar';
export type CalendarViewMode = 'month' | 'week' | '3day';
const CustomCalendarView = () => {
const theme = useTheme();
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
const [currentDate, setCurrentDate] = useState(startOfDay(new Date())); // Use start of day for consistency
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Calculate date range based on view mode and current date
const { startDate, endDate, displayRangeText } = useMemo(() => {
let start: Date, end: Date, text: string;
switch (viewMode) {
case 'week':
start = startOfWeek(currentDate, { weekStartsOn: 1 }); // Assuming week starts on Monday
end = endOfWeek(currentDate, { weekStartsOn: 1 });
text = `${format(start, 'MMM d')} - ${format(end, 'MMM d, yyyy')}`;
break;
case '3day':
start = currentDate;
end = addDays(currentDate, 2);
text = `${format(start, 'MMM d')} - ${format(end, 'MMM d, yyyy')}`;
break;
case 'month':
default:
start = startOfMonth(currentDate);
end = endOfMonth(currentDate);
text = format(currentDate, 'MMMM yyyy');
break;
}
// Ensure end date includes the full day for fetching
return { startDate: start, endDate: endOfDay(end), displayRangeText: text };
}, [viewMode, currentDate]);
// Fetch events when date range or view mode changes
const fetchEvents = useCallback(async () => {
setIsLoading(true);
setError(null);
console.log(`[CustomCalendar] Fetching events from ${startDate.toISOString()} to ${endDate.toISOString()}`);
try {
// Pass adjusted start/end dates for fetching
const fetchedEvents = await getCalendarEvents(startDate, endDate);
setEvents(fetchedEvents);
} catch (err) {
console.error("Error fetching calendar events:", err);
setError('Failed to load events.');
setEvents([]);
} finally {
setIsLoading(false);
}
}, [startDate, endDate]); // Depend on calculated start/end dates
useEffect(() => {
fetchEvents();
}, [fetchEvents]); // Re-run fetchEvents when it changes (due to date changes)
// Navigation handlers
const handlePrev = useCallback(() => {
switch (viewMode) {
case 'week': setCurrentDate(subWeeks(currentDate, 1)); break;
case '3day': setCurrentDate(subDays(currentDate, 3)); break;
case 'month':
default: setCurrentDate(subMonths(currentDate, 1)); break;
}
}, [viewMode, currentDate]);
const handleNext = useCallback(() => {
switch (viewMode) {
case 'week': setCurrentDate(addWeeks(currentDate, 1)); break;
case '3day': setCurrentDate(addDays(currentDate, 3)); break;
case 'month':
default: setCurrentDate(addMonths(currentDate, 1)); break;
}
}, [viewMode, currentDate]);
// Group events by date string for easier lookup in child components
const eventsByDate = useMemo(() => {
const grouped: { [key: string]: CalendarEvent[] } = {};
events.forEach(event => {
if (typeof event.start !== 'string') return; // Skip invalid
const start = parseISO(event.start);
if (!isValid(start)) return; // Skip invalid
const end = typeof event.end === 'string' && isValid(parseISO(event.end)) ? parseISO(event.end) : start;
const endForInterval = end < start ? start : end; // Ensure end >= start
const intervalDays = eachDayOfInterval({ start: startOfDay(start), end: startOfDay(endForInterval) });
intervalDays.forEach(day => {
const dateKey = format(day, 'yyyy-MM-dd');
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
// Avoid duplicates within the same day's list
if (!grouped[dateKey].some(e => e.id === event.id)) {
grouped[dateKey].push(event);
}
});
});
return grouped;
}, [events]);
const renderCalendarView = () => {
const viewProps = { startDate, endDate, eventsByDate };
switch (viewMode) {
case 'week': return <WeekView {...viewProps} />;
case '3day': return <ThreeDayView {...viewProps} />;
case 'month':
default: return <MonthView {...viewProps} />;
}
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.colors.background },
contentArea: {
flex: 1,
justifyContent: 'center',
},
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
errorText: { color: theme.colors.error, textAlign: 'center', padding: 10 },
viewContainer: { flex: 1 },
});
return (
<View style={styles.container}>
<CalendarHeader
currentRangeText={displayRangeText}
onPrev={handlePrev}
onNext={handleNext}
/>
<ViewSwitcher currentView={viewMode} onViewChange={setViewMode} />
<View style={styles.contentArea}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
) : error ? (
<Text style={styles.errorText}>{error}</Text>
) : (
<View style={styles.viewContainer}>
{renderCalendarView()}
</View>
)}
</View>
</View>
);
};
export default CustomCalendarView;

View File

@@ -0,0 +1,98 @@
// src/components/calendar/EventItem.tsx
import React from 'react';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { Text, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { format, parseISO, isValid } from 'date-fns';
import { CalendarEvent } from '../../types/calendar';
import { AppStackParamList } from '../../navigation/AppNavigator'; // Adjust path if needed
interface EventItemProps {
event: CalendarEvent;
showTime?: boolean; // Optional prop to show time
}
// Define navigation prop type for navigating to EventForm
type EventItemNavigationProp = StackNavigationProp<AppStackParamList, 'EventForm'>;
const EventItem: React.FC<EventItemProps> = ({ event, showTime = true }) => {
const theme = useTheme();
const navigation = useNavigation<EventItemNavigationProp>();
if (!event || typeof event.start !== 'string') {
return null; // Don't render if event or start date is invalid
}
const startDate = parseISO(event.start);
if (!isValid(startDate)) {
return null; // Don't render if start date is invalid
}
const eventColor = event.color || theme.colors.primary;
const styles = StyleSheet.create({
container: {
backgroundColor: eventColor,
borderRadius: 4,
paddingVertical: 2,
paddingHorizontal: 4,
marginBottom: 2, // Space between event items
overflow: 'hidden',
},
text: {
color: theme.colors.onPrimary, // Ensure text is readable on the background color
fontSize: 10,
fontWeight: '500',
},
timeText: {
fontSize: 9,
fontWeight: 'normal',
},
tagContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 1,
},
tag: {
backgroundColor: 'rgba(255, 255, 255, 0.3)', // Semi-transparent white
borderRadius: 3,
paddingHorizontal: 3,
paddingVertical: 1,
marginRight: 2,
marginBottom: 1,
},
tagText: {
color: theme.colors.onPrimary,
fontSize: 8,
},
});
const handlePress = () => {
navigation.navigate('EventForm', { eventId: event.id });
};
const timeString = showTime ? format(startDate, 'p') : ''; // Format time like 1:00 PM
return (
<TouchableOpacity onPress={handlePress} style={styles.container}>
<Text style={styles.text} numberOfLines={1} ellipsizeMode="tail">
{showTime && <Text style={styles.timeText}>{timeString} </Text>}
{event.title}
</Text>
{/* Optional: Display tags if they exist */}
{event.tags && event.tags.length > 0 && (
<View style={styles.tagContainer}>
{event.tags.map((tag, index) => (
<View key={index} style={styles.tag}>
<Text style={styles.tagText}>{tag}</Text>
</View>
))}
</View>
)}
</TouchableOpacity>
);
};
export default React.memo(EventItem); // Memoize for performance

View File

@@ -0,0 +1,104 @@
// src/components/calendar/MonthView.tsx
import React, { useMemo } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { Text, useTheme } from 'react-native-paper';
import {
eachDayOfInterval, endOfMonth, startOfMonth, format, getDay, isSameMonth,
startOfWeek, endOfWeek, addDays
} from 'date-fns';
import CalendarDayCell from './CalendarDayCell';
import { CalendarEvent } from '../../types/calendar';
interface MonthViewProps {
startDate: Date; // Should be the start of the month
endDate: Date; // Should be the end of the month
eventsByDate: { [key: string]: CalendarEvent[] };
}
const screenWidth = Dimensions.get('window').width;
const cellWidth = screenWidth / 7; // Approximate width for each day cell
const MonthView: React.FC<MonthViewProps> = ({ startDate, eventsByDate }) => {
const theme = useTheme();
const currentMonth = startDate; // The month being displayed
// Calculate the full range of days to display, including leading/trailing days from other months
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const displayStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Week starts Monday
const displayEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = useMemo(() => eachDayOfInterval({ start: displayStart, end: displayEnd }), [
displayStart,
displayEnd,
]);
// Generate week rows
const weeks: Date[][] = [];
let currentWeek: Date[] = [];
days.forEach((day, index) => {
currentWeek.push(day);
if ((index + 1) % 7 === 0) {
weeks.push(currentWeek);
currentWeek = [];
}
});
const weekDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const styles = StyleSheet.create({
container: { flex: 1 },
weekRow: {
flexDirection: 'row',
flex: 1,
},
dayHeaderRow: {
flexDirection: 'row',
paddingVertical: 5,
borderBottomWidth: 1,
borderBottomColor: theme.colors.outlineVariant,
backgroundColor: theme.colors.surfaceVariant, // Slightly different background for header
},
dayHeaderCell: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
dayHeaderText: {
fontSize: 10,
fontWeight: 'bold',
color: theme.colors.onSurfaceVariant,
},
});
return (
<View style={styles.container}>
<View style={styles.dayHeaderRow}>
{weekDays.map(day => (
<View key={day} style={styles.dayHeaderCell}>
<Text style={styles.dayHeaderText}>{day}</Text>
</View>
))}
</View>
{weeks.map((week, weekIndex) => (
<View key={`week-${weekIndex}`} style={styles.weekRow}>
{week.map(day => {
const dateKey = format(day, 'yyyy-MM-dd');
const dayEvents = eventsByDate[dateKey] || [];
return (
<CalendarDayCell
key={dateKey}
date={day}
events={dayEvents}
isCurrentMonth={isSameMonth(day, currentMonth)}
/>
);
})}
</View>
))}
</View>
);
};
export default MonthView;

View File

@@ -0,0 +1,115 @@
// src/components/calendar/ThreeDayView.tsx
import React, { useMemo } from 'react';
// Import Dimensions
import { View, StyleSheet, ScrollView, Dimensions } from 'react-native';
import { Text, useTheme } from 'react-native-paper';
import { eachDayOfInterval, format, isToday } from 'date-fns';
import { CalendarEvent } from '../../types/calendar';
import EventItem from './EventItem';
interface ThreeDayViewProps {
startDate: Date; // Start of the 3-day period
endDate: Date; // End of the 3-day period
eventsByDate: { [key: string]: CalendarEvent[] };
}
// Get screen width
const screenWidth = Dimensions.get('window').width;
const dayColumnWidth = screenWidth / 3; // Divide by 3 for 3-day view
const ThreeDayView: React.FC<ThreeDayViewProps> = ({ startDate, endDate, eventsByDate }) => {
const theme = useTheme();
// Ensure endDate is included in the interval
const days = useMemo(() => eachDayOfInterval({ start: startDate, end: endDate }), [
startDate,
endDate,
]);
// Ensure exactly 3 days are generated if interval logic is tricky
const displayDays = days.slice(0, 3);
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row', // Apply row direction directly to the container View
},
// Remove scrollViewContent style as it's no longer needed
// scrollViewContent: { flexDirection: 'row' },
dayColumn: {
width: dayColumnWidth,
borderRightWidth: 1,
borderRightColor: theme.colors.outlineVariant,
// Add flex: 1 to allow inner ScrollView to expand vertically
flex: 1,
},
lastDayColumn: {
borderRightWidth: 0,
},
dayHeader: {
paddingVertical: 8,
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: theme.colors.outlineVariant,
backgroundColor: theme.colors.surfaceVariant,
},
dayHeaderText: {
fontSize: 10,
fontWeight: 'bold',
color: theme.colors.onSurfaceVariant,
},
dayNumberText: {
fontSize: 12,
fontWeight: 'bold',
marginTop: 2,
color: theme.colors.onSurfaceVariant,
},
todayHeader: {
backgroundColor: theme.colors.primaryContainer,
},
todayHeaderText: {
color: theme.colors.onPrimaryContainer,
},
eventsContainer: {
// Remove flex: 1 here if dayColumn has flex: 1
padding: 4,
},
});
return (
// Change ScrollView to View
<View style={styles.container}>
{displayDays.map((day, index) => {
const dateKey = format(day, 'yyyy-MM-dd');
const dayEvents = eventsByDate[dateKey] || [];
const today = isToday(day);
const isLastColumn = index === displayDays.length - 1;
return (
<View
key={dateKey}
style={[styles.dayColumn, isLastColumn && styles.lastDayColumn]}
>
<View style={[styles.dayHeader, today && styles.todayHeader]}>
<Text style={[styles.dayHeaderText, today && styles.todayHeaderText]}>
{format(day, 'EEE')}
</Text>
<Text style={[styles.dayNumberText, today && styles.todayHeaderText]}>
{format(day, 'd')}
</Text>
</View>
{/* Keep inner ScrollView for vertical scrolling within the column */}
<ScrollView style={styles.eventsContainer}>
{dayEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
.map(event => (
<EventItem key={event.id} event={event} showTime={true} />
))}
</ScrollView>
</View>
);
})}
</View>
);
};
export default ThreeDayView;

View File

@@ -0,0 +1,41 @@
// src/components/calendar/ViewSwitcher.tsx
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { SegmentedButtons, useTheme } from 'react-native-paper';
import { CalendarViewMode } from './CustomCalendarView'; // Import the type
interface ViewSwitcherProps {
currentView: CalendarViewMode;
onViewChange: (view: CalendarViewMode) => void;
}
const ViewSwitcher: React.FC<ViewSwitcherProps> = ({ currentView, onViewChange }) => {
const theme = useTheme();
const styles = StyleSheet.create({
container: {
paddingVertical: 8,
paddingHorizontal: 16,
backgroundColor: theme.colors.surface, // Match background
borderBottomWidth: 1,
borderBottomColor: theme.colors.outlineVariant,
},
});
return (
<View style={styles.container}>
<SegmentedButtons
value={currentView}
onValueChange={(value) => onViewChange(value as CalendarViewMode)} // Cast value
buttons={[
{ value: 'month', label: 'Month' },
{ value: 'week', label: 'Week' },
{ value: '3day', label: '3-Day' },
]}
// Optional: Add density for smaller buttons
// density="medium"
/>
</View>
);
};
export default ViewSwitcher;

View File

@@ -0,0 +1,112 @@
// src/components/calendar/WeekView.tsx
import React, { useMemo } from 'react';
// Import Dimensions
import { View, StyleSheet, ScrollView, Dimensions } from 'react-native';
import { Text, useTheme } from 'react-native-paper';
import { eachDayOfInterval, format, isToday } from 'date-fns';
import CalendarDayCell from './CalendarDayCell';
import { CalendarEvent } from '../../types/calendar';
import EventItem from './EventItem'; // Import EventItem
interface WeekViewProps {
startDate: Date; // Start of the week
endDate: Date; // End of the week
eventsByDate: { [key: string]: CalendarEvent[] };
}
// Get screen width
const screenWidth = Dimensions.get('window').width;
const dayColumnWidth = screenWidth / 7; // Divide by 7 for week view
const WeekView: React.FC<WeekViewProps> = ({ startDate, endDate, eventsByDate }) => {
const theme = useTheme();
const days = useMemo(() => eachDayOfInterval({ start: startDate, end: endDate }), [
startDate,
endDate,
]);
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row', // Apply row direction directly to the container View
},
// Remove scrollViewContent style
// scrollViewContent: { flexDirection: 'row' },
dayColumn: {
width: dayColumnWidth,
borderRightWidth: 1,
borderRightColor: theme.colors.outlineVariant,
flex: 1, // Add flex: 1 to allow inner ScrollView to expand vertically
},
lastDayColumn: { // Add style for the last column
borderRightWidth: 0, // Remove border
},
dayHeader: {
paddingVertical: 8,
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: theme.colors.outlineVariant,
backgroundColor: theme.colors.surfaceVariant,
},
dayHeaderText: {
fontSize: 10,
fontWeight: 'bold',
color: theme.colors.onSurfaceVariant,
},
dayNumberText: {
fontSize: 12,
fontWeight: 'bold',
marginTop: 2,
color: theme.colors.onSurfaceVariant,
},
todayHeader: {
backgroundColor: theme.colors.primaryContainer,
},
todayHeaderText: {
color: theme.colors.onPrimaryContainer,
},
eventsContainer: {
// Remove flex: 1 here if dayColumn has flex: 1
padding: 4,
},
});
return (
// Change ScrollView to View
<View style={styles.container}>
{days.map((day, index) => { // Add index to map
const dateKey = format(day, 'yyyy-MM-dd');
const dayEvents = eventsByDate[dateKey] || [];
const today = isToday(day);
const isLastColumn = index === days.length - 1; // Check if it's the last column
return (
<View
key={dateKey}
// Apply conditional style to remove border on the last column
style={[styles.dayColumn, isLastColumn && styles.lastDayColumn]}
>
<View style={[styles.dayHeader, today && styles.todayHeader]}>
<Text style={[styles.dayHeaderText, today && styles.todayHeaderText]}>
{format(day, 'EEE')}
</Text>
<Text style={[styles.dayNumberText, today && styles.todayHeaderText]}>
{format(day, 'd')}
</Text>
</View>
{/* Keep inner ScrollView for vertical scrolling within the column */}
<ScrollView style={styles.eventsContainer}>
{dayEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
.map(event => (
<EventItem key={event.id} event={event} showTime={true} />
))}
</ScrollView>
</View>
);
})}
</View>
);
};
export default WeekView;

View File

@@ -1,435 +1,42 @@
// src/screens/CalendarScreen.tsx
import React, { useState, useEffect, useMemo, useCallback } from 'react';
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,
startOfMonth,
endOfMonth, // Need endOfMonth
getYear,
getMonth,
eachDayOfInterval, // Crucial for period marking
isSameDay, // Helper for comparisons
isValid, // Check if dates are valid
} from 'date-fns';
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { useTheme, FAB } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
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';
import CustomCalendarView from '../components/calendar/CustomCalendarView'; // Import the new custom view
import { AppStackParamList } from '../navigation/AppNavigator';
// 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');
type CalendarScreenNavigationProp = StackNavigationProp<AppStackParamList, 'Calendar'>;
const CalendarScreen = () => {
const theme = useTheme();
const navigation = useNavigation<CalendarScreenNavigationProp>(); // Use the hook with the correct type
const todayString = useMemo(getTodayDateString, []);
const navigation = useNavigation<CalendarScreenNavigationProp>();
const [selectedDate, setSelectedDate] = useState<string>(todayString);
const [currentMonthData, setCurrentMonthData] = useState<DateData | null>(null);
// Store events fetched from API *directly*
// We process them for marking and display separately
const [rawEvents, setRawEvents] = useState<CalendarEvent[]>([]);
// Store events keyed by date *for the list display*
const [eventsByDate, setEventsByDate] = useState<{ [key: string]: CalendarEvent[] }>({});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// --- Fetching Logic ---
const fetchEventsForMonth = useCallback(async (date: Date | DateData) => {
console.log("[CAM] fetchevents start");
setIsLoading(true);
setError(null);
const targetYear = 'year' in date ? date.year : getYear(date);
const targetMonth = 'month' in date ? date.month : getMonth(date) + 1;
if (isLoading && currentMonthData?.year === targetYear && currentMonthData?.month === targetMonth) {
return;
}
if ('dateString' in date) {
setCurrentMonthData(date);
} else {
// If called with Date, create approximate DateData
const dateObj = date instanceof Date ? date : new Date(date.timestamp);
setCurrentMonthData({
year: targetYear,
month: targetMonth,
dateString: format(dateObj, 'yyyy-MM-dd'),
day: dateObj.getDate(),
timestamp: dateObj.getTime(),
});
}
try {
console.log(`Fetching events potentially overlapping ${targetYear}-${targetMonth}`);
const fetchedEvents = await getCalendarEvents(targetYear, targetMonth);
setRawEvents(fetchedEvents); // Store the raw events for period marking
// Process events for the daily list view
const newEventsByDate: { [key: string]: CalendarEvent[] } = {};
fetchedEvents.forEach(event => {
// --- Check for valid START date string ---
if (typeof event.start !== 'string') {
console.warn(`Event ${event.id} has invalid start date type:`, event.start);
return; // Skip this event
}
// --- End check ---
const startDate = parseISO(event.start);
// --- Check if START date is valid after parsing ---
if (!isValid(startDate)) {
console.warn(`Invalid start date found in event ${event.id}:`, event.start);
return; // Skip invalid events
}
// --- Handle potentially null END date ---
// If end is null or not a string, treat it as the same as start date
const endDate = typeof event.end === 'string' && isValid(parseISO(event.end)) ? parseISO(event.end) : startDate;
// Ensure end date is not before start date (could happen with invalid data or timezone issues)
const endForInterval = endDate < startDate ? startDate : endDate;
const intervalDates = eachDayOfInterval({ start: startDate, end: endForInterval });
intervalDates.forEach(dayInInterval => {
const dateKey = format(dayInInterval, 'yyyy-MM-dd');
if (!newEventsByDate[dateKey]) {
newEventsByDate[dateKey] = [];
}
// Avoid duplicates if an event is already added for this key
if (!newEventsByDate[dateKey].some(e => e.id === event.id)) {
newEventsByDate[dateKey].push(event);
}
});
});
setEventsByDate(newEventsByDate); // Update state for list view
} catch (err) {
setError('Failed to load calendar events.');
setRawEvents([]); // Clear events on error
setEventsByDate({});
console.error(err);
} finally {
setIsLoading(false);
console.log("[CAM] isLoading:", isLoading);
}
}, [isLoading, currentMonthData]); // Include dependencies
// --- Initial Fetch ---
useEffect(() => {
const performInitialLoad = async () => {
console.log("[CalendarScreen] Performing initial load.");
setIsLoading(true);
setError(null);
const initialDate = parseISO(todayString);
const targetYear = getYear(initialDate);
const targetMonth = getMonth(initialDate) + 1;
setCurrentMonthData({
year: targetYear,
month: targetMonth,
dateString: todayString,
day: initialDate.getDate(),
timestamp: initialDate.getTime(),
});
try {
const fetchedEvents = await getCalendarEvents(targetYear, targetMonth);
const newEventsByDate: {[key: string]: CalendarEvent[] } = {};
fetchedEvents.forEach(event => {
// --- Check for valid START date string ---
if (typeof event.start !== 'string') {
console.warn(`Event ${event.id} has invalid start date type during initial load:`, event.start);
return; // Skip this event
}
// --- End check ---
const startDate = parseISO(event.start);
// --- Check if START date is valid after parsing ---
if (!isValid(startDate)) {
console.warn(`Invalid start date found in event ${event.id} during initial load:`, event.start);
return; // Skip invalid events
}
// --- Handle potentially null END date ---
// If end is null or not a string, treat it as the same as start date
const endDate = typeof event.end === 'string' && isValid(parseISO(event.end)) ? parseISO(event.end) : startDate;
// Ensure end date is not before start date
const endForInterval = endDate < startDate ? startDate : endDate;
const intervalDates = eachDayOfInterval({ start: startDate, end: endForInterval });
intervalDates.forEach(dayInInterval => {
const dateKey = format(dayInInterval, 'yyyy-MM-dd');
if (!newEventsByDate[dateKey]) {
newEventsByDate[dateKey] = [];
}
// Avoid duplicates if an event is already added for this key
if (!newEventsByDate[dateKey].some(e => e.id === event.id)) {
newEventsByDate[dateKey].push(event);
}
});
});
setRawEvents(fetchedEvents);
setEventsByDate(newEventsByDate);
} catch (err) {
setError('Failed to load initial calendar events.');
setRawEvents([]);
setEventsByDate({});
console.error(err);
} finally {
setIsLoading(false);
}
};
performInitialLoad();
}, [todayString]);
// --- Callbacks for Calendar ---
const onDayPress = useCallback((day: DateData) => {
setSelectedDate(day.dateString);
}, []);
const onMonthChange = useCallback((month: DateData) => {
if (!currentMonthData || month.year !== currentMonthData.year || month.month !== currentMonthData.month) {
console.log("[CAM] CAlling fetchevents");
fetchEventsForMonth(month);
} else {
setCurrentMonthData(month); // Just update the current data if same month
}
}, [fetchEventsForMonth, currentMonthData]);
// --- Calculate Marked Dates (Period Marking) ---
const markedDates = useMemo(() => {
const marks: { [key: string]: MarkingProps } = {}; // Use MarkingProps type
rawEvents.forEach(event => {
// --- Check for valid START date string ---
if (typeof event.start !== 'string') {
console.warn(`Event ${event.id} has invalid start date type in markedDates:`, event.start);
return; // Skip this event
}
// --- End check ---
const startDate = parseISO(event.start);
// --- Check if START date is valid after parsing ---
if (!isValid(startDate)) {
console.warn(`Invalid start date found for marking in event ${event.id}:`, event.start);
return; // Skip invalid events
}
// --- Handle potentially null END date ---
// If end is null or not a string, treat it as the same as start date
const endDate = typeof event.end === 'string' && isValid(parseISO(event.end)) ? parseISO(event.end) : startDate;
const eventColor = event.color || theme.colors.primary; // Use event color or default
// Ensure end date is not before start date
const endForInterval = endDate < startDate ? startDate : endDate;
const intervalDates = eachDayOfInterval({ start: startDate, end: endForInterval });
intervalDates.forEach((dateInInterval, index) => {
const dateString = format(dateInInterval, 'yyyy-MM-dd');
const isStartingDay = index === 0;
const isEndingDay = index === intervalDates.length - 1;
const marking: MarkingProps = {
color: eventColor,
textColor: theme.colors.onPrimary || '#ffffff', // Text color within the period mark
};
if (isStartingDay) {
marking.startingDay = true;
}
if (isEndingDay) {
marking.endingDay = true;
}
// Handle single-day events (both start and end)
if (intervalDates.length === 1) {
marking.startingDay = true;
marking.endingDay = true;
}
// Merge markings if multiple events overlap on the same day
marks[dateString] = {
...(marks[dateString] || {}), // Keep existing marks
...marking,
// Ensure start/end flags aren't overwritten by non-start/end marks
startingDay: marks[dateString]?.startingDay || marking.startingDay,
endingDay: marks[dateString]?.endingDay || marking.endingDay,
};
});
});
// Add selected day marking (merge with period marking)
if (selectedDate) {
marks[selectedDate] = {
...(marks[selectedDate] || {}),
selected: true,
color: marks[selectedDate]?.color || theme.colors.primary,
textColor: theme.colors.onPrimary || '#ffffff',
// If selected, don't let it look like starting/ending unless it truly is
startingDay: marks[selectedDate]?.startingDay && marks[selectedDate]?.selected,
endingDay: marks[selectedDate]?.endingDay && marks[selectedDate]?.selected,
};
}
marks[todayString] = {
...(marks[todayString] || {}),
dotColor: theme.colors.secondary,
};
return marks;
}, [rawEvents, selectedDate, theme.colors, theme.dark, todayString]); // Include theme.dark if colors change
// --- Render Event Item ---
const renderEventItem = ({ item }: { item: CalendarEvent }) => {
// --- Check for valid START date string ---
if (typeof item.start !== 'string') {
console.warn(`Event ${item.id} has invalid start date type for rendering:`, item.start);
return null; // Don't render item with invalid start date
}
const startDate = parseISO(item.start);
if (!isValid(startDate)) {
console.warn(`Invalid start date found for rendering event ${item.id}:`, item.start);
return null; // Don't render item with invalid start date
}
// --- Handle potentially null END date ---
const hasValidEndDate = typeof item.end === 'string' && isValid(parseISO(item.end));
const endDate = hasValidEndDate ? parseISO(item.end) : startDate;
let description = item.description || '';
const timePrefix = `Time: ${format(startDate, 'p')}`;
const dateRangePrefix = `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
// Show date range only if end date is valid and different from start date
if (hasValidEndDate && !isSameDay(startDate, endDate)) {
description = `${dateRangePrefix}${item.description ? `\n${item.description}` : ''}`;
} else {
// Otherwise, show start time
description = `${timePrefix}${item.description ? `\n${item.description}` : ''}`;
}
return (
<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>
);
}
// --- Styles ---
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.colors.background },
calendar: { /* ... */ },
loadingContainer: { height: 100, justifyContent: 'center', alignItems: 'center' },
eventListContainer: { flex: 1, paddingHorizontal: 16, paddingTop: 10 },
eventListHeader: { fontSize: 16, fontWeight: 'bold', color: theme.colors.text, marginBottom: 10, marginTop: 10 },
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
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: theme.colors.primary, // Use theme color
backgroundColor: theme.colors.primary,
},
});
// --- Calendar Theme ---
const calendarTheme: CalendarProps['theme'] = { // Use CalendarProps['theme'] for stricter typing
backgroundColor: theme.colors.background,
calendarBackground: theme.colors.surface,
textSectionTitleColor: theme.colors.primary,
selectedDayBackgroundColor: theme.colors.secondary, // Make selection distinct?
selectedDayTextColor: theme.colors.background, // Text on selection
todayTextColor: theme.colors.secondary, // Today's date number color
dayTextColor: theme.colors.text,
textDisabledColor: theme.colors.disabled,
dotColor: theme.colors.secondary, // Color for the explicit 'today' dot
selectedDotColor: theme.colors.primary,
arrowColor: theme.colors.primary,
monthTextColor: theme.colors.text,
indicatorColor: theme.colors.primary,
textDayFontWeight: '300',
textMonthFontWeight: 'bold',
textDayHeaderFontWeight: '500',
textDayFontSize: 16,
textMonthFontSize: 18,
textDayHeaderFontSize: 14,
// Period marking text color is handled by 'textColor' within the mark itself
'stylesheet.calendar.header': { // Example of deeper theme customization if needed
week: {
marginTop: 5,
flexDirection: 'row',
justifyContent: 'space-around'
}
}
};
// Get events for the *selected* date from the processed map
const eventsForSelectedDate = eventsByDate[selectedDate] || [];
return (
<View style={styles.container}>
<Calendar
key={theme.dark ? 'dark-calendar-period' : 'light-calendar-period'} // Change key if theme changes
style={styles.calendar}
theme={calendarTheme}
current={format(currentMonthData ? new Date(currentMonthData.timestamp) : new Date(), 'yyyy-MM-dd')} // Ensure current reflects viewed month
onDayPress={onDayPress}
onMonthChange={onMonthChange}
markedDates={markedDates}
markingType={'period'} // *** SET MARKING TYPE TO PERIOD ***
firstDay={1} // Optional: Start week on Monday
/>
{/* Replace the old Calendar and FlatList with the new CustomCalendarView */}
<CustomCalendarView />
{isLoading && <ActivityIndicator animating={true} color={theme.colors.primary} size="large" style={styles.loadingContainer} />}
{error && !isLoading && <Text style={styles.errorText}>{error}</Text>}
{!isLoading && !error && (
<View style={styles.eventListContainer}>
<Text style={styles.eventListHeader}>
Events for {selectedDate === todayString ? 'Today' : format(parseISO(selectedDate), 'MMMM d, yyyy')}
</Text>
{eventsForSelectedDate.length > 0 ? (
<FlatList
data={eventsForSelectedDate}
renderItem={renderEventItem}
keyExtractor={(item) => item.id + item.start} // Key needs to be unique if event appears on multiple days in list potentially
ItemSeparatorComponent={() => <View style={{ height: 5 }} />}
/>
) : (
<Text style={styles.noEventsText}>No events scheduled for this day.</Text>
)}
</View>
)}
{/* Add FAB for creating new events */}
{/* Keep the 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
color={theme.colors.onPrimary || '#ffffff'} // Ensure icon color contrasts
/>
</View>
);

View File

@@ -2,7 +2,8 @@
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';
// Add Chip
import { TextInput, Button, useTheme, Text, ActivityIndicator, HelperText, Chip } 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.
@@ -34,6 +35,8 @@ const EventFormScreen = () => {
const [endDate, setEndDate] = useState<Date | null>(null);
const [color, setColor] = useState(''); // Basic color input for now
const [location, setLocation] = useState(''); // Add location state
const [tags, setTags] = useState<string[]>([]); // Add tags state
const [currentTagInput, setCurrentTagInput] = useState(''); // State for tag input field
// Add state for raw web date input
const [webStartDateInput, setWebStartDateInput] = useState<string>('');
@@ -60,6 +63,7 @@ const EventFormScreen = () => {
setDescription(event.description || '');
setColor(event.color || ''); // Use optional color
setLocation(event.location || ''); // Set location state
setTags(event.tags || []); // Load tags or default to empty array
// Ensure dates are Date objects
if (event.start && isValid(parseISO(event.start))) {
const parsedDate = parseISO(event.start);
@@ -112,6 +116,7 @@ const EventFormScreen = () => {
setEndDate(null);
setWebEndDateInput('');
}
setTags([]); // Ensure tags start empty for new event
} else {
// Default start date to now if creating without a selected date
const now = new Date();
@@ -119,6 +124,7 @@ const EventFormScreen = () => {
setWebStartDateInput(formatForWebInput(now)); // Init web input
setEndDate(null);
setWebEndDateInput('');
setTags([]); // Ensure tags start empty for new event
}
}, [eventId, selectedDate]);
@@ -275,6 +281,7 @@ const EventFormScreen = () => {
end: endDate ? endDate.toISOString() : null,
location: location.trim() || null, // Include location
color: color.trim() || null, // Include color
tags: tags.length > 0 ? tags : null, // Include tags, send null if empty
};
try {
@@ -344,6 +351,18 @@ const EventFormScreen = () => {
}
};
// --- Tag Handling Logic ---
const handleAddTag = () => {
const newTag = currentTagInput.trim();
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag]);
setCurrentTagInput(''); // Clear input after adding
}
};
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove));
};
if (isLoading && !title) { // Show loading indicator only during initial fetch
return <ActivityIndicator animating={true} style={styles.loading} />;
@@ -435,6 +454,35 @@ const EventFormScreen = () => {
placeholder={theme.colors.primary} // Show default color hint
/>
{/* --- Tags Input --- */}
<View style={styles.tagInputContainer}>
<TextInput
label="Add Tag"
value={currentTagInput}
onChangeText={setCurrentTagInput}
mode="outlined"
style={styles.tagInput}
onSubmitEditing={handleAddTag} // Allow adding tag by pressing enter/submit
/>
{/* Wrap Button text in <Text> */}
<Button onPress={handleAddTag} mode="contained" style={styles.addTagButton} icon="plus">
<Text>Add</Text>
</Button>
</View>
<View style={styles.tagsDisplayContainer}>
{tags.map((tag, index) => (
<Chip
key={index}
icon="close" // Use close icon for removal
onPress={() => handleRemoveTag(tag)} // Use onPress for removal
style={styles.tagChip}
mode="flat" // Use flat mode for less emphasis
>
{tag}
</Chip>
))}
</View>
<Button
mode="contained"
onPress={handleSave}
@@ -514,6 +562,33 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginBottom: 10,
},
tagInputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 5,
},
tagInput: {
flex: 1,
marginRight: 8,
},
addTagButton: {
// Adjust height or padding if needed to align with text input
},
tagsDisplayContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 15, // Add some space below the tags
},
tagChip: {
marginRight: 5,
marginBottom: 5,
// backgroundColor: theme.colors.secondaryContainer, // Optional: Style chips
},
errorText: {
color: 'red', // Use theme.colors.error in practice
textAlign: 'center',
marginBottom: 10,
},
});
export default EventFormScreen;

View File

@@ -3,11 +3,13 @@
export interface CalendarEvent {
id: number;
title: string;
description: string;
start: string;
end: string;
location: string;
color?: string; // Add optional color property
description?: string | null; // Make optional to match backend base
start: string; // ISO string format
end?: string | null; // ISO string format
location?: string | null; // Make optional
color?: string | null; // Keep optional
tags?: string[]; // Add optional tags array
user_id: number; // Add user_id as it's in the response
}
// Type for creating an event (matches backend schema)
@@ -18,6 +20,7 @@ export type CalendarEventCreate = {
end?: string | null; // ISO string format
location?: string | null;
color?: string | null;
tags?: string[]; // Add optional tags array
};
// Type for updating an event (matches backend schema)
@@ -28,4 +31,5 @@ export type CalendarEventUpdate = {
end?: string | null; // ISO string format
location?: string | null;
color?: string | null;
tags?: string[]; // Add optional tags array
};