[V0.3] Working dashboard calendar module

This commit is contained in:
c-d-p
2025-04-21 20:09:41 +02:00
parent c158ff4e0e
commit 4f57df8101
15 changed files with 5401 additions and 294 deletions

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 1
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
PEPPER: str = getenv("PEPPER", "")

View File

@@ -3,7 +3,7 @@ from fastapi import APIRouter, Cookie, Depends, HTTPException, status, Request,
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
from modules.auth.models import User
from modules.auth.schemas import UserCreate, UserResponse, Token
from modules.auth.schemas import UserCreate, UserResponse, Token, RefreshTokenRequest, LogoutRequest
from modules.auth.services import create_user
from modules.auth.security import TokenType, get_current_user, oauth2_scheme, create_access_token, create_refresh_token, verify_token, authenticate_user, blacklist_tokens
from sqlalchemy.orm import Session
@@ -20,9 +20,9 @@ def register(user: UserCreate, db: Annotated[Session, Depends(get_db)]):
return create_user(user.username, user.password, user.name, db)
@router.post("/login", response_model=Token)
def login(response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(get_db)]):
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(get_db)]):
"""
Authenticate user and return JWT token.
Authenticate user and return JWT tokens in the response body.
"""
user = authenticate_user(form_data.username, form_data.password, db)
if not user:
@@ -34,40 +34,34 @@ def login(response: Response, form_data: Annotated[OAuth2PasswordRequestForm, De
access_token = create_access_token(data={"sub": user.username}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
refresh_token = create_refresh_token(data={"sub": user.username})
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
response.set_cookie(
key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="Lax", max_age=max_age
)
return {"access_token": access_token, "token_type": "bearer"}
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
@router.post("/refresh")
def refresh_token(request: Request, db: Annotated[Session, Depends(get_db)]):
refresh_token = request.cookies.get("refresh_token")
def refresh_token(payload: RefreshTokenRequest, db: Annotated[Session, Depends(get_db)]):
print("Refreshing token...")
refresh_token = payload.refresh_token
if not refresh_token:
raise unauthorized_exception("Refresh token missing")
raise unauthorized_exception("Refresh token missing in request body")
user_data = verify_token(refresh_token, expected_token_type=TokenType.REFRESH, db=db)
if not user_data:
raise unauthorized_exception("Invalid refresh token")
new_access_token = create_access_token(data={"sub": user_data.username})
return {"access_token": new_access_token, "token_type": "bearer"}
@router.post("/logout")
def logout(response: Response, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], access_token: str = Depends(oauth2_scheme), refresh_token: Optional[str] = Cookie(None, alias="refresh_token")):
def logout(payload: LogoutRequest, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], access_token: str = Depends(oauth2_scheme)):
try:
refresh_token = payload.refresh_token
if not refresh_token:
raise unauthorized_exception("Refresh token not found")
raise unauthorized_exception("Refresh token not found in request body")
blacklist_tokens(
access_token=access_token,
refresh_token=refresh_token,
db=db
)
response.delete_cookie(key="refresh_token")
return {"message": "Logged out successfully"}
except JWTError:

View File

@@ -11,6 +11,12 @@ class TokenData(BaseModel):
username: str | None = None
scopes: list[str] = []
class RefreshTokenRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
refresh_token: str
class UserRole(str, PyEnum):
ADMIN = "admin"
USER = "user"

View File

@@ -58,6 +58,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
# expire = datetime.now(timezone.utc) + timedelta(seconds=5)
to_encode.update({"exp": expire, "token_type": TokenType.ACCESS})
return jwt.encode(
to_encode,

View File

@@ -3,6 +3,7 @@ import apiClient from './client';
import { CalendarEvent, CalendarEventCreate, CalendarEventUpdate } from '../types/calendar';
export const getCalendarEvents = async (start?: Date, end?: Date): Promise<CalendarEvent[]> => {
console.log(`Getting calendar events from ${start} - ${end}`);
try {
const params: Record<string, string> = {};
if (start instanceof Date) {

View File

@@ -8,13 +8,35 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://192.168.255.221:8000/api'; // Use your machine's IP
// 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 ACCESS_TOKEN_KEY = 'maia_access_token'; // Renamed for clarity
const REFRESH_TOKEN_KEY = 'maia_refresh_token'; // Key for refresh token
console.log("Using API Base URL:", API_BASE_URL);
// Helper functions for storage (assuming they are defined above or imported)
// const getToken = async (): Promise<string | null> => { ... };
// const deleteToken = async () => { ... };
// Helper functions for storage
const storeToken = async (key: string, token: string): Promise<void> => {
if (Platform.OS === 'web') {
await AsyncStorage.setItem(key, token);
} else {
await SecureStore.setItemAsync(key, token);
}
};
const getToken = async (key: string): Promise<string | null> => {
if (Platform.OS === 'web') {
return await AsyncStorage.getItem(key);
} else {
return await SecureStore.getItemAsync(key).catch(() => null);
}
};
const deleteToken = async (key: string): Promise<void> => {
if (Platform.OS === 'web') {
await AsyncStorage.removeItem(key);
} else {
await SecureStore.deleteItemAsync(key).catch(() => {}); // Ignore delete error
}
};
const apiClient = axios.create({
@@ -23,16 +45,14 @@ const apiClient = axios.create({
'Content-Type': 'application/json',
},
timeout: 10000,
...(Platform.OS === 'web' ? { withCredentials: true } : {}),
// Remove withCredentials from default config if not needed globally
// ...(Platform.OS === 'web' ? { withCredentials: true } : {}),
});
// --- Request Interceptor remains the same ---
// --- Request Interceptor ---
apiClient.interceptors.request.use(
async (config) => {
// Using AsyncStorage for web token retrieval here too for consistency
const token = Platform.OS === 'web'
? await AsyncStorage.getItem(TOKEN_KEY)
: await SecureStore.getItemAsync(TOKEN_KEY).catch(() => null); // Handle potential SecureStore error
const token = await getToken(ACCESS_TOKEN_KEY); // Use helper
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -46,7 +66,6 @@ apiClient.interceptors.request.use(
}
);
// --- Modified Response Interceptor ---
apiClient.interceptors.response.use(
(response) => {
@@ -56,105 +75,126 @@ apiClient.interceptors.response.use(
async (error: AxiosError) => { // Explicitly type error as AxiosError
const originalRequest = error.config;
// 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)
if (error.response && originalRequest) {
console.error('[API Client] Response Error Status:', error.response.status);
console.error('[API Client] Response Error Data:', error.response.data);
// Handle 401 specifically
if (error.response.status === 401) {
console.warn('[API Client] Unauthorized (401). Token might be expired or invalid.');
// Prevent refresh loops if the refresh endpoint itself returns 401
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
}
console.error('[API Client] Refresh token attempt failed with 401. Clearing tokens.');
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY); // Clear refresh token too
delete apiClient.defaults.headers.common['Authorization'];
return Promise.reject(error); // Reject immediately
// TODO: Trigger logout flow in UI
return Promise.reject(error);
}
// 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;
// Check if we haven't already tried to refresh for this request
// Need to declare _retry on AxiosRequestConfig interface or use a different check
// Using a simple check here, consider a more robust solution if needed
if (!(originalRequest as any)._retry) {
(originalRequest as any)._retry = true;
try {
const storedRefreshToken = await getToken(REFRESH_TOKEN_KEY);
if (!storedRefreshToken) {
console.error('[API Client] No refresh token found to attempt refresh.');
await deleteToken(ACCESS_TOKEN_KEY); // Clear potentially invalid access token
// TODO: Trigger logout flow in UI
return Promise.reject(error); // Reject if no refresh token
}
console.log('[API Client] Attempting token refresh...');
const refreshResponse = await apiClient.post('/auth/refresh', {}, {
headers: {
'Content-Type': 'application/json',
},
});
// Send refresh token in the body, remove withCredentials
const refreshResponse = await apiClient.post('/auth/refresh',
{ refresh_token: storedRefreshToken }, // Send token in body
{
// No withCredentials needed
headers: { 'Content-Type': 'application/json' },
}
);
if (refreshResponse.status === 200) {
const newToken = refreshResponse.data?.access_token;
const newAccessToken = refreshResponse.data?.access_token; // Expecting only access token
if (newToken) {
if (newAccessToken) {
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);
}
await storeToken(ACCESS_TOKEN_KEY, newAccessToken); // Store new access token
// Update the Authorization header for future requests
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
// Safely update original request headers
// Update the default Authorization header for subsequent requests
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
// Update the Authorization header in the original request config
if (originalRequest.headers) {
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
}
// Retry the original request (originalRequest is guaranteed to exist here)
// Retry the original request with the new access token
return apiClient(originalRequest);
} else {
console.error('[API Client] Invalid token structure received during refresh:', refreshResponse.data);
throw new Error('Invalid token received from server.');
throw new Error('Invalid access token received from server during refresh.');
}
} else {
// Handle non-200 responses from refresh endpoint if necessary
console.error(`[API Client] Token refresh endpoint returned status ${refreshResponse.status}`);
throw new Error(`Refresh failed with status ${refreshResponse.status}`);
}
} catch (refreshError: any) {
console.error('[API Client] Token refresh failed:', refreshError);
// Clear tokens if refresh fails
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization'];
// TODO: Trigger logout flow in UI
// Propagate the refresh error instead of the original 401
return Promise.reject(refreshError);
}
} else {
console.warn('[API Client] Already retried this request. Not attempting refresh again.');
}
// Clear potentially invalid token due to 401
console.log('[API Client] Clearing potentially invalid token due to 401.');
if (Platform.OS === 'web') {
await AsyncStorage.removeItem(TOKEN_KEY);
} else {
await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => {}); // Ignore delete error
}
// If retry flag was already set, or if refresh attempt failed and fell through,
// we might still end up here. Ensure tokens are cleared if a 401 persists.
console.log('[API Client] Clearing tokens due to persistent 401 or failed refresh.');
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization'];
// TODO: Trigger logout flow in UI
// How to trigger logout? Propagating error is simplest for now.
}
} // End of 401 handling
} else if (error.request) {
// The request was made but no response was received
// (e.g., network error, CORS block preventing response reading, server timeout)
console.error('[API Client] Network Error or No Response:', error.message);
// Log the request object for debugging if needed
// console.error('[API Client] Error Request Object:', error.request);
// If CORS is suspected, this is often where the error ends up.
if (error.message.toLowerCase().includes('network error') && Platform.OS === 'web') {
console.warn('[API Client] Hint: A "Network Error" on web often masks a CORS issue. Check browser console & backend CORS config.');
}
} else {
// Something happened in setting up the request that triggered an Error
console.error('[API Client] Request Setup Error (Interceptor):', error.message);
}
// Log the config that failed (optional, can be verbose)
// console.error("[API Client] Failing Request Config:", error.config);
// Always reject the promise to propagate the error
return Promise.reject(error);
}
);
export default apiClient;
// Add functions to manage tokens globally if needed by UI components
export const storeTokens = async (accessToken: string, refreshToken?: string) => {
await storeToken(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) {
await storeToken(REFRESH_TOKEN_KEY, refreshToken);
}
apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; // Update client instance
};
export const clearTokens = async () => {
await deleteToken(ACCESS_TOKEN_KEY);
await deleteToken(REFRESH_TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization']; // Clear client instance header
};
export const getRefreshToken = () => getToken(REFRESH_TOKEN_KEY);
export const getAccessToken = () => getToken(ACCESS_TOKEN_KEY);

View File

@@ -1,15 +1,11 @@
// src/contexts/AuthContext.tsx
import React, { createContext, useState, useEffect, useContext, useMemo, useCallback } from 'react';
import { Platform, ActivityIndicator, View, StyleSheet } from 'react-native'; // Import Platform
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage'; // Use AsyncStorage for web localStorage
import apiClient from '../api/client';
import { useTheme } from 'react-native-paper';
const TOKEN_KEY = 'maia_access_token'; // Use the same key
import { getAccessToken, getRefreshToken, storeTokens, clearTokens } from '../api/client';
interface AuthContextData {
authToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
@@ -17,7 +13,6 @@ interface AuthContextData {
}
const AuthContext = createContext<AuthContextData>({
authToken: null,
isAuthenticated: false,
isLoading: true,
login: async () => { throw new Error('AuthContext not initialized'); },
@@ -28,104 +23,42 @@ interface AuthProviderProps {
children: React.ReactNode;
}
// Helper functions for platform-specific storage
const storeToken = async (token: string) => {
if (Platform.OS === 'web') {
try {
// Use AsyncStorage for web (polyfilled to localStorage)
await AsyncStorage.setItem(TOKEN_KEY, token);
} catch (e) {
console.error("Failed to save token to web storage", e);
}
} else {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
};
const getToken = async (): Promise<string | null> => {
if (Platform.OS === 'web') {
try {
return await AsyncStorage.getItem(TOKEN_KEY);
} catch (e) {
console.error("Failed to get token from web storage", e);
return null;
}
} else {
// SecureStore might throw if not available, handle gracefully
try {
return await SecureStore.getItemAsync(TOKEN_KEY);
} catch (e) {
console.error("Failed to get token from secure store", e);
// If SecureStore fails on native, treat as no token found
return null;
}
}
};
const deleteToken = async () => {
if (Platform.OS === 'web') {
try {
await AsyncStorage.removeItem(TOKEN_KEY);
} catch (e) {
console.error("Failed to remove token from web storage", e);
}
} else {
// Avoid potential crash if SecureStore is unavailable
try {
await SecureStore.deleteItemAsync(TOKEN_KEY);
} catch (e) {
console.error("Failed to delete token from secure store", e);
}
}
};
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [authToken, setAuthToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticatedState, setIsAuthenticatedState] = useState<boolean>(false);
const loadToken = useCallback(async () => {
console.log("[AuthContext] loadToken: Starting..."); // Log: Start
const checkAuthStatus = useCallback(async () => {
const token = await getAccessToken();
const hasToken = !!token;
if (hasToken !== isAuthenticatedState) {
setIsAuthenticatedState(hasToken);
}
return hasToken;
}, [isAuthenticatedState]);
const loadInitialAuth = useCallback(async () => {
setIsLoading(true);
try {
console.log("[AuthContext] loadToken: Calling getToken()..."); // Log: Before await
const storedToken = await getToken(); // Use helper
console.log("[AuthContext] loadToken: getToken() returned:", storedToken); // Log: After await
if (storedToken) {
console.log('[AuthContext] loadToken: Token found. Setting state and headers.'); // Log: Token Found Path
setAuthToken(storedToken);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${storedToken}`;
} else {
console.log('[AuthContext] loadToken: No token found. Clearing state and headers.'); // Log: No Token Path
setAuthToken(null);
delete apiClient.defaults.headers.common['Authorization'];
}
console.log('[AuthContext] loadToken: Try block finished successfully.'); // Log: Try Success
console.log("[AuthContext] loadInitialAuth: Checking initial auth status");
await checkAuthStatus();
console.log("[AuthContext] loadInitialAuth: Initial check complete.");
} catch (error) {
// **Log the actual error object**
console.error("[AuthContext] loadToken: Caught error:", error); // Log: Catch Block
setAuthToken(null); // Ensure logged out state on error
delete apiClient.defaults.headers.common['Authorization'];
console.error("[AuthContext] loadInitialAuth: Error loading initial token:", error);
await clearTokens();
setIsAuthenticatedState(false);
} finally {
console.log("[AuthContext] loadToken: Entering finally block."); // Log: Finally Start
setIsLoading(false);
console.log("[AuthContext] loadToken: setIsLoading(false) called."); // Log: Finally End
}
}, []);
}, [checkAuthStatus]);
useEffect(() => {
console.log("[AuthContext] useEffect: Component mounted, calling loadToken."); // Log: useEffect call
loadToken();
}, [loadToken]);
loadInitialAuth();
}, [loadInitialAuth]);
const login = useCallback(async (username: string, password: string) => {
console.log("[AuthContext] login: Function called with:", username); // Log: Function entry
console.log("[AuthContext] login: Function called with:", username);
try {
console.log("[AuthContext] login: Preparing to call apiClient.post for /auth/login");
console.log("[AuthContext] login: Data being sent:", { username: username, password: password });
// const response = await apiClient.post(`/auth/login?grant_type=password&username=${username}&password=${password}`);
const response = await apiClient.post(
'/auth/login',
'grant_type=password&username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + '&scope=&client_id=&client_secret=',
@@ -137,53 +70,61 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
}
);
console.log("[AuthContext] login: apiClient.post successful, response status:", response?.status); // Log success
console.log("[AuthContext] login: apiClient.post successful, response status:", response?.status);
const { access_token, refresh_token } = response.data;
console.log("[AuthContext] login: Response data received.");
const { access_token } = response.data;
if (!access_token || typeof access_token !== 'string') {
if (!access_token || typeof access_token !== 'string' || !refresh_token) {
console.error("[AuthContext] login: Invalid token structure received:", response.data);
throw new Error('Invalid token received from server.');
}
console.log('[AuthContext] login: Login successful, received token.');
setAuthToken(access_token);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
await storeToken(access_token); // Use helper
console.log('[AuthContext] login: Login successful, storing tokens.');
await storeTokens(access_token, refresh_token);
setIsAuthenticatedState(true);
} catch (error: any) {
// --- Log the error object *itself* ---
console.error("[AuthContext] login: Caught Error Object:", error);
// --- Check if it's an Axios error with config details ---
if (error.isAxiosError) {
console.error("[AuthContext] login: Axios Error Details:");
console.error(" Request Config:", error.config);
console.error(" Response:", error.response); // This will likely still be undefined
console.error(" Response:", error.response?.status, error.response?.data);
console.error(" Message:", error.message);
}
// Original logging (might be redundant now but keep for context)
console.error("Login failed:", error.response?.data || error.message);
throw error; // Re-throw
await clearTokens();
setIsAuthenticatedState(false);
throw error;
}
}, []);
const logout = useCallback(async () => {
console.log('Logging out.');
setAuthToken(null);
delete apiClient.defaults.headers.common['Authorization'];
await deleteToken(); // Use helper
await apiClient.post("/auth/logout");
console.log('[AuthContext] logout: Logging out.');
const refreshToken = await getRefreshToken();
if (!refreshToken) {
console.warn('[AuthContext] logout: No refresh token found to send to backend.');
}
try {
if (refreshToken) {
console.log('[AuthContext] logout: Calling backend /auth/logout');
await apiClient.post("/auth/logout", { refresh_token: refreshToken });
console.log('[AuthContext] logout: Backend logout call successful (or ignored error).');
}
} catch (error: any) {
console.error('[AuthContext] logout: Error calling backend logout:', error.response?.data || error.message);
} finally {
await clearTokens();
setIsAuthenticatedState(false);
console.log('[AuthContext] logout: Local tokens cleared and state updated.');
}
}, []);
const contextValue = useMemo(() => ({
authToken,
isAuthenticated: !!authToken,
isAuthenticated: isAuthenticatedState,
isLoading,
login,
logout,
}), [authToken, isLoading, login, logout]);
}), [isAuthenticatedState, isLoading, login, logout]);
return (
<AuthContext.Provider value={contextValue}>

View File

@@ -1,9 +1,20 @@
// src/screens/ChatScreen.tsx
import React, { useState, useCallback, useRef, useEffect } 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 {
View,
StyleSheet,
FlatList,
KeyboardAvoidingView,
Platform,
TextInput as RNTextInput, // Keep if needed for specific props, otherwise can remove
NativeSyntheticEvent,
TextInputKeyPressEventData,
ActivityIndicator // Import ActivityIndicator
} from 'react-native';
import { Text, useTheme, TextInput, IconButton } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import apiClient from '../api/client'; // Import the apiClient
import { useRoute, RouteProp } from '@react-navigation/native'; // Import useRoute and RouteProp
// Define the structure for a message
interface Message {
@@ -26,73 +37,49 @@ interface ChatHistoryResponse {
timestamp: string; // Backend sends ISO string
}
// Define the type for the navigation route parameters
type RootStackParamList = {
Chat: { // Assuming 'Chat' is the name of the route for this screen
initialQuestion?: string; // Make initialQuestion optional
};
// Add other routes here if needed
};
type ChatScreenRouteProp = RouteProp<RootStackParamList, 'Chat'>;
const ChatScreen = () => {
const theme = useTheme();
const route = useRoute<ChatScreenRouteProp>(); // Get route params
const initialQuestion = route.params?.initialQuestion; // Extract initialQuestion
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isHistoryLoading, setIsHistoryLoading] = useState(true); // Add state for history loading
const [isLoading, setIsLoading] = useState(false); // Loading state for sending messages
const [isHistoryLoading, setIsHistoryLoading] = useState(true); // Loading state for initial history fetch
const flatListRef = useRef<FlatList>(null);
// --- Load messages from backend API on mount ---
useEffect(() => {
const loadHistory = async () => {
setIsHistoryLoading(true);
try {
console.log("[ChatScreen] Fetching chat history from /nlp/history");
const response = await apiClient.get<ChatHistoryResponse[]>('/nlp/history');
console.log("[ChatScreen] Received history:", response.data);
if (response.data && Array.isArray(response.data)) {
// Map backend response to frontend Message format
const historyMessages = response.data.map((msg) => ({
id: msg.id.toString(), // Convert backend ID to string for keyExtractor
text: msg.text,
sender: msg.sender,
timestamp: new Date(msg.timestamp), // Convert ISO string to Date
}));
setMessages(historyMessages);
} else {
console.warn("[ChatScreen] Received invalid history data:", response.data);
setMessages([]); // Set to empty array if data is invalid
}
} catch (error: any) {
console.error("Failed to load chat history from backend:", error.response?.data || error.message || error);
// Optionally, show an error message to the user
// For now, just start with an empty chat
setMessages([]);
} finally {
setIsHistoryLoading(false);
}
};
loadHistory();
}, []); // Empty dependency array ensures this runs only once on mount
// Function to handle sending a message
const handleSend = useCallback(async () => {
const trimmedText = inputText.trim();
if (!trimmedText || isLoading) return; // Prevent sending while loading
// --- Function to send a message to the backend --- (Extracted logic)
const sendMessageToApi = useCallback(async (textToSend: string) => {
if (!textToSend) return; // Don't send empty messages
const userMessage: Message = {
id: Date.now().toString() + '-user', // Temporary frontend ID
text: trimmedText,
text: textToSend,
sender: 'user',
timestamp: new Date(),
};
// Add user message optimistically
setMessages(prevMessages => [...prevMessages, userMessage]);
setInputText('');
setIsLoading(true);
// Scroll to bottom after sending user message
// Scroll to bottom after adding 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<NlpResponse>('/nlp/process-command', { user_input: trimmedText });
console.log(`[ChatScreen] Sending to /nlp/process-command: ${textToSend}`);
const response = await apiClient.post<NlpResponse>('/nlp/process-command', { user_input: textToSend });
console.log("[ChatScreen] Received response:", response.data);
const aiResponses: Message[] = [];
@@ -100,7 +87,7 @@ const ChatScreen = () => {
response.data.responses.forEach((responseText, index) => {
aiResponses.push({
id: `${Date.now()}-ai-${index}`, // Temporary frontend ID
text: responseText || "...",
text: responseText || "...", // Handle potentially empty strings
sender: 'ai',
timestamp: new Date(),
});
@@ -109,7 +96,7 @@ const ChatScreen = () => {
console.warn("[ChatScreen] Received invalid or empty responses array:", response.data);
aiResponses.push({
id: Date.now().toString() + '-ai-fallback',
text: "Sorry, I didn't get a valid response.",
text: "Sorry, I couldn't process that properly.",
sender: 'ai',
timestamp: new Date(),
});
@@ -133,13 +120,92 @@ const ChatScreen = () => {
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
}
// --- End API Call ---
// NOTE: Removed `messages` from dependency array to prevent potential loops.
// State updates within useCallback using the functional form `setMessages(prev => ...)`
// don't require the state itself as a dependency.
}, []);
}, [inputText, isLoading, messages]); // Add isLoading and messages to dependency array
// --- Load messages from backend API on mount & handle initial question ---
useEffect(() => {
let isMounted = true; // Flag to prevent state updates on unmounted component
const loadHistoryAndSendInitial = async () => {
console.log("[ChatScreen] Component mounted. Loading history...");
setIsHistoryLoading(true);
let historyLoadedSuccessfully = false;
try {
const response = await apiClient.get<ChatHistoryResponse[]>('/nlp/history');
if (isMounted) {
console.log("[ChatScreen] Received history:", response.data);
if (response.data && Array.isArray(response.data)) {
const historyMessages = response.data.map((msg) => ({
id: msg.id.toString(),
text: msg.text,
sender: msg.sender,
timestamp: new Date(msg.timestamp),
}));
setMessages(historyMessages);
historyLoadedSuccessfully = true; // Mark history as loaded
} else {
console.warn("[ChatScreen] Received invalid history data:", response.data);
setMessages([]);
}
}
} catch (error: any) {
if (isMounted) {
console.error("Failed to load chat history:", error.response?.data || error.message || error);
setMessages([]); // Clear messages on error
}
} finally {
if (isMounted) {
setIsHistoryLoading(false);
console.log("[ChatScreen] History loading finished. History loaded:", historyLoadedSuccessfully);
// Send initial question *after* history load attempt, if provided
if (initialQuestion) {
console.log("[ChatScreen] Initial question provided:", initialQuestion);
// Check if the initial question is already the last message from history (simple check)
const lastMessageText = messages[messages.length - 1]?.text;
if (lastMessageText !== initialQuestion) {
console.log("[ChatScreen] Sending initial question now.");
// Use a timeout to ensure history state update is processed before sending
setTimeout(() => sendMessageToApi(initialQuestion), 0);
} else {
console.log("[ChatScreen] Initial question seems to match last history message, not sending again.");
}
} else {
console.log("[ChatScreen] No initial question provided.");
}
}
}
};
loadHistoryAndSendInitial();
return () => {
isMounted = false; // Cleanup function to set flag on unmount
console.log("[ChatScreen] Component unmounted.");
};
// Run only once on mount. `initialQuestion` is stable after mount.
// `sendMessageToApi` is memoized by useCallback.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuestion, sendMessageToApi]);
// Function to handle sending a message via input button
const handleSend = useCallback(async () => {
const trimmedText = inputText.trim();
if (!trimmedText || isLoading) return;
setInputText(''); // Clear input immediately
await sendMessageToApi(trimmedText); // Use the extracted function
}, [inputText, isLoading, sendMessageToApi]);
// Function to handle Enter key press for sending
const handleKeyPress = (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
// Check if Enter is pressed without Shift key
if (e.nativeEvent.key === 'Enter' && !(e.nativeEvent as any).shiftKey) {
e.preventDefault(); // Prevent new line
handleSend();
e.preventDefault(); // Prevent default behavior (like newline)
handleSend(); // Trigger send action
}
};
@@ -164,6 +230,12 @@ const ChatScreen = () => {
flex: 1,
backgroundColor: theme.colors.background,
},
loadingContainer: { // Centering container for loading indicator
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.colors.background,
},
keyboardAvoidingContainer: { // Style for KAV
flex: 1,
},
@@ -181,39 +253,33 @@ const ChatScreen = () => {
paddingVertical: 8, // Add some vertical padding
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: theme.colors.outlineVariant,
backgroundColor: theme.colors.background, // Or theme.colors.surface
backgroundColor: theme.colors.surface, // Use surface color for input area
},
textInput: {
flex: 1, // Take available horizontal space
marginRight: 8,
backgroundColor: theme.colors.surface,
paddingTop: 10, // Keep the vertical alignment fix for placeholder
paddingHorizontal: 10,
// Add some vertical padding inside the input itself
paddingVertical: Platform.OS === 'ios' ? 10 : 5, // Adjust padding for different platforms if needed
backgroundColor: theme.colors.surface, // Match container background
// Remove explicit padding if mode="outlined" handles it well
maxHeight: 100, // Optional: prevent input from getting too tall with multiline
},
sendButton: {
marginVertical: 4, // Adjust vertical alignment if needed
// Ensure button doesn't shrink
height: 40, // Match TextInput height approx.
justifyContent: 'center',
margin: 0, // Remove default margins if IconButton has them
},
messageBubble: {
maxWidth: '80%',
padding: 10,
borderRadius: 15,
padding: 12, // Slightly larger padding
borderRadius: 18, // More rounded corners
marginBottom: 10,
},
userBubble: {
alignSelf: 'flex-end',
backgroundColor: theme.colors.primary,
borderBottomRightRadius: 5,
borderBottomRightRadius: 5, // Keep the chat bubble tail effect
},
aiBubble: {
alignSelf: 'flex-start',
backgroundColor: theme.colors.surfaceVariant,
borderBottomLeftRadius: 5,
borderBottomLeftRadius: 5, // Keep the chat bubble tail effect
},
timestamp: {
fontSize: 10,
@@ -223,13 +289,12 @@ const ChatScreen = () => {
}
});
// Optionally, show a loading indicator while history loads
// Show a loading indicator while history loads
if (isHistoryLoading) {
return (
<SafeAreaView style={styles.container} edges={['bottom', 'left', 'right']}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Loading chat history...</Text>
</View>
<SafeAreaView style={styles.loadingContainer} edges={['bottom', 'left', 'right']}>
<ActivityIndicator animating={true} size="large" color={theme.colors.primary} />
<Text style={{ marginTop: 10, color: theme.colors.onBackground }}>Loading chat history...</Text>
</SafeAreaView>
);
}
@@ -237,13 +302,9 @@ const ChatScreen = () => {
return (
<SafeAreaView style={styles.container} edges={['bottom', 'left', 'right']}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingContainer} // Use style with flex: 1
// Use 'padding' for both iOS and Android
behavior={Platform.OS === "ios" ? "padding" : "padding"}
// Remove keyboardVerticalOffset for Android when using padding.
// Keep iOS offset if needed (e.g., for header).
// Assuming headerHeight might be needed for iOS, otherwise set to 0.
keyboardVerticalOffset={Platform.OS === "ios" ? 60 : 0} // Example iOS offset, adjust if necessary
style={styles.keyboardAvoidingContainer}
behavior={Platform.OS === "ios" ? "padding" : "height"} // Use height for Android if padding causes issues
keyboardVerticalOffset={Platform.OS === "ios" ? 60 : 0} // Adjust as needed
>
{/* List container takes available space */}
<View style={styles.listContainer}>
@@ -252,9 +313,11 @@ const ChatScreen = () => {
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.messageList} // Padding inside the scrollable content
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })}
onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })}
contentContainerStyle={styles.messageList}
// Optimization: remove onContentSizeChange/onLayout if not strictly needed for scrolling
// onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })}
// onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })}
// Consider initialScrollIndex or other props if performance is an issue
/>
</View>
@@ -264,13 +327,14 @@ const ChatScreen = () => {
style={styles.textInput}
value={inputText}
onChangeText={setInputText}
placeholder="Type your message..."
mode="outlined" // Keep outlined or flat as preferred
placeholder="Ask MAIA..."
mode="outlined"
multiline
onKeyPress={handleKeyPress}
onKeyPress={handleKeyPress} // Use onKeyPress for web/desktop-like Enter behavior
blurOnSubmit={false} // Keep false for multiline + send button
disabled={isLoading}
dense // Try making the input slightly smaller vertically
disabled={isLoading} // Disable input while AI is responding
outlineStyle={{ borderRadius: 20 }} // Make input more rounded
dense // Reduce vertical padding
/>
<IconButton
icon="send"
@@ -280,7 +344,8 @@ const ChatScreen = () => {
mode="contained"
iconColor={theme.colors.onPrimary}
containerColor={theme.colors.primary}
style={styles.sendButton} // Apply style for alignment
style={styles.sendButton}
animated // Add subtle animation
/>
</View>
</KeyboardAvoidingView>

View File

@@ -1,18 +1,212 @@
// src/screens/DashboardScreen.tsx
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Text, useTheme } from 'react-native-paper';
import React, { useState, useEffect } from 'react'; // Added useEffect
import { View, StyleSheet, ScrollView } from 'react-native'; // Added ScrollView
import { Text, TextInput, Button, useTheme, Card, List, Divider } from 'react-native-paper'; // Added Card, List, Divider
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { format, addDays, startOfDay, isSameDay, parseISO, endOfDay } from 'date-fns'; // Added date-fns imports
import { getCalendarEvents } from '../api/calendar';
import { CalendarEvent } from '../types/calendar';
// Placeholder for the TODO component
const TodoComponent = () => (
<View style={{ marginVertical: 10, padding: 10, borderWidth: 1, borderColor: 'grey' }}>
<Text>TODO Component Placeholder</Text>
</View>
);
// --- Calendar Preview Component Implementation ---
const CalendarPreview = () => {
const theme = useTheme();
const [eventsByDay, setEventsByDay] = useState<{ [key: string]: CalendarEvent[] }>({});
const today = startOfDay(new Date());
const days = [today, addDays(today, 1), addDays(today, 2)];
useEffect(() => {
const fetchAndProcessEvents = async () => {
try {
// Fetch events for the entire 3-day range once
const endDate = endOfDay(addDays(today, 2)); // Calculate end date to include the whole day
const allEvents = await getCalendarEvents(today, endDate); // Fetch events until the end of the third day
// Process events: Group by day
const groupedEvents: { [key: string]: CalendarEvent[] } = {};
days.forEach(day => {
const dayStr = format(day, 'yyyy-MM-dd');
// Filter events for the current day and sort them
groupedEvents[dayStr] = allEvents
.filter(event => isSameDay(parseISO(event.start), day))
.sort((a, b) => parseISO(a.start).getTime() - parseISO(b.start).getTime());
});
setEventsByDay(groupedEvents);
} catch (error) {
console.error("Failed to fetch calendar events:", error);
// Optionally, set an error state here to display to the user
}
};
fetchAndProcessEvents();
}, []); // Run once on mount
const formatDateHeader = (date: Date): string => {
if (isSameDay(date, today)) return `Today, ${format(date, 'MMMM d')}`;
if (isSameDay(date, addDays(today, 1))) return `Tomorrow, ${format(date, 'MMMM d')}`;
return format(date, 'EEEE, MMMM d');
};
const styles = StyleSheet.create({
card: {
marginVertical: 8,
backgroundColor: theme.colors.background,
},
dayHeader: {
fontSize: 16,
fontWeight: 'bold',
paddingLeft: 16,
paddingTop: 12,
paddingBottom: 8,
color: theme.colors.primary,
},
listItem: {
paddingVertical: 4, // Reduce padding slightly
},
eventTime: {
fontSize: 14,
color: theme.colors.onSurfaceVariant, // Ensure contrast
},
eventTitle: {
fontSize: 15,
color: theme.colors.onSurface,
},
noEventsText: {
paddingLeft: 16,
paddingBottom: 12,
fontStyle: 'italic',
color: theme.colors.onSurfaceDisabled,
},
divider: {
marginHorizontal: 16,
}
});
return (
<Card style={styles.card} elevation={1}>
<Card.Content style={{ paddingHorizontal: 0, paddingVertical: 0 }}>
{days.map((day, index) => {
const dayStr = format(day, 'yyyy-MM-dd');
const dailyEvents = eventsByDay[dayStr] || [];
return (
<View key={dayStr}>
<Text style={styles.dayHeader}>{formatDateHeader(day)}</Text>
{dailyEvents.length > 0 ? (
dailyEvents.map((event, eventIndex) => (
<React.Fragment key={event.id}>
<List.Item
title={event.title}
titleStyle={styles.eventTitle}
description={format(parseISO(event.start), 'p')} // Format time like '1:30 PM'
descriptionStyle={styles.eventTime}
style={styles.listItem}
left={props => <List.Icon {...props} icon="circle-small" />} // Simple indicator
/>
{eventIndex < dailyEvents.length - 1 && <Divider style={styles.divider} />}
</React.Fragment>
))
) : (
<Text style={styles.noEventsText}>No events scheduled.</Text>
)}
{/* Add divider between days, except after the last day */}
{index < days.length - 1 && <Divider style={{marginTop: 8}} />}
</View>
);
})}
</Card.Content>
</Card>
);
};
// --- End Calendar Preview Component ---
// Define the type for the navigation stack parameters
// Ensure this matches the navigator's configuration
type RootStackParamList = {
Dashboard: undefined; // No params expected for Dashboard itself
Chat: { initialQuestion?: string }; // Chat screen expects an optional initialQuestion
// Add other screens in your stack here
};
// Define the specific navigation prop type for DashboardScreen
type DashboardScreenNavigationProp = StackNavigationProp<RootStackParamList, 'Dashboard'>;
const DashboardScreen = () => {
const theme = useTheme();
// Use the specific navigation prop type
const navigation = useNavigation<DashboardScreenNavigationProp>();
const [question, setQuestion] = useState('');
const handleQuestionSubmit = () => {
const trimmedQuestion = question.trim();
if (trimmedQuestion) {
// Navigate to ChatScreen, passing the question as a param
navigation.navigate('Chat', { initialQuestion: trimmedQuestion });
// console.log('Navigating to ChatScreen with question:', question); // Keep for debugging if needed
setQuestion(''); // Clear input after submission
}
};
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,
// alignItems: 'center', // Keep removed
justifyContent: 'flex-start',
padding: 16,
backgroundColor: theme.colors.background
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
color: theme.colors.primary, // Use theme color
alignSelf: 'center' // Center the title
},
inputContainer: {
marginBottom: 20,
},
textInput: {
marginBottom: 10,
},
scrollViewContent: {
paddingBottom: 20, // Add padding at the bottom of the scroll view
}
});
return (
<View style={styles.container}>
<Text style={styles.text}>Dashboard</Text>
</View>
// Wrap content in ScrollView to handle potential overflow
<ScrollView style={{ flex: 1, backgroundColor: theme.colors.background }} contentContainerStyle={styles.scrollViewContent}>
<View style={styles.container}>
<Text style={styles.title}>Dashboard</Text>
{/* AI Question Input */}
<View style={styles.inputContainer}>
<TextInput
label="Ask MAIA anything..."
value={question}
onChangeText={setQuestion}
mode="outlined" // Use outlined style for better visibility
style={styles.textInput}
onSubmitEditing={handleQuestionSubmit} // Allow submission via keyboard
/>
<Button mode="contained" onPress={handleQuestionSubmit}>
Ask
</Button>
</View>
{/* Calendar Preview */}
<CalendarPreview />
{/* TODO Component */}
<TodoComponent />
</View>
</ScrollView>
);
};

4859
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@react-navigation/stack": "^7.2.10"
}
}