[V0.2] WORKING Working calendar and AI with full frontend.
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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=["*"]
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
||||||
8
interfaces/nativeapp/package-lock.json
generated
8
interfaces/nativeapp/package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
98
interfaces/nativeapp/src/components/calendar/EventItem.tsx
Normal file
98
interfaces/nativeapp/src/components/calendar/EventItem.tsx
Normal 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
|
||||||
104
interfaces/nativeapp/src/components/calendar/MonthView.tsx
Normal file
104
interfaces/nativeapp/src/components/calendar/MonthView.tsx
Normal 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;
|
||||||
115
interfaces/nativeapp/src/components/calendar/ThreeDayView.tsx
Normal file
115
interfaces/nativeapp/src/components/calendar/ThreeDayView.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
112
interfaces/nativeapp/src/components/calendar/WeekView.tsx
Normal file
112
interfaces/nativeapp/src/components/calendar/WeekView.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user