[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

@@ -7,6 +7,12 @@ from core.database import get_engine, Base
from modules import router from modules import router
import logging import logging
# import all models to ensure they are registered before create_all
from modules.calendar.models import CalendarEvent
from modules.auth.models import User
logging.getLogger('passlib').setLevel(logging.ERROR) # fix bc package logging is broken logging.getLogger('passlib').setLevel(logging.ERROR) # fix bc package logging is broken
# Create DB tables (remove in production; use migrations instead) # Create DB tables (remove in production; use migrations instead)
@@ -29,7 +35,11 @@ app.include_router(router)
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:8081"], allow_origins=[
"http://localhost:8081", # Keep for web testing if needed
"http://192.168.1.9:8081", # Add your mobile device/emulator origin (adjust port if needed)
# Add other origins if necessary, e.g., production frontend URL
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"] allow_headers=["*"]

View File

@@ -14,15 +14,21 @@ def read_admin():
return {"message": "Admin route"} return {"message": "Admin route"}
@router.get("/cleardb") @router.get("/cleardb")
def clear_db(db: Annotated[Session, Depends(get_db)]): def clear_db(db: Annotated[Session, Depends(get_db)], hard: bool):
""" """
Clear the database. Clear the database.
'hard' parameter determines if the database should be completely reset.
""" """
if hard:
Base.metadata.drop_all(bind=db.get_bind())
Base.metadata.create_all(bind=db.get_bind())
return {"message": "Database reset (HARD)"}
else:
tables = Base.metadata.tables.keys() tables = Base.metadata.tables.keys()
for table in tables: for table_name in tables:
# delete all tables that isn't the users table # delete all tables that isn't the users table
if table != "users": if table_name != "users":
table = Base.metadata.tables[table] table = Base.metadata.tables[table_name]
db.execute(table.delete()) db.execute(table.delete())
# delete all non-admin accounts # delete all non-admin accounts

View File

@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from typing import List, Optional
from modules.auth.dependencies import get_current_user from modules.auth.dependencies import get_current_user
from core.database import get_db from core.database import get_db
from core.exceptions import not_found_exception from core.exceptions import not_found_exception
@@ -19,15 +20,13 @@ def create_event(
): ):
return create_calendar_event(db, user.id, event) return create_calendar_event(db, user.id, event)
@router.get("/events", response_model=list[CalendarEventResponse]) @router.get("/events", response_model=List[CalendarEventResponse])
def get_events( def get_events(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
start: datetime | None = None, start: Optional[datetime] = None,
end: datetime | None = None end: Optional[datetime] = None
): ):
start = None if start == "" else start
end = None if end == "" else end
return get_calendar_events(db, user.id, start, end) return get_calendar_events(db, user.id, start, end)
@router.get("/events/{event_id}", response_model=CalendarEventResponse) @router.get("/events/{event_id}", response_model=CalendarEventResponse)
@@ -37,8 +36,6 @@ def get_event_by_id(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
event = get_calendar_event_by_id(db, user.id, event_id) event = get_calendar_event_by_id(db, user.id, event_id)
if not event:
raise not_found_exception()
return event return event
@router.patch("/events/{event_id}", response_model=CalendarEventResponse) @router.patch("/events/{event_id}", response_model=CalendarEventResponse)
@@ -50,11 +47,10 @@ def update_event(
): ):
return update_calendar_event(db, user.id, event_id, event) return update_calendar_event(db, user.id, event_id, event)
@router.delete("/events/{event_id}") @router.delete("/events/{event_id}", status_code=204)
def delete_event( def delete_event(
event_id: int, event_id: int,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
delete_calendar_event(db, user.id, event_id) delete_calendar_event(db, user.id, event_id)
return {"message": "Event deleted"}

View File

@@ -1,5 +1,5 @@
# modules/calendar/models.py # modules/calendar/models.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON # Add JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from core.database import Base from core.database import Base
@@ -12,6 +12,8 @@ class CalendarEvent(Base):
start = Column(DateTime, nullable=False) start = Column(DateTime, nullable=False)
end = Column(DateTime) end = Column(DateTime)
location = Column(String) location = Column(String)
tags = Column(JSON)
color = Column(String) # hex code for color
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # <-- Relationship user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # <-- Relationship
# Bi-directional relationship (for eager loading) # Bi-directional relationship (for eager loading)

View File

@@ -1,31 +1,60 @@
# modules/calendar/schemas.py # modules/calendar/schemas.py
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, field_validator # Add field_validator
from typing import List, Optional # Add List and Optional
class CalendarEventCreate(BaseModel): # Base schema for common fields, including tags
class CalendarEventBase(BaseModel):
title: str title: str
description: str | None = None description: Optional[str] = None
start: datetime start: datetime
end: datetime | None = None end: Optional[datetime] = None
location: str | None = None location: Optional[str] = None
color: Optional[str] = None # Assuming color exists
tags: Optional[List[str]] = None # Add optional tags
@field_validator('tags', mode='before')
@classmethod
def tags_validate_null_string(cls, v):
if v == "Null":
return None
return v
# Schema for creating an event (inherits from Base)
class CalendarEventCreate(CalendarEventBase):
pass
# Schema for updating an event (all fields optional)
class CalendarEventUpdate(BaseModel): class CalendarEventUpdate(BaseModel):
title: str | None = None title: Optional[str] = None
description: str | None = None description: Optional[str] = None
start: datetime | None = None start: Optional[datetime] = None
end: datetime | None = None end: Optional[datetime] = None
location: str | None = None location: Optional[str] = None
color: Optional[str] = None
tags: Optional[List[str]] = None # Add optional tags for update
class CalendarEventResponse(CalendarEventCreate): @field_validator('tags', mode='before')
@classmethod
def tags_validate_null_string(cls, v):
if v == "Null":
return None
return v
# Schema for the response (inherits from Base, adds ID and user_id)
class CalendarEventResponse(CalendarEventBase):
id: int id: int
user_id: int user_id: int
tags: List[str] # Keep as List[str], remove default []
@field_validator('tags', mode='before')
@classmethod
def tags_validate_none_to_list(cls, v):
# If the value from the source object (e.g., ORM model) is None,
# convert it to an empty list before Pydantic validation.
if v is None:
return []
return v
class Config: class Config:
from_attributes = True from_attributes = True # Changed from orm_mode
class CalendarEventUpdate(BaseModel):
title: str | None = None
description: str | None = None
start: datetime | None = None
end: datetime | None = None
location: str | None = None

View File

@@ -1,25 +1,50 @@
# modules/calendar/service.py # modules/calendar/service.py
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ # Import or_
from datetime import datetime from datetime import datetime
from modules.calendar.models import CalendarEvent from modules.calendar.models import CalendarEvent
from core.exceptions import not_found_exception from core.exceptions import not_found_exception
from modules.calendar.schemas import CalendarEventCreate, CalendarEventUpdate # Import schemas
def create_calendar_event(db: Session, user_id: int, event_data): def create_calendar_event(db: Session, user_id: int, event_data: CalendarEventCreate):
event = CalendarEvent(**event_data.dict(), user_id=user_id) # Ensure tags is None if not provided or empty list, matching model
tags_to_store = event_data.tags if event_data.tags else None
event = CalendarEvent(
**event_data.model_dump(exclude={'tags'}), # Use model_dump and exclude tags initially
tags=tags_to_store, # Set tags separately
user_id=user_id
)
db.add(event) db.add(event)
db.commit() db.commit()
db.refresh(event) db.refresh(event)
return event return event
def get_calendar_events(db: Session, user_id: int, start: datetime, end: datetime): def get_calendar_events(db: Session, user_id: int, start: datetime | None, end: datetime | None):
query = db.query(CalendarEvent).filter( query = db.query(CalendarEvent).filter(CalendarEvent.user_id == user_id)
CalendarEvent.user_id == user_id
# If start and end dates are provided, filter for events overlapping the range.
# An event overlaps if: event_start < query_end AND (event_end IS NULL OR event_end > query_start)
if start and end:
query = query.filter(
CalendarEvent.start < end, # Event starts before the query window ends
or_(
CalendarEvent.end == None, # Event has no end date (considered single point in time at start)
CalendarEvent.end > start # Event ends after the query window starts
) )
if start: )
query = query.filter(CalendarEvent.start_time >= start) # If only start is provided, filter events starting on or after start
if end: elif start:
query = query.filter(CalendarEvent.end_time <= end) query = query.filter(CalendarEvent.start >= start)
return query.all() # If only end is provided, filter events ending on or before end (or starting before end if no end date)
elif end:
query = query.filter(
or_(
CalendarEvent.end <= end,
(CalendarEvent.end == None and CalendarEvent.start < end)
)
)
return query.order_by(CalendarEvent.start).all() # Order by start time
def get_calendar_event_by_id(db: Session, user_id: int, event_id: int): def get_calendar_event_by_id(db: Session, user_id: int, event_id: int):
event = db.query(CalendarEvent).filter( event = db.query(CalendarEvent).filter(
@@ -30,25 +55,23 @@ def get_calendar_event_by_id(db: Session, user_id: int, event_id: int):
raise not_found_exception() raise not_found_exception()
return event return event
def update_calendar_event(db: Session, user_id: int, event_id: int, event_data): def update_calendar_event(db: Session, user_id: int, event_id: int, event_data: CalendarEventUpdate):
event = db.query(CalendarEvent).filter( event = get_calendar_event_by_id(db, user_id, event_id) # Reuse get_by_id for check
CalendarEvent.id == event_id, # Use model_dump with exclude_unset=True to only update provided fields
CalendarEvent.user_id == user_id update_data = event_data.model_dump(exclude_unset=True)
).first()
if not event: for key, value in update_data.items():
raise not_found_exception() # Ensure tags is handled correctly (set to None if empty list provided)
for key, value in event_data.dict().items(): if key == 'tags' and isinstance(value, list) and not value:
setattr(event, key, None)
else:
setattr(event, key, value) setattr(event, key, value)
db.commit() db.commit()
db.refresh(event) db.refresh(event)
return event return event
def delete_calendar_event(db: Session, user_id: int, event_id: int): def delete_calendar_event(db: Session, user_id: int, event_id: int):
event = db.query(CalendarEvent).filter( event = get_calendar_event_by_id(db, user_id, event_id) # Reuse get_by_id for check
CalendarEvent.id == event_id,
CalendarEvent.user_id == user_id
).first()
if not event:
raise not_found_exception()
db.delete(event) db.delete(event)
db.commit() db.commit()

View File

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

View File

@@ -30,7 +30,8 @@
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
"react-native-vector-icons": "^10.2.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": { "devDependencies": {
"@babel/core": "^7.25.2", "@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'; 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'; const TOKEN_KEY = 'maia_access_token';
console.log("Using API Base URL:", API_BASE_URL); 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 // src/screens/CalendarScreen.tsx
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native'; // Import TouchableOpacity import { View, StyleSheet } from 'react-native';
import { Calendar, DateData, LocaleConfig, CalendarProps, MarkingProps } from 'react-native-calendars'; import { useTheme, FAB } from 'react-native-paper';
import { Text, useTheme, ActivityIndicator, List, Divider, FAB } from 'react-native-paper'; // Import FAB import { useNavigation } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native'; // Import useNavigation import { StackNavigationProp } from '@react-navigation/stack';
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 { getCalendarEvents } from '../api/calendar'; import CustomCalendarView from '../components/calendar/CustomCalendarView'; // Import the new custom view
import { CalendarEvent } from '../types/calendar'; // Use updated type import { AppStackParamList } from '../navigation/AppNavigator';
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 // Define navigation prop type
type CalendarScreenNavigationProp = StackNavigationProp<AppStackParamList, 'Calendar'>; // Adjust 'Calendar' if your route name is different type CalendarScreenNavigationProp = StackNavigationProp<AppStackParamList, 'Calendar'>;
const getTodayDateString = () => format(new Date(), 'yyyy-MM-dd');
const CalendarScreen = () => { const CalendarScreen = () => {
const theme = useTheme(); const theme = useTheme();
const navigation = useNavigation<CalendarScreenNavigationProp>(); // Use the hook with the correct type const navigation = useNavigation<CalendarScreenNavigationProp>();
const todayString = useMemo(getTodayDateString, []);
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({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.colors.background }, container: { flex: 1, backgroundColor: theme.colors.background },
calendar: { /* ... */ }, fab: {
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
position: 'absolute', position: 'absolute',
margin: 16, margin: 16,
right: 0, right: 0,
bottom: 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 ( return (
<View style={styles.container}> <View style={styles.container}>
<Calendar {/* Replace the old Calendar and FlatList with the new CustomCalendarView */}
key={theme.dark ? 'dark-calendar-period' : 'light-calendar-period'} // Change key if theme changes <CustomCalendarView />
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
/>
{isLoading && <ActivityIndicator animating={true} color={theme.colors.primary} size="large" style={styles.loadingContainer} />} {/* Keep the FAB for creating new events */}
{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 */}
<FAB <FAB
style={styles.fab} style={styles.fab}
icon="plus" icon="plus"
onPress={() => navigation.navigate('EventForm')} // Navigate without eventId for creation 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>
); );

View File

@@ -2,7 +2,8 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
// Add Platform import // Add Platform import
import { View, StyleSheet, ScrollView, Alert, TouchableOpacity, Platform } from 'react-native'; 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'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
// Conditionally import DateTimePickerModal only if not on web // Conditionally import DateTimePickerModal only if not on web
// Note: This dynamic import might not work as expected depending on the bundler setup. // 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 [endDate, setEndDate] = useState<Date | null>(null);
const [color, setColor] = useState(''); // Basic color input for now const [color, setColor] = useState(''); // Basic color input for now
const [location, setLocation] = useState(''); // Add location state 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 // Add state for raw web date input
const [webStartDateInput, setWebStartDateInput] = useState<string>(''); const [webStartDateInput, setWebStartDateInput] = useState<string>('');
@@ -60,6 +63,7 @@ const EventFormScreen = () => {
setDescription(event.description || ''); setDescription(event.description || '');
setColor(event.color || ''); // Use optional color setColor(event.color || ''); // Use optional color
setLocation(event.location || ''); // Set location state setLocation(event.location || ''); // Set location state
setTags(event.tags || []); // Load tags or default to empty array
// Ensure dates are Date objects // Ensure dates are Date objects
if (event.start && isValid(parseISO(event.start))) { if (event.start && isValid(parseISO(event.start))) {
const parsedDate = parseISO(event.start); const parsedDate = parseISO(event.start);
@@ -112,6 +116,7 @@ const EventFormScreen = () => {
setEndDate(null); setEndDate(null);
setWebEndDateInput(''); setWebEndDateInput('');
} }
setTags([]); // Ensure tags start empty for new event
} else { } else {
// Default start date to now if creating without a selected date // Default start date to now if creating without a selected date
const now = new Date(); const now = new Date();
@@ -119,6 +124,7 @@ const EventFormScreen = () => {
setWebStartDateInput(formatForWebInput(now)); // Init web input setWebStartDateInput(formatForWebInput(now)); // Init web input
setEndDate(null); setEndDate(null);
setWebEndDateInput(''); setWebEndDateInput('');
setTags([]); // Ensure tags start empty for new event
} }
}, [eventId, selectedDate]); }, [eventId, selectedDate]);
@@ -275,6 +281,7 @@ const EventFormScreen = () => {
end: endDate ? endDate.toISOString() : null, end: endDate ? endDate.toISOString() : null,
location: location.trim() || null, // Include location location: location.trim() || null, // Include location
color: color.trim() || null, // Include color color: color.trim() || null, // Include color
tags: tags.length > 0 ? tags : null, // Include tags, send null if empty
}; };
try { 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 if (isLoading && !title) { // Show loading indicator only during initial fetch
return <ActivityIndicator animating={true} style={styles.loading} />; return <ActivityIndicator animating={true} style={styles.loading} />;
@@ -435,6 +454,35 @@ const EventFormScreen = () => {
placeholder={theme.colors.primary} // Show default color hint 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 <Button
mode="contained" mode="contained"
onPress={handleSave} onPress={handleSave}
@@ -514,6 +562,33 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
marginBottom: 10, 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; export default EventFormScreen;

View File

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