diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc index 5a63223..58a6b67 100644 Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/backend/main.py b/backend/main.py index 02282a8..c9c4fe0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ def lifespan_factory() -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any] @asynccontextmanager 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()) yield diff --git a/backend/modules/auth/__pycache__/security.cpython-312.pyc b/backend/modules/auth/__pycache__/security.cpython-312.pyc index f562ca5..b1bd187 100644 Binary files a/backend/modules/auth/__pycache__/security.cpython-312.pyc and b/backend/modules/auth/__pycache__/security.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc index 694dcc2..238ddf8 100644 Binary files a/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc and b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/modules/calendar/schemas.py b/backend/modules/calendar/schemas.py index 00b6755..01e495e 100644 --- a/backend/modules/calendar/schemas.py +++ b/backend/modules/calendar/schemas.py @@ -14,4 +14,11 @@ class CalendarEventResponse(CalendarEventCreate): user_id: int class Config: - from_attributes = True \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/modules/nlp/__pycache__/api.cpython-312.pyc b/backend/modules/nlp/__pycache__/api.cpython-312.pyc index bfb7551..78a6a0a 100644 Binary files a/backend/modules/nlp/__pycache__/api.cpython-312.pyc and b/backend/modules/nlp/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/nlp/__pycache__/schemas.cpython-312.pyc b/backend/modules/nlp/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..5b4a3e8 Binary files /dev/null and b/backend/modules/nlp/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/modules/nlp/__pycache__/service.cpython-312.pyc b/backend/modules/nlp/__pycache__/service.cpython-312.pyc index fa577a7..65c73fc 100644 Binary files a/backend/modules/nlp/__pycache__/service.cpython-312.pyc and b/backend/modules/nlp/__pycache__/service.cpython-312.pyc differ diff --git a/backend/modules/nlp/api.py b/backend/modules/nlp/api.py index b495008..34fbb43 100644 --- a/backend/modules/nlp/api.py +++ b/backend/modules/nlp/api.py @@ -1,53 +1,74 @@ # modules/nlp/api.py -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from core.database import get_db -from core.exceptions import bad_request_exception from modules.auth.dependencies import get_current_user from modules.auth.models import User 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.schemas import CalendarEventCreate - +from modules.calendar.schemas import CalendarEventCreate, CalendarEventUpdate router = APIRouter(prefix="/nlp", tags=["nlp"]) @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) - - if "error" in command: - raise bad_request_exception(command["error"]) - - 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} + user_input = request_data.user_input + command_data = process_request(user_input) + intent = command_data["intent"] + params = command_data["params"] + response_text = command_data["response_text"] - case "update_calendar_event": - event = CalendarEventCreate(**command["params"]) - result = update_calendar_event(db, current_user.id, 0, event_data=event) ## PLACEHOLDER - return {"action": "calendar_event_updated", "details": result} - - case "delete_calendar_event": - result = update_calendar_event(db, current_user.id, 0) ## PLACEHOLDER - return {"action": "calendar_event_deleted", "details": result} + if intent == "error": + raise HTTPException(status_code=400, detail=response_text) + + if intent == "clarification_needed": + return {"response": response_text} + + if intent == "unknown": + return {"response": response_text} + + try: + match intent: + case "ask_ai": + ai_answer = ask_ai(**params) + return {"response": ai_answer} - case "unknown": - return {"action": "unknown_command", "details": command["params"]} - case _: - raise bad_request_exception(400, detail="Unrecognized command") \ No newline at end of file + case "get_calendar_events": + result = get_calendar_events(db, current_user.id, **params) + return {"response": response_text, "details": result} + + 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.") \ No newline at end of file diff --git a/backend/modules/nlp/schemas.py b/backend/modules/nlp/schemas.py new file mode 100644 index 0000000..f0de734 --- /dev/null +++ b/backend/modules/nlp/schemas.py @@ -0,0 +1,5 @@ +# modules/nlp/schemas.py +from pydantic import BaseModel + +class ProcessCommandRequest(BaseModel): + user_input: str diff --git a/backend/modules/nlp/service.py b/backend/modules/nlp/service.py index 24c825d..5d92cd2 100644 --- a/backend/modules/nlp/service.py +++ b/backend/modules/nlp/service.py @@ -10,25 +10,53 @@ client = genai.Client(api_key="AIzaSyBrte_mETZJce8qE6cRTSz_fHOjdjlShBk") ### Base prompt for MAIA, used for inital user requests 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: -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. -2. get_calendar_events(start: Optional[datetime], end: Optional[datetime]) -3. add_calendar_event(title: str, description: str, start: datetime, end: Optional[datetime], location: str) -4. update_calendar_event(event_id: int, title: Optional[str], description: Optional[str], start: Optional[datetime], end: Optional[datetime], location: Optional[str]) -5. delete_calendar_event(event_id: int) +Available functions/intents: +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]): Retrieve calendar events. +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]): Update an existing event. Requires event_id. +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", "params": { - "title": "Team Meeting", - "description": "Discuss project updates", - "start": "2025-04-16 15:00:00.000000+00:00", - "end": "2025-04-16 16:00:00.000000+00:00", - "location": "Office" - } + "title": "Meeting", + "description": "Project X", + "start": "2025-04-19 15:00:00.000000+00:00", + "end": null, + "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))+""". @@ -45,6 +73,7 @@ Here is the user request: def process_request(request: str): """ Process the user request using the Google GenAI API. + Expects a JSON response with intent, params, and response_text. """ response = client.models.generate_content( model="gemini-2.0-flash", @@ -52,41 +81,25 @@ def process_request(request: str): config={ "temperature": 0.3, # Less creativity, more factual "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 try: - return json.loads(response.text) - except ValueError: - raise ValueError("Invalid JSON response from AI") + parsed_response = json.loads(response.text) + # Validate required fields + 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): """ diff --git a/interfaces/nativeapp/src/api/auth.ts b/interfaces/nativeapp/src/api/auth.ts deleted file mode 100644 index 93916c6..0000000 --- a/interfaces/nativeapp/src/api/auth.ts +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/interfaces/nativeapp/src/api/calendar.ts b/interfaces/nativeapp/src/api/calendar.ts index 7dc9374..447ebd3 100644 --- a/interfaces/nativeapp/src/api/calendar.ts +++ b/interfaces/nativeapp/src/api/calendar.ts @@ -11,6 +11,7 @@ export const getCalendarEvents = async (start?: Date, end?: Date): Promise { // Explicitly type error as AxiosError const originalRequest = error.config; - // Check if the error has a response object (i.e., server responded with error status) - if (error.response) { + // Check if the error has a response object AND an original request config + if (error.response && originalRequest) { // <-- Added check for originalRequest // 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 Data:', error.response.data); @@ -64,39 +64,58 @@ apiClient.interceptors.response.use( if (error.response.status === 401) { console.warn('[API Client] Unauthorized (401). Token might be expired or invalid.'); - if (!originalRequest?._retry) { - originalRequest._retry = true; // Mark the request as retried to avoid infinite loops + if (originalRequest.url === '/auth/refresh') { + 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 { console.log('[API Client] Attempting token refresh...'); const refreshResponse = await apiClient.post('/auth/refresh', {}, { headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json', }, }); - if (refreshResponse.status === 200) { - const newToken = refreshResponse.data?.accessToken; + if (refreshResponse.status === 200) { + const newToken = refreshResponse.data?.access_token; 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 - if (Platform.OS === 'web') { - await AsyncStorage.setItem(TOKEN_KEY, newToken); - } else { - await SecureStore.setItemAsync(TOKEN_KEY, newToken); - } + // Update the Authorization header for future requests + apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`; + // Safely update original request headers + if (originalRequest.headers) { + originalRequest.headers['Authorization'] = `Bearer ${newToken}`; + } - // Update the Authorization header for future requests - apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`; - originalRequest.headers['Authorization'] = `Bearer ${newToken}`; - - // Retry the original request with the new token - return apiClient(originalRequest); + // Retry the original request (originalRequest is guaranteed to exist here) + return apiClient(originalRequest); + } else { + console.error('[API Client] Invalid token structure received during refresh:', refreshResponse.data); + throw new Error('Invalid token received from server.'); } } - } catch (refreshError) { + } catch (refreshError: any) { console.error('[API Client] Token refresh failed:', refreshError); } } diff --git a/interfaces/nativeapp/src/contexts/AuthContext.tsx b/interfaces/nativeapp/src/contexts/AuthContext.tsx index 02cac4f..a0bcc6e 100644 --- a/interfaces/nativeapp/src/contexts/AuthContext.tsx +++ b/interfaces/nativeapp/src/contexts/AuthContext.tsx @@ -174,7 +174,7 @@ export const AuthProvider: React.FC = ({ children }) => { setAuthToken(null); delete apiClient.defaults.headers.common['Authorization']; await deleteToken(); // Use helper - // Optional backend logout call + await apiClient.post("/auth/logout"); }, []); const contextValue = useMemo(() => ({ @@ -192,7 +192,6 @@ export const AuthProvider: React.FC = ({ children }) => { ); }; -// --- useAuth and AuthLoadingScreen remain the same --- export const useAuth = () => { const context = useContext(AuthContext); if (!context) { diff --git a/interfaces/nativeapp/src/screens/CalendarScreen.tsx b/interfaces/nativeapp/src/screens/CalendarScreen.tsx index 3654f65..54e4647 100644 --- a/interfaces/nativeapp/src/screens/CalendarScreen.tsx +++ b/interfaces/nativeapp/src/screens/CalendarScreen.tsx @@ -36,11 +36,12 @@ const CalendarScreen = () => { // Store events keyed by date *for the list display* const [eventsByDate, setEventsByDate] = useState<{ [key: string]: CalendarEvent[] }>({}); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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); @@ -71,18 +72,28 @@ const CalendarScreen = () => { // Process events for the daily list view const newEventsByDate: { [key: string]: CalendarEvent[] } = {}; fetchedEvents.forEach(event => { - const startDate = parseISO(event.start); - const endDate = parseISO(event.end); + // --- 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 --- - if (!isValid(startDate) || !isValid(endDate)) { - console.warn(`Invalid date found in event ${event.id}`); + 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 } - // Ensure end date is not before start date - const end = endDate < startDate ? startDate : endDate; + // --- 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 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 => { const dateKey = format(dayInInterval, 'yyyy-MM-dd'); if (!newEventsByDate[dateKey]) { @@ -103,14 +114,80 @@ const CalendarScreen = () => { console.error(err); } finally { setIsLoading(false); + console.log("[CAM] isLoading:", isLoading); } }, [isLoading, currentMonthData]); // Include dependencies // --- Initial Fetch --- useEffect(() => { - const initialDate = parseISO(todayString); - fetchEventsForMonth(initialDate); - }, [fetchEventsForMonth, todayString]); + 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) => { @@ -119,6 +196,7 @@ const CalendarScreen = () => { 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 @@ -130,18 +208,29 @@ const CalendarScreen = () => { const marks: { [key: string]: MarkingProps } = {}; // Use MarkingProps type rawEvents.forEach(event => { - const startDate = parseISO(event.start); - const endDate = parseISO(event.end); - const eventColor = event.color || theme.colors.primary; // Use event color or default + // --- 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 --- - 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 } - // Ensure end date is not before start date - const end = endDate < startDate ? startDate : endDate; + // --- 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 - 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) => { 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 startingDay: marks[dateString]?.startingDay || marking.startingDay, 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) if (selectedDate) { marks[selectedDate] = { - ...(marks[selectedDate] || {}), // Keep existing period/dot marks + ...(marks[selectedDate] || {}), selected: true, - // Keep the period color if it exists, otherwise use selection color - 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 + 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, }; } - - // Add today marking (merge with period/selection marking) - // Period marking visually indicates today already if colored. Add dot? - 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 - }; + 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); - 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 || ''; - if (isValid(startDate)) { - // Show date range if it spans multiple days or specific time if single day - if (!isSameDay(startDate, endDate)) { - description = `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}${item.description ? `\n${item.description}` : ''}`; - } else { - description = `Time: ${format(startDate, 'p')}${item.description ? `\n${item.description}` : ''}`; // 'p' is locale-specific time format - } + 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 ( @@ -229,7 +322,7 @@ const CalendarScreen = () => { style={styles.eventItem} titleStyle={{ color: theme.colors.text }} descriptionStyle={{ color: theme.colors.textSecondary }} - descriptionNumberOfLines={3} // Allow more lines for range/details + descriptionNumberOfLines={3} /> ); } diff --git a/interfaces/nativeapp/src/screens/ChatScreen.tsx b/interfaces/nativeapp/src/screens/ChatScreen.tsx index 3c9a831..1b108f2 100644 --- a/interfaces/nativeapp/src/screens/ChatScreen.tsx +++ b/interfaces/nativeapp/src/screens/ChatScreen.tsx @@ -1,19 +1,192 @@ -// src/screens/DashboardScreen.tsx -import React from 'react'; -import { View, StyleSheet } from 'react-native'; -import { Text, useTheme } from 'react-native-paper'; +// src/screens/ChatScreen.tsx +import React, { useState, useCallback, useRef } from 'react'; +import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform, TextInput as RNTextInput, NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native'; +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 [messages, setMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const [isLoading, setIsLoading] = useState(false); // To show activity indicator while AI responds + const flatListRef = useRef(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) => { + 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 ( + + + {item.text} + + {/* Optional: Add timestamp */} + {/* + {item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + */} + + ); + }; + const styles = StyleSheet.create({ - container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background }, - text: { fontSize: 20, color: theme.colors.text } + container: { + 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 ( - - Chat - + + + + 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 + /> + + + + + + + + ); }; -export default DashboardScreen; +export default ChatScreen; \ No newline at end of file diff --git a/interfaces/nativeapp/src/screens/ProfileScreen.tsx b/interfaces/nativeapp/src/screens/ProfileScreen.tsx index 8858165..e45888e 100644 --- a/interfaces/nativeapp/src/screens/ProfileScreen.tsx +++ b/interfaces/nativeapp/src/screens/ProfileScreen.tsx @@ -1,19 +1,124 @@ -// src/screens/DashboardScreen.tsx -import React from 'react'; -import { View, StyleSheet } from 'react-native'; -import { Text, useTheme } from 'react-native-paper'; +// src/screens/ProfileScreen.tsx +import React, { useState, useEffect } from 'react'; +import { View, StyleSheet, ActivityIndicator, Alert } from 'react-native'; +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 { logout } = useAuth(); // Get the logout function + const [userProfile, setUserProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchUserProfile = async () => { + setIsLoading(true); + setError(null); + try { + const response = await apiClient.get('/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({ - container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background }, - text: { fontSize: 20, color: theme.colors.text } + container: { + 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 ( + + + + ); + } + return ( - Profile + + {error && {error}} + + {userProfile ? ( + + + User Profile + Username: {userProfile.username} + Name: {userProfile.name} + UUID: {userProfile.uuid} + Role: {userProfile.role} + + + ) : ( + !error && No profile data available. // Show if no error but no data + )} + + + ); }; -export default DashboardScreen; +export default ProfileScreen; diff --git a/interfaces/nativeapp/src/types/axios.d.ts b/interfaces/nativeapp/src/types/axios.d.ts new file mode 100644 index 0000000..27d8c88 --- /dev/null +++ b/interfaces/nativeapp/src/types/axios.d.ts @@ -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 + } +}