From ee86374da6db86dbc1bf78bf62072aa747a915d3 Mon Sep 17 00:00:00 2001 From: c-d-p Date: Fri, 18 Apr 2025 19:30:02 +0200 Subject: [PATCH] Updated calendar to have CRUD functionality (mostly). --- .gitignore | 1 + .../calendar/__pycache__/api.cpython-312.pyc | Bin 2512 -> 3062 bytes .../__pycache__/schemas.cpython-312.pyc | Bin 1454 -> 1677 bytes .../__pycache__/service.cpython-312.pyc | Bin 2793 -> 3262 bytes backend/modules/calendar/api.py | 20 +- backend/modules/calendar/schemas.py | 7 + backend/modules/calendar/service.py | 9 + interfaces/nativeapp/package-lock.json | 36 ++ interfaces/nativeapp/package.json | 5 +- interfaces/nativeapp/src/api/calendar.ts | 48 +- .../src/navigation/WebContentNavigator.tsx | 3 +- .../nativeapp/src/screens/CalendarScreen.tsx | 48 +- .../nativeapp/src/screens/EventFormScreen.tsx | 519 ++++++++++++++++++ interfaces/nativeapp/src/types/calendar.ts | 23 +- interfaces/nativeapp/src/types/navigation.ts | 2 + 15 files changed, 700 insertions(+), 21 deletions(-) create mode 100644 .gitignore create mode 100644 interfaces/nativeapp/src/screens/EventFormScreen.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/backend/modules/calendar/__pycache__/api.cpython-312.pyc b/backend/modules/calendar/__pycache__/api.cpython-312.pyc index 8b40ea6c74b0a6d8c42c026e9b19b4aaa2197bea..8fa56dca5b776837d637935b68ebc665e0800b1f 100644 GIT binary patch delta 1337 zcmZ`%-)j?D6ux(UWs)Y7ACsRNTid3z0Z~Lmq*Yg>&|SK^Dnc2-5OZ&BB$LEDQ_)?e zQIvh|-hW^ptrgt=V_&-TMF&M6bYFZa?yBIE=iW3SEp=edocZqgzVqF4?}q(3XZ;HD0NrJ{ml4^?vNj=inw+o$p zz&$a@>gK>L(qsXCq(bRDv5CE?^+ZUT>csvvgb>N}vPF|*2ZRrNM>y9rbs|rPdwO81 zFu)xF?%6#|^|>bnH%=3MzDf2C@JE3^yBB{Q^QU-XWM}971I+QecDcysmCGx3v*lM7 zTgy#Sp?7Mu?bln)G7hvW9%W@Uu+LQ+v`MOL_BL($=NPT}bQgG~O$3?=^}q^zAb^7+j6(1<2nXR|2OtR|qN8l?u@V4TKB~{)5q?b{;~h1F=lMnB z=*oTdp6W$>9SY>ryFw4~J@qnT`vC+`arns2EGz(xO$eZCQe?vOuJ-YM`|dW`B-BWo zBI%(2qbN4 z{(Cc!CI-d@OS?=&m*r`7$H2-^aEPakF|DhrX8A9MGo9*7s=M~(sj$=^Xq32}NDb)Y~ delta 916 zcmZuvzi-n(6uyg{-?8)4PSU1n2!UWBH4~Jor3?tE6Xl0k%uuDdOA{0)(b++wQiX&V zx>h$cAf_svVL|-|Sdf@%De8j63JU`h@0_Gqs3-aPd+*-&`Fl_Po&TCuzbHx!!Pzh2 z&YRGYTELZ~`twJYYN{9!L?XSUl`>OS+Duy+Gh=1VEJguDF^Q7c9zKbW1A>l_nWMQE zH>cPiNrI{;3jd7jcU7v8B#o04mD?ebej6Om8ciLz4L@s&fm@(z5fV?K_K;*qrY+A! zNR|dkWx(=@f(Yff{$MU{`zJ7G3KZE+OlB(8>#4H zmS1Iz1%4WM8kV2gloIT-D%D4cc^IK zAEfVk0>1ACy2OxJ4j|J+(D^%gJGcl?;lJeLdx|KEO&Rj6K@^rmC_}Ji2o*SN0e~CZ zqt5gTHoF1^jh_WKaEa@oD!&@k_?__TRsrfpfT{4B3s(j9d4Tjk;lyZ2#2B7?R)dBL z{}ir*v>Ue9lNVYN^R%X;2pLJV&g~@#?a>RBI$Rn2FmfYQ5@?x!jO^-JYd}UlYOgd$ zPGQJj`Gx1xqI8@57#Ri$TA#WOC!ws>)$f3-YhXdPTVv(){TXE%H z7jm%_R~n8SSL*g1ZxA;$b;X3AW5k3RZ<~K28&flzY!zx49{UjDPYGlE16`lwQFVfr jCg}DARVJu*j<(Oy!8dfk*J5XUH@=Ct`1|-{SqT0G>tDnv diff --git a/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc index 238ddf87f86cb4358fb5e48060a0c7cbe1cfbfd2..01698c91b1487c564ff47dabcae94bf77cd5f5ee 100644 GIT binary patch delta 286 zcmZ3--OI~&nwOW00SI&(m@=f9C-O-!mP}N)vu0Y&3=(Bvh+<)4aA!ziZ(&H`NM(kL zvVui9fud}wNNOv&G`Tl+onV}Np1GcJqn8(2bWvOlXVmkLmn5r~UzCYQ0wP3~Y-0{{}} BF(m*1 delta 237 zcmeC>UB}ILnwOW00SFv3nKFWyCh|!z#!XbWQ)OPw3=(Bvh+<)4aA!ziZ(&H`NM(YH zvQ~0xa&262f^qUC=J(Q)K!pN8T+9w6S{QCf$#wD<2?5zf(v$D7D9A~Jn1UbzEGP_O ziNXk;$x^H;jG~k6S^F6^C%<4a0Fsi-9(-~j1t0>Xym)dxa}cB4HvlHCZ}_jGV)Ho%NfTFGO!4wzDQ@X tBbO|fA&|)k#Kn^*XK;B*u`>!T5dXjcq&66XsRPQNLA(z_lQ(gh0|1~RL7o5r delta 115 zcmdld`BIecG%qg~0}$*GV8~d{I+0I;v1OuqD@!GxCjZ7Gek_}hvCU_k%*iG?xsoHF zRSl?6V)0uJCPvN4@@y)T8M(z-HGyKPlV!O}8F?m8=8j_nsVUN&{D)hXOAp9o1mfb# M$;v!lliPXB0f2fM*8l(j diff --git a/backend/modules/calendar/api.py b/backend/modules/calendar/api.py index 802771d..71fc69d 100644 --- a/backend/modules/calendar/api.py +++ b/backend/modules/calendar/api.py @@ -4,9 +4,10 @@ from sqlalchemy.orm import Session from datetime import datetime from modules.auth.dependencies import get_current_user from core.database import get_db +from core.exceptions import not_found_exception from modules.auth.models import User -from modules.calendar.schemas import CalendarEventCreate, CalendarEventResponse -from modules.calendar.service import create_calendar_event, get_calendar_events, update_calendar_event, delete_calendar_event +from modules.calendar.schemas import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse +from modules.calendar.service import create_calendar_event, get_calendar_event_by_id, get_calendar_events, update_calendar_event, delete_calendar_event router = APIRouter(prefix="/calendar", tags=["calendar"]) @@ -29,10 +30,21 @@ def get_events( end = None if end == "" else end return get_calendar_events(db, user.id, start, end) -@router.put("/events/{event_id}", response_model=CalendarEventResponse) +@router.get("/events/{event_id}", response_model=CalendarEventResponse) +def get_event_by_id( + event_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + event = get_calendar_event_by_id(db, user.id, event_id) + if not event: + raise not_found_exception() + return event + +@router.patch("/events/{event_id}", response_model=CalendarEventResponse) def update_event( event_id: int, - event: CalendarEventCreate, + event: CalendarEventUpdate, user: User = Depends(get_current_user), db: Session = Depends(get_db) ): diff --git a/backend/modules/calendar/schemas.py b/backend/modules/calendar/schemas.py index 01e495e..3da652e 100644 --- a/backend/modules/calendar/schemas.py +++ b/backend/modules/calendar/schemas.py @@ -9,6 +9,13 @@ class CalendarEventCreate(BaseModel): end: datetime | None = None location: str | None = None +class CalendarEventUpdate(BaseModel): + title: str | None = None + description: str | None = None + start: datetime | None = None + end: datetime | None = None + location: str | None = None + class CalendarEventResponse(CalendarEventCreate): id: int user_id: int diff --git a/backend/modules/calendar/service.py b/backend/modules/calendar/service.py index 0a9830f..2a5f912 100644 --- a/backend/modules/calendar/service.py +++ b/backend/modules/calendar/service.py @@ -21,6 +21,15 @@ def get_calendar_events(db: Session, user_id: int, start: datetime, end: datetim query = query.filter(CalendarEvent.end_time <= end) return query.all() +def get_calendar_event_by_id(db: Session, user_id: int, event_id: int): + event = db.query(CalendarEvent).filter( + CalendarEvent.id == event_id, + CalendarEvent.user_id == user_id + ).first() + if not event: + raise not_found_exception() + return event + def update_calendar_event(db: Session, user_id: int, event_id: int, event_data): event = db.query(CalendarEvent).filter( CalendarEvent.id == event_id, diff --git a/interfaces/nativeapp/package-lock.json b/interfaces/nativeapp/package-lock.json index f72b452..da08209 100644 --- a/interfaces/nativeapp/package-lock.json +++ b/interfaces/nativeapp/package-lock.json @@ -24,6 +24,7 @@ "react-native": "0.76.9", "react-native-async-storage": "^0.0.1", "react-native-calendars": "^1.1311.0", + "react-native-modal-datetime-picker": "^18.0.0", "react-native-paper": "^5.13.2", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", @@ -3035,6 +3036,29 @@ "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, + "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, + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=50.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", @@ -8601,6 +8625,18 @@ "react-native": "*" } }, + "node_modules/react-native-modal-datetime-picker": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/react-native-modal-datetime-picker/-/react-native-modal-datetime-picker-18.0.0.tgz", + "integrity": "sha512-0jdvhhraZQlRACwr7pM6vmZ2kxgzJ4CpnmV6J3TVA6MrXMXK6Zo/upRBKkRp0+fTOiKuNblzesA2U59rYo6SGA==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@react-native-community/datetimepicker": ">=6.7.0", + "react-native": ">=0.65.0" + } + }, "node_modules/react-native-paper": { "version": "5.13.2", "resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.13.2.tgz", diff --git a/interfaces/nativeapp/package.json b/interfaces/nativeapp/package.json index 52631a6..1246be1 100644 --- a/interfaces/nativeapp/package.json +++ b/interfaces/nativeapp/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@expo/metro-runtime": "~4.0.1", + "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", @@ -24,12 +25,12 @@ "react-native": "0.76.9", "react-native-async-storage": "^0.0.1", "react-native-calendars": "^1.1311.0", + "react-native-modal-datetime-picker": "^18.0.0", "react-native-paper": "^5.13.2", "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-async-storage/async-storage": "1.23.1" + "react-native-web": "~0.19.13" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/interfaces/nativeapp/src/api/calendar.ts b/interfaces/nativeapp/src/api/calendar.ts index 447ebd3..7e9bb64 100644 --- a/interfaces/nativeapp/src/api/calendar.ts +++ b/interfaces/nativeapp/src/api/calendar.ts @@ -1,5 +1,6 @@ import apiClient from './client'; -import { CalendarEvent } from '../types/calendar'; +// Import the new types +import { CalendarEvent, CalendarEventCreate, CalendarEventUpdate } from '../types/calendar'; export const getCalendarEvents = async (start?: Date, end?: Date): Promise => { try { @@ -17,4 +18,49 @@ export const getCalendarEvents = async (start?: Date, end?: Date): Promise => { + try { + const response = await apiClient.get(`/calendar/events/${event_id}`); + console.log("[CAM] Got calendar event:", response); + return response.data; + } catch (error) { + console.error("Error fetching calendar event", error); + throw error; + } +} + +// Use CalendarEventCreate type for the event parameter +export const createCalendarEvent = async (event: CalendarEventCreate): Promise => { + try { + const response = await apiClient.post('/calendar/events', event); + console.log("[CAM] Created calendar event:", response); + return response.data; // Backend returns the full event + } catch (error) { + console.error("Error creating calendar event", error); + throw error; + } +} + +// Use CalendarEventUpdate type for the event parameter +export const updateCalendarEvent = async (event_id: number, event: CalendarEventUpdate): Promise => { + try { + const response = await apiClient.patch(`/calendar/events/${event_id}`, event); + console.log("[CAM] Updated calendar event:", response); + return response.data; // Backend returns the full event + } catch (error) { + console.error("Error updating calendar event", error); + throw error; + } +} + +export const deleteCalendarEvent = async (event_id: number): Promise => { + try { + const response = await apiClient.delete(`/calendar/events/${event_id}`); + console.log("[CAM] Deleted calendar event:", response); + } catch (error) { + console.error("Error deleting calendar event", error); + throw error; + } } \ No newline at end of file diff --git a/interfaces/nativeapp/src/navigation/WebContentNavigator.tsx b/interfaces/nativeapp/src/navigation/WebContentNavigator.tsx index cb223be..b61a361 100644 --- a/interfaces/nativeapp/src/navigation/WebContentNavigator.tsx +++ b/interfaces/nativeapp/src/navigation/WebContentNavigator.tsx @@ -7,6 +7,7 @@ import DashboardScreen from '../screens/DashboardScreen'; import ChatScreen from '../screens/ChatScreen'; import CalendarScreen from '../screens/CalendarScreen'; import ProfileScreen from '../screens/ProfileScreen'; +import EventFormScreen from '../screens/EventFormScreen'; import { WebContentStackParamList } from '../types/navigation'; @@ -37,7 +38,7 @@ const WebContentNavigator = () => { - {/* Add other detail screens here if needed */} + ); }; diff --git a/interfaces/nativeapp/src/screens/CalendarScreen.tsx b/interfaces/nativeapp/src/screens/CalendarScreen.tsx index 54e4647..9215676 100644 --- a/interfaces/nativeapp/src/screens/CalendarScreen.tsx +++ b/interfaces/nativeapp/src/screens/CalendarScreen.tsx @@ -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; // Adjust 'Calendar' if your route name is different + const getTodayDateString = () => format(new Date(), 'yyyy-MM-dd'); const CalendarScreen = () => { const theme = useTheme(); + const navigation = useNavigation(); // Use the hook with the correct type const todayString = useMemo(getTodayDateString, []); const [selectedDate, setSelectedDate] = useState(todayString); @@ -315,15 +322,17 @@ const CalendarScreen = () => { } return ( - } // Use a filled circle or similar - style={styles.eventItem} - titleStyle={{ color: theme.colors.text }} - descriptionStyle={{ color: theme.colors.textSecondary }} - descriptionNumberOfLines={3} - /> + navigation.navigate('EventForm', { eventId: item.id })}> + } + style={styles.eventItem} + titleStyle={{ color: theme.colors.text }} + descriptionStyle={{ color: theme.colors.textSecondary }} + descriptionNumberOfLines={3} + /> + ); } @@ -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 = () => { )} )} + + {/* Add FAB for creating new events */} + navigation.navigate('EventForm')} // Navigate without eventId for creation + color={theme.colors.onPrimary || '#ffffff'} // Ensure icon color contrasts with background + /> ); }; diff --git a/interfaces/nativeapp/src/screens/EventFormScreen.tsx b/interfaces/nativeapp/src/screens/EventFormScreen.tsx new file mode 100644 index 0000000..b9c4d0a --- /dev/null +++ b/interfaces/nativeapp/src/screens/EventFormScreen.tsx @@ -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; +type EventFormNavigationProp = NativeStackNavigationProp; + +const EventFormScreen = () => { + const theme = useTheme(); + const navigation = useNavigation(); + const route = useRoute(); + + 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(null); + const [endDate, setEndDate] = useState(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(''); + const [webEndDateInput, setWebEndDateInput] = useState(''); + + const [isStartDatePickerVisible, setStartDatePickerVisibility] = useState(false); + const [isEndDatePickerVisible, setEndDatePickerVisibility] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(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 ; + } + + return ( + + + {eventId ? 'Edit Event' : 'Create Event'} + + {error && {error}} + + { setTitle(text); validateForm({ title: text }); }} + mode="outlined" + style={styles.input} + error={!!formErrors.title} + /> + + {formErrors.title} + + + + {/* Start Date Input - Conditional Logic */} + + handleWebDateInputChange(text, 'start') : undefined} + placeholder={Platform.OS === 'web' ? 'YYYY-MM-DD HH:mm' : ''} + mode="outlined" + style={styles.input} + right={Platform.OS !== 'web' ? : null} + error={!!formErrors.start} + /> + + + {formErrors.start} + + + {/* End Date Input - Conditional Logic */} + + handleWebDateInputChange(text, 'end') : undefined} + placeholder={Platform.OS === 'web' ? 'YYYY-MM-DD HH:mm' : ''} + mode="outlined" + style={styles.input} + right={Platform.OS !== 'web' ? : null} + error={!!formErrors.end} + /> + + + {formErrors.end} + + + {/* Add Location Input */} + + + + + + + + + {eventId && ( + + )} + + {/* Conditionally render DateTimePickerModal only on native platforms */} + {Platform.OS !== 'web' && ( + <> + + + + )} + + + ); +}; + +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; diff --git a/interfaces/nativeapp/src/types/calendar.ts b/interfaces/nativeapp/src/types/calendar.ts index 30582b9..60563f5 100644 --- a/interfaces/nativeapp/src/types/calendar.ts +++ b/interfaces/nativeapp/src/types/calendar.ts @@ -7,4 +7,25 @@ export interface CalendarEvent { start: string; end: string; location: string; -} \ No newline at end of file + color?: string; // Add optional color property +} + +// Type for creating an event (matches backend schema) +export type CalendarEventCreate = { + title: string; + description?: string | null; + start: string; // ISO string format + end?: string | null; // ISO string format + location?: string | null; + color?: string | null; +}; + +// Type for updating an event (matches backend schema) +export type CalendarEventUpdate = { + title?: string | null; + description?: string | null; + start?: string | null; // ISO string format + end?: string | null; // ISO string format + location?: string | null; + color?: string | null; +}; \ No newline at end of file diff --git a/interfaces/nativeapp/src/types/navigation.ts b/interfaces/nativeapp/src/types/navigation.ts index 9a4437a..de5bd83 100644 --- a/interfaces/nativeapp/src/types/navigation.ts +++ b/interfaces/nativeapp/src/types/navigation.ts @@ -1,4 +1,5 @@ // src/types/navigation.ts +import { NavigationContainerRef } from '@react-navigation/native'; // Import NavigationContainerRef // Screens within the main Mobile Bottom Tab Navigator export type MobileTabParamList = { @@ -14,6 +15,7 @@ export type WebContentStackParamList = { Chat: undefined; Calendar: undefined; Profile: undefined; + EventForm?: { eventId?: number; selectedDate?: string }; // Add EventForm with optional params }; // Screens managed by the Root Navigator (Auth vs App)