[V0.1 WORKING] Added chat, profile, & calendar screen implementations.

This commit is contained in:
c-d-p
2025-04-18 17:30:09 +02:00
parent bf7eb8275c
commit 8d884111fd
19 changed files with 613 additions and 181 deletions

View File

@@ -14,7 +14,7 @@ def lifespan_factory() -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
Base.metadata.drop_all(bind=get_engine()) # Base.metadata.drop_all(bind=get_engine())
Base.metadata.create_all(bind=get_engine()) Base.metadata.create_all(bind=get_engine())
yield yield

View File

@@ -14,4 +14,11 @@ class CalendarEventResponse(CalendarEventCreate):
user_id: int user_id: int
class Config: class Config:
from_attributes = True from_attributes = True
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,53 +1,74 @@
# modules/nlp/api.py # modules/nlp/api.py
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.database import get_db from core.database import get_db
from core.exceptions import bad_request_exception
from modules.auth.dependencies import get_current_user from modules.auth.dependencies import get_current_user
from modules.auth.models import User from modules.auth.models import User
from modules.nlp.service import process_request, ask_ai from modules.nlp.service import process_request, ask_ai
from modules.nlp.schemas import ProcessCommandRequest
from modules.calendar.service import create_calendar_event, get_calendar_events, update_calendar_event, delete_calendar_event from modules.calendar.service import create_calendar_event, get_calendar_events, update_calendar_event, delete_calendar_event
from modules.calendar.schemas import CalendarEventCreate from modules.calendar.schemas import CalendarEventCreate, CalendarEventUpdate
router = APIRouter(prefix="/nlp", tags=["nlp"]) router = APIRouter(prefix="/nlp", tags=["nlp"])
@router.post("/process-command") @router.post("/process-command")
def process_command(user_input: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def process_command(request_data: ProcessCommandRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
""" """
Process the user command and return the appropriate action. Process the user command, execute the action, and return a user-friendly response.
""" """
command = process_request(user_input) user_input = request_data.user_input
command_data = process_request(user_input)
if "error" in command: intent = command_data["intent"]
raise bad_request_exception(command["error"]) params = command_data["params"]
response_text = command_data["response_text"]
match command["intent"]:
case "ask_ai":
result = ask_ai(**command["params"])
return {"action": "ai_response", "details": result}
case "get_calendar_events":
result = get_calendar_events(db, current_user.id, **command["params"])
return {"action": "calendar_events_retrieved", "details": result}
case "add_calendar_event":
event = CalendarEventCreate(**command["params"])
result = create_calendar_event(db, current_user.id, event)
return {"action": "calendar_event_created", "details": result}
case "update_calendar_event": if intent == "error":
event = CalendarEventCreate(**command["params"]) raise HTTPException(status_code=400, detail=response_text)
result = update_calendar_event(db, current_user.id, 0, event_data=event) ## PLACEHOLDER
return {"action": "calendar_event_updated", "details": result} if intent == "clarification_needed":
return {"response": response_text}
case "delete_calendar_event":
result = update_calendar_event(db, current_user.id, 0) ## PLACEHOLDER if intent == "unknown":
return {"action": "calendar_event_deleted", "details": result} return {"response": response_text}
try:
match intent:
case "ask_ai":
ai_answer = ask_ai(**params)
return {"response": ai_answer}
case "unknown": case "get_calendar_events":
return {"action": "unknown_command", "details": command["params"]} result = get_calendar_events(db, current_user.id, **params)
case _: return {"response": response_text, "details": result}
raise bad_request_exception(400, detail="Unrecognized command")
case "add_calendar_event":
event = CalendarEventCreate(**params)
result = create_calendar_event(db, current_user.id, event)
return {"response": response_text, "details": result}
case "update_calendar_event":
event_id = params.pop('event_id', None)
if event_id is None:
raise HTTPException(status_code=400, detail="Event ID is required for update.")
event_data = CalendarEventUpdate(**params)
result = update_calendar_event(db, current_user.id, event_id, event_data=event_data)
return {"response": response_text, "details": result}
case "delete_calendar_event":
event_id = params.get('event_id')
if event_id is None:
raise HTTPException(status_code=400, detail="Event ID is required for delete.")
result = delete_calendar_event(db, current_user.id, event_id)
return {"response": response_text, "details": {"deleted": True, "event_id": event_id}}
case _:
print(f"Warning: Unhandled intent '{intent}' reached api.py match statement.")
raise HTTPException(status_code=500, detail="An unexpected error occurred processing the command.")
except HTTPException as http_exc:
raise http_exc
except Exception as e:
print(f"Error executing intent '{intent}': {e}")
raise HTTPException(status_code=500, detail="Sorry, I encountered an error while trying to perform that action.")

View File

@@ -0,0 +1,5 @@
# modules/nlp/schemas.py
from pydantic import BaseModel
class ProcessCommandRequest(BaseModel):
user_input: str

View File

@@ -10,25 +10,53 @@ client = genai.Client(api_key="AIzaSyBrte_mETZJce8qE6cRTSz_fHOjdjlShBk")
### Base prompt for MAIA, used for inital user requests ### Base prompt for MAIA, used for inital user requests
SYSTEM_PROMPT = """ SYSTEM_PROMPT = """
You are MAIA - My AI Assistant. Your job is to parse user requests into structured JSON commands. You are MAIA - My AI Assistant. Your job is to parse user requests into structured JSON commands and generate a user-facing response text.
Available functions: Available functions/intents:
1. ask_ai(request: str). If the intent of the request is a simple question (e.x. What is the weather like today?), you should call this function, and forward the user's request as the parameter. 1. ask_ai(request: str): Use for simple questions (e.g., weather, facts). Forward the user's request.
2. get_calendar_events(start: Optional[datetime], end: Optional[datetime]) 2. get_calendar_events(start: Optional[datetime], end: Optional[datetime]): Retrieve calendar events.
3. add_calendar_event(title: str, description: str, start: datetime, end: Optional[datetime], location: str) 3. add_calendar_event(title: str, description: str, start: datetime, end: Optional[datetime], location: str): Add a new event.
4. update_calendar_event(event_id: int, title: Optional[str], description: Optional[str], start: Optional[datetime], end: Optional[datetime], location: Optional[str]) 4. update_calendar_event(event_id: int, title: Optional[str], description: Optional[str], start: Optional[datetime], end: Optional[datetime], location: Optional[str]): Update an existing event. Requires event_id.
5. delete_calendar_event(event_id: int) 5. delete_calendar_event(event_id: int): Delete an event. Requires event_id.
6. clarification_needed(request: str): Use this if the user's request is ambiguous or lacks necessary information (like event_id for update/delete). The original user request should be passed in the 'request' parameter.
Respond **ONLY** with JSON like this: **IMPORTANT:** Respond ONLY with JSON containing BOTH "intent" and "params", AND a "response_text" field.
- "response_text" should be a friendly, user-facing message confirming the action taken, providing the answer, or asking for clarification.
Examples:
User: Add a meeting tomorrow at 3pm about project X
MAIA:
{ {
"intent": "add_calendar_event", "intent": "add_calendar_event",
"params": { "params": {
"title": "Team Meeting", "title": "Meeting",
"description": "Discuss project updates", "description": "Project X",
"start": "2025-04-16 15:00:00.000000+00:00", "start": "2025-04-19 15:00:00.000000+00:00",
"end": "2025-04-16 16:00:00.000000+00:00", "end": null,
"location": "Office" "location": null
} },
"response_text": "Okay, I've added a meeting about Project X to your calendar for tomorrow at 3 PM."
}
User: What's the weather like?
MAIA:
{
"intent": "ask_ai",
"params": {
"request": "What's the weather like?"
},
"response_text": "Let me check the weather for you."
}
User: Delete the team sync event.
MAIA:
{
"intent": "clarification_needed",
"params": {
"request": "Delete the team sync event."
},
"response_text": "Okay, I can help with that. Could you please provide the ID or more specific details about the 'team sync' event you want me to delete?"
} }
The datetime right now is """+str(datetime.now(timezone.utc))+""". The datetime right now is """+str(datetime.now(timezone.utc))+""".
@@ -45,6 +73,7 @@ Here is the user request:
def process_request(request: str): def process_request(request: str):
""" """
Process the user request using the Google GenAI API. Process the user request using the Google GenAI API.
Expects a JSON response with intent, params, and response_text.
""" """
response = client.models.generate_content( response = client.models.generate_content(
model="gemini-2.0-flash", model="gemini-2.0-flash",
@@ -52,41 +81,25 @@ def process_request(request: str):
config={ config={
"temperature": 0.3, # Less creativity, more factual "temperature": 0.3, # Less creativity, more factual
"response_mime_type": "application/json", "response_mime_type": "application/json",
# "response_schema": { ### NOT WORKING
# "type": "object",
# "properties": {
# "intent": {
# "type": "string",
# "enum": [
# "get_calendar_events",
# "add_calendar_event",
# "update_calendar_event",
# "delete_calendar_event"
# ]
# },
# "params": {
# "type": "object",
# "properties": {
# "title": {"type": "string"},
# "description": {"type": "string"},
# "start": {"type": "string", "format": "date-time"},
# "end": {"type": "string", "format": "date-time"},
# "location": {"type": "string"},
# "event_id": {"type": "integer"},
# },
# }
# },
# "required": ["intent", "params"]
# }
} }
) )
# Parse the JSON response # Parse the JSON response
try: try:
return json.loads(response.text) parsed_response = json.loads(response.text)
except ValueError: # Validate required fields
raise ValueError("Invalid JSON response from AI") if not all(k in parsed_response for k in ("intent", "params", "response_text")):
raise ValueError("AI response missing required fields (intent, params, response_text)")
return parsed_response
except (json.JSONDecodeError, ValueError) as e:
print(f"Error parsing AI response: {e}")
print(f"Raw AI response: {response.text}")
# Return a structured error that the API layer can handle
return {
"intent": "error",
"params": {},
"response_text": "Sorry, I had trouble understanding that request or formulating a response. Could you please try rephrasing?"
}
def ask_ai(request: str): def ask_ai(request: str):
""" """

View File

@@ -1,12 +0,0 @@
import apiClient from './client';
export const healthCheck = async (): Promise<{ status: string }> => {
try {
const response = await apiClient.get('/health');
return response.data
} catch (error) {
console.error("Error fetching backend health:", error);
throw error;
}
}

View File

@@ -11,6 +11,7 @@ export const getCalendarEvents = async (start?: Date, end?: Date): Promise<Calen
params.end = end.toISOString(); params.end = end.toISOString();
} }
const response = await apiClient.get('/calendar/events', { params }); const response = await apiClient.get('/calendar/events', { params });
console.log("[CAM] Got calendar:", response);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("Error fetching calendar events", error); console.error("Error fetching calendar events", error);

View File

@@ -54,8 +54,8 @@ apiClient.interceptors.response.use(
async (error: AxiosError) => { // Explicitly type error as AxiosError async (error: AxiosError) => { // Explicitly type error as AxiosError
const originalRequest = error.config; const originalRequest = error.config;
// Check if the error has a response object (i.e., server responded with error status) // Check if the error has a response object AND an original request config
if (error.response) { if (error.response && originalRequest) { // <-- Added check for originalRequest
// Server responded with an error status code (4xx, 5xx) // Server responded with an error status code (4xx, 5xx)
console.error('[API Client] Response Error Status:', error.response.status); console.error('[API Client] Response Error Status:', error.response.status);
console.error('[API Client] Response Error Data:', error.response.data); console.error('[API Client] Response Error Data:', error.response.data);
@@ -64,39 +64,58 @@ apiClient.interceptors.response.use(
if (error.response.status === 401) { if (error.response.status === 401) {
console.warn('[API Client] Unauthorized (401). Token might be expired or invalid.'); console.warn('[API Client] Unauthorized (401). Token might be expired or invalid.');
if (!originalRequest?._retry) { if (originalRequest.url === '/auth/refresh') {
originalRequest._retry = true; // Mark the request as retried to avoid infinite loops console.error('[API Client] Refresh token attempt failed with 401. Not retrying.');
// Clear token and reject without retry
if (Platform.OS === 'web') {
await AsyncStorage.removeItem(TOKEN_KEY);
} else {
await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => {}); // Ignore delete error
}
delete apiClient.defaults.headers.common['Authorization'];
return Promise.reject(error); // Reject immediately
}
// Proceed with refresh logic only if it wasn't the refresh endpoint that failed
// and if originalRequest exists (already checked above)
if (!originalRequest._retry) { // Now TS knows _retry exists due to declaration file
originalRequest._retry = true;
try { try {
console.log('[API Client] Attempting token refresh...'); console.log('[API Client] Attempting token refresh...');
const refreshResponse = await apiClient.post('/auth/refresh', {}, { const refreshResponse = await apiClient.post('/auth/refresh', {}, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
if (refreshResponse.status === 200) { if (refreshResponse.status === 200) {
const newToken = refreshResponse.data?.accessToken; const newToken = refreshResponse.data?.access_token;
if (newToken) { if (newToken) {
console.log('[API Client] Token refreshed successfully.'); console.log('[API Client] Token refreshed successfully.');
// Save the new token
if (Platform.OS === 'web') {
await AsyncStorage.setItem(TOKEN_KEY, newToken);
} else {
await SecureStore.setItemAsync(TOKEN_KEY, newToken);
}
// Save the new token // Update the Authorization header for future requests
if (Platform.OS === 'web') { apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
await AsyncStorage.setItem(TOKEN_KEY, newToken); // Safely update original request headers
} else { if (originalRequest.headers) {
await SecureStore.setItemAsync(TOKEN_KEY, newToken); originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
} }
// Update the Authorization header for future requests // Retry the original request (originalRequest is guaranteed to exist here)
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`; return apiClient(originalRequest);
originalRequest.headers['Authorization'] = `Bearer ${newToken}`; } else {
console.error('[API Client] Invalid token structure received during refresh:', refreshResponse.data);
// Retry the original request with the new token throw new Error('Invalid token received from server.');
return apiClient(originalRequest);
} }
} }
} catch (refreshError) { } catch (refreshError: any) {
console.error('[API Client] Token refresh failed:', refreshError); console.error('[API Client] Token refresh failed:', refreshError);
} }
} }

View File

@@ -174,7 +174,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
setAuthToken(null); setAuthToken(null);
delete apiClient.defaults.headers.common['Authorization']; delete apiClient.defaults.headers.common['Authorization'];
await deleteToken(); // Use helper await deleteToken(); // Use helper
// Optional backend logout call await apiClient.post("/auth/logout");
}, []); }, []);
const contextValue = useMemo(() => ({ const contextValue = useMemo(() => ({
@@ -192,7 +192,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
); );
}; };
// --- useAuth and AuthLoadingScreen remain the same ---
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {

View File

@@ -36,11 +36,12 @@ const CalendarScreen = () => {
// Store events keyed by date *for the list display* // Store events keyed by date *for the list display*
const [eventsByDate, setEventsByDate] = useState<{ [key: string]: CalendarEvent[] }>({}); const [eventsByDate, setEventsByDate] = useState<{ [key: string]: CalendarEvent[] }>({});
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// --- Fetching Logic --- // --- Fetching Logic ---
const fetchEventsForMonth = useCallback(async (date: Date | DateData) => { const fetchEventsForMonth = useCallback(async (date: Date | DateData) => {
console.log("[CAM] fetchevents start");
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const targetYear = 'year' in date ? date.year : getYear(date); const targetYear = 'year' in date ? date.year : getYear(date);
@@ -71,18 +72,28 @@ const CalendarScreen = () => {
// Process events for the daily list view // Process events for the daily list view
const newEventsByDate: { [key: string]: CalendarEvent[] } = {}; const newEventsByDate: { [key: string]: CalendarEvent[] } = {};
fetchedEvents.forEach(event => { fetchedEvents.forEach(event => {
const startDate = parseISO(event.start); // --- Check for valid START date string ---
const endDate = parseISO(event.end); if (typeof event.start !== 'string') {
console.warn(`Event ${event.id} has invalid start date type:`, event.start);
return; // Skip this event
}
// --- End check ---
if (!isValid(startDate) || !isValid(endDate)) { const startDate = parseISO(event.start);
console.warn(`Invalid date found in event ${event.id}`); // --- 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 return; // Skip invalid events
} }
// Ensure end date is not before start date // --- Handle potentially null END date ---
const end = endDate < startDate ? startDate : endDate; // 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 intervalDates = eachDayOfInterval({ start: startDate, end: end }); // 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 => { intervalDates.forEach(dayInInterval => {
const dateKey = format(dayInInterval, 'yyyy-MM-dd'); const dateKey = format(dayInInterval, 'yyyy-MM-dd');
if (!newEventsByDate[dateKey]) { if (!newEventsByDate[dateKey]) {
@@ -103,14 +114,80 @@ const CalendarScreen = () => {
console.error(err); console.error(err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
console.log("[CAM] isLoading:", isLoading);
} }
}, [isLoading, currentMonthData]); // Include dependencies }, [isLoading, currentMonthData]); // Include dependencies
// --- Initial Fetch --- // --- Initial Fetch ---
useEffect(() => { useEffect(() => {
const initialDate = parseISO(todayString); const performInitialLoad = async () => {
fetchEventsForMonth(initialDate); console.log("[CalendarScreen] Performing initial load.");
}, [fetchEventsForMonth, todayString]); 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 --- // --- Callbacks for Calendar ---
const onDayPress = useCallback((day: DateData) => { const onDayPress = useCallback((day: DateData) => {
@@ -119,6 +196,7 @@ const CalendarScreen = () => {
const onMonthChange = useCallback((month: DateData) => { const onMonthChange = useCallback((month: DateData) => {
if (!currentMonthData || month.year !== currentMonthData.year || month.month !== currentMonthData.month) { if (!currentMonthData || month.year !== currentMonthData.year || month.month !== currentMonthData.month) {
console.log("[CAM] CAlling fetchevents");
fetchEventsForMonth(month); fetchEventsForMonth(month);
} else { } else {
setCurrentMonthData(month); // Just update the current data if same month setCurrentMonthData(month); // Just update the current data if same month
@@ -130,18 +208,29 @@ const CalendarScreen = () => {
const marks: { [key: string]: MarkingProps } = {}; // Use MarkingProps type const marks: { [key: string]: MarkingProps } = {}; // Use MarkingProps type
rawEvents.forEach(event => { rawEvents.forEach(event => {
const startDate = parseISO(event.start); // --- Check for valid START date string ---
const endDate = parseISO(event.end); if (typeof event.start !== 'string') {
const eventColor = event.color || theme.colors.primary; // Use event color or default console.warn(`Event ${event.id} has invalid start date type in markedDates:`, event.start);
return; // Skip this event
}
// --- End check ---
if (!isValid(startDate) || !isValid(endDate)) { 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 return; // Skip invalid events
} }
// Ensure end date is not before start date // --- Handle potentially null END date ---
const end = endDate < startDate ? startDate : endDate; // 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
const intervalDates = eachDayOfInterval({ start: startDate, end: end }); // 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) => { intervalDates.forEach((dateInInterval, index) => {
const dateString = format(dateInInterval, 'yyyy-MM-dd'); const dateString = format(dateInInterval, 'yyyy-MM-dd');
@@ -172,9 +261,6 @@ const CalendarScreen = () => {
// Ensure start/end flags aren't overwritten by non-start/end marks // Ensure start/end flags aren't overwritten by non-start/end marks
startingDay: marks[dateString]?.startingDay || marking.startingDay, startingDay: marks[dateString]?.startingDay || marking.startingDay,
endingDay: marks[dateString]?.endingDay || marking.endingDay, endingDay: marks[dateString]?.endingDay || marking.endingDay,
// We might need a more complex strategy if multiple periods
// with different colors overlap on the same day.
// For now, the last event processed might "win" the color.
}; };
}); });
}); });
@@ -182,43 +268,50 @@ const CalendarScreen = () => {
// Add selected day marking (merge with period marking) // Add selected day marking (merge with period marking)
if (selectedDate) { if (selectedDate) {
marks[selectedDate] = { marks[selectedDate] = {
...(marks[selectedDate] || {}), // Keep existing period/dot marks ...(marks[selectedDate] || {}),
selected: true, selected: true,
// Keep the period color if it exists, otherwise use selection color color: marks[selectedDate]?.color || theme.colors.primary,
color: marks[selectedDate]?.color || theme.colors.primary, // Period wins color? or selection? Choose one. Here period wins.
// selectedColor: theme.colors.secondary, // Or use a distinct selection highlight color?
// Ensure text color is appropriate for selected state
textColor: theme.colors.onPrimary || '#ffffff', textColor: theme.colors.onPrimary || '#ffffff',
// If selected, don't let it look like starting/ending unless it truly is // If selected, don't let it look like starting/ending unless it truly is
startingDay: marks[selectedDate]?.startingDay && marks[selectedDate]?.selected, startingDay: marks[selectedDate]?.startingDay && marks[selectedDate]?.selected,
endingDay: marks[selectedDate]?.endingDay && marks[selectedDate]?.selected, endingDay: marks[selectedDate]?.endingDay && marks[selectedDate]?.selected,
}; };
} }
marks[todayString] = {
// Add today marking (merge with period/selection marking) ...(marks[todayString] || {}),
// Period marking visually indicates today already if colored. Add dot? dotColor: theme.colors.secondary,
marks[todayString] = { };
...(marks[todayString] || {}),
// marked: true, // 'marked' is implicit with period marking color
dotColor: theme.colors.secondary, // Add a distinct dot for today?
// Or rely on the 'todayTextColor' in the theme prop
};
return marks; return marks;
}, [rawEvents, selectedDate, theme.colors, theme.dark, todayString]); // Include theme.dark if colors change }, [rawEvents, selectedDate, theme.colors, theme.dark, todayString]); // Include theme.dark if colors change
// --- Render Event Item --- // --- Render Event Item ---
const renderEventItem = ({ item }: { item: CalendarEvent }) => { 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); const startDate = parseISO(item.start);
const endDate = parseISO(item.end); 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 || ''; let description = item.description || '';
if (isValid(startDate)) { const timePrefix = `Time: ${format(startDate, 'p')}`;
// Show date range if it spans multiple days or specific time if single day const dateRangePrefix = `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
if (!isSameDay(startDate, endDate)) {
description = `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}${item.description ? `\n${item.description}` : ''}`; // Show date range only if end date is valid and different from start date
} else { if (hasValidEndDate && !isSameDay(startDate, endDate)) {
description = `Time: ${format(startDate, 'p')}${item.description ? `\n${item.description}` : ''}`; // 'p' is locale-specific time format description = `${dateRangePrefix}${item.description ? `\n${item.description}` : ''}`;
} } else {
// Otherwise, show start time
description = `${timePrefix}${item.description ? `\n${item.description}` : ''}`;
} }
return ( return (
@@ -229,7 +322,7 @@ const CalendarScreen = () => {
style={styles.eventItem} style={styles.eventItem}
titleStyle={{ color: theme.colors.text }} titleStyle={{ color: theme.colors.text }}
descriptionStyle={{ color: theme.colors.textSecondary }} descriptionStyle={{ color: theme.colors.textSecondary }}
descriptionNumberOfLines={3} // Allow more lines for range/details descriptionNumberOfLines={3}
/> />
); );
} }

View File

@@ -1,19 +1,192 @@
// src/screens/DashboardScreen.tsx // src/screens/ChatScreen.tsx
import React from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform, TextInput as RNTextInput, NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native';
import { Text, useTheme } from 'react-native-paper'; import { Text, useTheme, TextInput, Button, IconButton, PaperProvider } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import apiClient from '../api/client'; // Import the apiClient
const DashboardScreen = () => { // Define the structure for a message
interface Message {
id: string;
text: string;
sender: 'user' | 'ai';
timestamp: Date;
}
const ChatScreen = () => {
const theme = useTheme(); const theme = useTheme();
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false); // To show activity indicator while AI responds
const flatListRef = useRef<FlatList>(null);
// Function to handle sending a message
const handleSend = useCallback(async () => {
const trimmedText = inputText.trim();
if (!trimmedText) return; // Don't send empty messages
const userMessage: Message = {
id: Date.now().toString() + '-user',
text: trimmedText,
sender: 'user',
timestamp: new Date(),
};
setMessages(prevMessages => [...prevMessages, userMessage]);
setInputText('');
setIsLoading(true);
// Scroll to bottom after sending user message
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
// --- Call Backend API ---
try {
console.log(`[ChatScreen] Sending to /nlp/process-command: ${trimmedText}`);
const response = await apiClient.post<{ response: string }>('/nlp/process-command', { user_input: trimmedText });
console.log("[ChatScreen] Received response:", response.data);
const aiResponse: Message = {
id: Date.now().toString() + '-ai',
// Assuming the backend returns the response text in a 'response' field
text: response.data.response || "Sorry, I didn't get a valid response.",
sender: 'ai',
timestamp: new Date(),
};
setMessages(prevMessages => [...prevMessages, aiResponse]);
} catch (error: any) {
console.error("Failed to get AI response:", error.response?.data || error.message || error);
const errorResponse: Message = {
id: Date.now().toString() + '-error',
text: 'Sorry, I encountered an error trying to reach MAIA.',
sender: 'ai',
timestamp: new Date(),
};
setMessages(prevMessages => [...prevMessages, errorResponse]);
} finally {
setIsLoading(false);
// Scroll to bottom after receiving AI message
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
}
// --- End API Call ---
}, [inputText]); // Keep inputText as dependency
const handleKeyPress = (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
if (e.nativeEvent.key === 'Enter' && !(e.nativeEvent as any).shiftKey) {
e.preventDefault(); // Prevent new line
handleSend();
}
};
// Render individual message item
const renderMessage = ({ item }: { item: Message }) => {
const isUser = item.sender === 'user';
return (
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.aiBubble]}>
<Text style={{ color: isUser ? theme.colors.onPrimary : theme.colors.onSurface }}>
{item.text}
</Text>
{/* Optional: Add timestamp */}
{/* <Text style={[styles.timestamp, { color: isUser ? theme.colors.onPrimary : theme.colors.onSurfaceVariant }]}>
{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text> */}
</View>
);
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background }, container: {
text: { fontSize: 20, color: theme.colors.text } flex: 1,
backgroundColor: theme.colors.background,
},
listContainer: {
flex: 1,
},
messageList: {
paddingHorizontal: 10,
paddingVertical: 10,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: theme.colors.outlineVariant,
backgroundColor: theme.colors.elevation.level2, // Slightly elevated background
},
textInput: {
flex: 1,
marginRight: 8,
backgroundColor: theme.colors.surface, // Use surface color for input background
},
messageBubble: {
maxWidth: '80%',
padding: 10,
borderRadius: 15,
marginBottom: 10,
},
userBubble: {
alignSelf: 'flex-end',
backgroundColor: theme.colors.primary,
borderBottomRightRadius: 5,
},
aiBubble: {
alignSelf: 'flex-start',
backgroundColor: theme.colors.surfaceVariant,
borderBottomLeftRadius: 5,
},
timestamp: {
fontSize: 10,
marginTop: 4,
alignSelf: 'flex-end',
opacity: 0.7,
}
}); });
return ( return (
<View style={styles.container}> <SafeAreaView style={styles.container} edges={['bottom', 'left', 'right']}>
<Text style={styles.text}>Chat</Text> <KeyboardAvoidingView
</View> style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0} // Adjust as needed
>
<View style={styles.listContainer}>
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.messageList}
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })} // Scroll on initial load/size change
onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })} // Scroll on layout change
/>
</View>
<View style={styles.inputContainer}>
<TextInput
style={styles.textInput}
value={inputText}
onChangeText={setInputText}
placeholder="Type your message..."
mode="outlined" // Or "flat"
multiline
onKeyPress={handleKeyPress}
blurOnSubmit={false}
disabled={isLoading}
/>
<IconButton
icon="send"
size={24}
onPress={handleSend}
disabled={!inputText.trim() || isLoading}
mode="contained"
iconColor={theme.colors.onPrimary}
containerColor={theme.colors.primary}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
); );
}; };
export default DashboardScreen; export default ChatScreen;

View File

@@ -1,19 +1,124 @@
// src/screens/DashboardScreen.tsx // src/screens/ProfileScreen.tsx
import React from 'react'; import React, { useState, useEffect } from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { Text, useTheme } from 'react-native-paper'; import { Text, Button, useTheme, Card, Title, Paragraph } from 'react-native-paper';
import { useAuth } from '../contexts/AuthContext';
import apiClient from '../api/client';
const DashboardScreen = () => { // Define an interface for the user data structure
interface UserProfile {
username: string;
name: string;
uuid: string;
role: string;
}
const ProfileScreen = () => {
const theme = useTheme(); const theme = useTheme();
const { logout } = useAuth(); // Get the logout function
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUserProfile = async () => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.get<UserProfile>('/user/me');
setUserProfile(response.data);
} catch (err: any) {
console.error("Failed to fetch user profile:", err);
setError(err.response?.data?.detail || err.message || 'Failed to load profile.');
} finally {
setIsLoading(false);
}
};
fetchUserProfile();
}, []); // Empty dependency array means this runs once on mount
const handleLogout = async () => {
try {
await logout();
// Navigation to login screen will likely happen automatically
// due to the state change in AuthProvider re-rendering the navigator
} catch (err) {
console.error("Logout failed:", err);
Alert.alert("Logout Error", "Could not log out. Please try again.");
}
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background }, container: {
text: { fontSize: 20, color: theme.colors.text } flex: 1,
padding: 16,
backgroundColor: theme.colors.background,
justifyContent: 'space-between', // Pushes logout button to bottom
},
contentContainer: {
flex: 1, // Takes up available space
alignItems: 'center',
justifyContent: 'center', // Center content vertically
},
card: {
width: '100%',
maxWidth: 400, // Optional: constrain width on larger screens
marginBottom: 20,
backgroundColor: theme.colors.surface, // Use theme surface color
},
errorText: {
fontSize: 16,
color: theme.colors.error,
textAlign: 'center',
marginBottom: 20,
},
logoutButton: {
marginTop: 'auto', // Pushes button to the bottom within its container
marginBottom: 20, // Add some space at the very bottom
},
// Removed text style as Paper components handle theme text color
}); });
if (isLoading) {
return (
<View style={[styles.container, { justifyContent: 'center', alignItems: 'center' }]}>
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
);
}
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.text}>Profile</Text> <View style={styles.contentContainer}>
{error && <Text style={styles.errorText}>{error}</Text>}
{userProfile ? (
<Card style={styles.card}>
<Card.Content>
<Title style={{ color: theme.colors.primary }}>User Profile</Title>
<Paragraph>Username: {userProfile.username}</Paragraph>
<Paragraph>Name: {userProfile.name}</Paragraph>
<Paragraph>UUID: {userProfile.uuid}</Paragraph>
<Paragraph>Role: {userProfile.role}</Paragraph>
</Card.Content>
</Card>
) : (
!error && <Text>No profile data available.</Text> // Show if no error but no data
)}
</View>
<Button
mode="contained" // Use contained style for primary actions
onPress={handleLogout}
style={styles.logoutButton}
icon="logout" // Add an icon
color={theme.colors.error} // Use error color for logout/destructive action
>
Logout
</Button>
</View> </View>
); );
}; };
export default DashboardScreen; export default ProfileScreen;

View File

@@ -0,0 +1,8 @@
// src/types/axios.d.ts
import 'axios';
declare module 'axios' {
export interface InternalAxiosRequestConfig {
_retry?: boolean; // Add our custom property as optional
}
}