[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" JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 1
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 REFRESH_TOKEN_EXPIRE_DAYS: int = 7
PEPPER: str = getenv("PEPPER", "") 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 fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError from jose import JWTError
from modules.auth.models import User 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.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 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 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) return create_user(user.username, user.password, user.name, db)
@router.post("/login", response_model=Token) @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) user = authenticate_user(form_data.username, form_data.password, db)
if not user: 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)) 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}) refresh_token = create_refresh_token(data={"sub": user.username})
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
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"}
@router.post("/refresh") @router.post("/refresh")
def refresh_token(request: Request, db: Annotated[Session, Depends(get_db)]): def refresh_token(payload: RefreshTokenRequest, db: Annotated[Session, Depends(get_db)]):
refresh_token = request.cookies.get("refresh_token") print("Refreshing token...")
refresh_token = payload.refresh_token
if not 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) user_data = verify_token(refresh_token, expected_token_type=TokenType.REFRESH, db=db)
if not user_data: if not user_data:
raise unauthorized_exception("Invalid refresh token") raise unauthorized_exception("Invalid refresh token")
new_access_token = create_access_token(data={"sub": user_data.username}) new_access_token = create_access_token(data={"sub": user_data.username})
return {"access_token": new_access_token, "token_type": "bearer"} return {"access_token": new_access_token, "token_type": "bearer"}
@router.post("/logout") @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: try:
refresh_token = payload.refresh_token
if not 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( blacklist_tokens(
access_token=access_token, access_token=access_token,
refresh_token=refresh_token, refresh_token=refresh_token,
db=db db=db
) )
response.delete_cookie(key="refresh_token")
return {"message": "Logged out successfully"} return {"message": "Logged out successfully"}
except JWTError: except JWTError:

View File

@@ -11,6 +11,12 @@ class TokenData(BaseModel):
username: str | None = None username: str | None = None
scopes: list[str] = [] scopes: list[str] = []
class RefreshTokenRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
refresh_token: str
class UserRole(str, PyEnum): class UserRole(str, PyEnum):
ADMIN = "admin" ADMIN = "admin"
USER = "user" 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 expire = datetime.now(timezone.utc) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 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}) to_encode.update({"exp": expire, "token_type": TokenType.ACCESS})
return jwt.encode( return jwt.encode(
to_encode, to_encode,

View File

@@ -3,6 +3,7 @@ import apiClient from './client';
import { CalendarEvent, CalendarEventCreate, CalendarEventUpdate } from '../types/calendar'; import { CalendarEvent, CalendarEventCreate, CalendarEventUpdate } from '../types/calendar';
export const getCalendarEvents = async (start?: Date, end?: Date): Promise<CalendarEvent[]> => { export const getCalendarEvents = async (start?: Date, end?: Date): Promise<CalendarEvent[]> => {
console.log(`Getting calendar events from ${start} - ${end}`);
try { try {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (start instanceof Date) { 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.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://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 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); console.log("Using API Base URL:", API_BASE_URL);
// Helper functions for storage (assuming they are defined above or imported) // Helper functions for storage
// const getToken = async (): Promise<string | null> => { ... }; const storeToken = async (key: string, token: string): Promise<void> => {
// const deleteToken = async () => { ... }; 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({ const apiClient = axios.create({
@@ -23,16 +45,14 @@ const apiClient = axios.create({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 10000, 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( apiClient.interceptors.request.use(
async (config) => { async (config) => {
// Using AsyncStorage for web token retrieval here too for consistency const token = await getToken(ACCESS_TOKEN_KEY); // Use helper
const token = Platform.OS === 'web'
? await AsyncStorage.getItem(TOKEN_KEY)
: await SecureStore.getItemAsync(TOKEN_KEY).catch(() => null); // Handle potential SecureStore error
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
@@ -46,7 +66,6 @@ apiClient.interceptors.request.use(
} }
); );
// --- Modified Response Interceptor --- // --- Modified Response Interceptor ---
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => { (response) => {
@@ -56,105 +75,126 @@ 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 AND an original request config if (error.response && originalRequest) {
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 Status:', error.response.status);
console.error('[API Client] Response Error Data:', error.response.data); console.error('[API Client] Response Error Data:', error.response.data);
// Handle 401 specifically
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.');
// Prevent refresh loops if the refresh endpoint itself returns 401
if (originalRequest.url === '/auth/refresh') { if (originalRequest.url === '/auth/refresh') {
console.error('[API Client] Refresh token attempt failed with 401. Not retrying.'); console.error('[API Client] Refresh token attempt failed with 401. Clearing tokens.');
// Clear token and reject without retry await deleteToken(ACCESS_TOKEN_KEY);
if (Platform.OS === 'web') { await deleteToken(REFRESH_TOKEN_KEY); // Clear refresh token too
await AsyncStorage.removeItem(TOKEN_KEY);
} else {
await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => {}); // Ignore delete error
}
delete apiClient.defaults.headers.common['Authorization']; 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 // Check if we haven't already tried to refresh for this request
// and if originalRequest exists (already checked above) // Need to declare _retry on AxiosRequestConfig interface or use a different check
if (!originalRequest._retry) { // Now TS knows _retry exists due to declaration file // Using a simple check here, consider a more robust solution if needed
originalRequest._retry = true; if (!(originalRequest as any)._retry) {
(originalRequest as any)._retry = true;
try { 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...'); console.log('[API Client] Attempting token refresh...');
const refreshResponse = await apiClient.post('/auth/refresh', {}, { // Send refresh token in the body, remove withCredentials
headers: { const refreshResponse = await apiClient.post('/auth/refresh',
'Content-Type': 'application/json', { refresh_token: storedRefreshToken }, // Send token in body
}, {
}); // No withCredentials needed
headers: { 'Content-Type': 'application/json' },
}
);
if (refreshResponse.status === 200) { 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.'); console.log('[API Client] Token refreshed successfully.');
// Save the new token await storeToken(ACCESS_TOKEN_KEY, newAccessToken); // Store new access 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 // Update the default Authorization header for subsequent requests
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`; apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
// Safely update original request headers
// Update the Authorization header in the original request config
if (originalRequest.headers) { 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); return apiClient(originalRequest);
} else { } else {
console.error('[API Client] Invalid token structure received during refresh:', refreshResponse.data); 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) { } catch (refreshError: any) {
console.error('[API Client] Token refresh failed:', refreshError); 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 // If retry flag was already set, or if refresh attempt failed and fell through,
console.log('[API Client] Clearing potentially invalid token due to 401.'); // we might still end up here. Ensure tokens are cleared if a 401 persists.
if (Platform.OS === 'web') { console.log('[API Client] Clearing tokens due to persistent 401 or failed refresh.');
await AsyncStorage.removeItem(TOKEN_KEY); await deleteToken(ACCESS_TOKEN_KEY);
} else { await deleteToken(REFRESH_TOKEN_KEY);
await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => {}); // Ignore delete error
}
delete apiClient.defaults.headers.common['Authorization']; 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) { } 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); 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') { 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.'); console.warn('[API Client] Hint: A "Network Error" on web often masks a CORS issue. Check browser console & backend CORS config.');
} }
} else { } else {
// Something happened in setting up the request that triggered an Error
console.error('[API Client] Request Setup Error (Interceptor):', error.message); 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); return Promise.reject(error);
} }
); );
export default apiClient; 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 // src/contexts/AuthContext.tsx
import React, { createContext, useState, useEffect, useContext, useMemo, useCallback } from 'react'; import React, { createContext, useState, useEffect, useContext, useMemo, useCallback } from 'react';
import { Platform, ActivityIndicator, View, StyleSheet } from 'react-native'; // Import Platform 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 apiClient from '../api/client';
import { useTheme } from 'react-native-paper'; import { useTheme } from 'react-native-paper';
import { getAccessToken, getRefreshToken, storeTokens, clearTokens } from '../api/client';
const TOKEN_KEY = 'maia_access_token'; // Use the same key
interface AuthContextData { interface AuthContextData {
authToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
@@ -17,7 +13,6 @@ interface AuthContextData {
} }
const AuthContext = createContext<AuthContextData>({ const AuthContext = createContext<AuthContextData>({
authToken: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: true, isLoading: true,
login: async () => { throw new Error('AuthContext not initialized'); }, login: async () => { throw new Error('AuthContext not initialized'); },
@@ -28,104 +23,42 @@ interface AuthProviderProps {
children: React.ReactNode; 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 }) => { export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [authToken, setAuthToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticatedState, setIsAuthenticatedState] = useState<boolean>(false);
const loadToken = useCallback(async () => { const checkAuthStatus = useCallback(async () => {
console.log("[AuthContext] loadToken: Starting..."); // Log: Start const token = await getAccessToken();
const hasToken = !!token;
if (hasToken !== isAuthenticatedState) {
setIsAuthenticatedState(hasToken);
}
return hasToken;
}, [isAuthenticatedState]);
const loadInitialAuth = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
console.log("[AuthContext] loadToken: Calling getToken()..."); // Log: Before await console.log("[AuthContext] loadInitialAuth: Checking initial auth status");
const storedToken = await getToken(); // Use helper await checkAuthStatus();
console.log("[AuthContext] loadToken: getToken() returned:", storedToken); // Log: After await console.log("[AuthContext] loadInitialAuth: Initial check complete.");
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
} catch (error) { } catch (error) {
// **Log the actual error object** console.error("[AuthContext] loadInitialAuth: Error loading initial token:", error);
console.error("[AuthContext] loadToken: Caught error:", error); // Log: Catch Block await clearTokens();
setAuthToken(null); // Ensure logged out state on error setIsAuthenticatedState(false);
delete apiClient.defaults.headers.common['Authorization'];
} finally { } finally {
console.log("[AuthContext] loadToken: Entering finally block."); // Log: Finally Start
setIsLoading(false); setIsLoading(false);
console.log("[AuthContext] loadToken: setIsLoading(false) called."); // Log: Finally End
} }
}, []); }, [checkAuthStatus]);
useEffect(() => { useEffect(() => {
console.log("[AuthContext] useEffect: Component mounted, calling loadToken."); // Log: useEffect call loadInitialAuth();
loadToken(); }, [loadInitialAuth]);
}, [loadToken]);
const login = useCallback(async (username: string, password: string) => { 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 { try {
console.log("[AuthContext] login: Preparing to call apiClient.post for /auth/login"); 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( const response = await apiClient.post(
'/auth/login', '/auth/login',
'grant_type=password&username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + '&scope=&client_id=&client_secret=', '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' || !refresh_token) {
if (!access_token || typeof access_token !== 'string') {
console.error("[AuthContext] login: Invalid token structure received:", response.data); console.error("[AuthContext] login: Invalid token structure received:", response.data);
throw new Error('Invalid token received from server.'); throw new Error('Invalid token received from server.');
} }
console.log('[AuthContext] login: Login successful, received token.'); console.log('[AuthContext] login: Login successful, storing tokens.');
setAuthToken(access_token); await storeTokens(access_token, refresh_token);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${access_token}`; setIsAuthenticatedState(true);
await storeToken(access_token); // Use helper
} catch (error: any) { } catch (error: any) {
// --- Log the error object *itself* ---
console.error("[AuthContext] login: Caught Error Object:", error); console.error("[AuthContext] login: Caught Error Object:", error);
// --- Check if it's an Axios error with config details ---
if (error.isAxiosError) { if (error.isAxiosError) {
console.error("[AuthContext] login: Axios Error Details:"); console.error("[AuthContext] login: Axios Error Details:");
console.error(" Request Config:", error.config); 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); console.error(" Message:", error.message);
} }
await clearTokens();
// Original logging (might be redundant now but keep for context) setIsAuthenticatedState(false);
console.error("Login failed:", error.response?.data || error.message); throw error;
throw error; // Re-throw
} }
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
console.log('Logging out.'); console.log('[AuthContext] logout: Logging out.');
setAuthToken(null); const refreshToken = await getRefreshToken();
delete apiClient.defaults.headers.common['Authorization']; if (!refreshToken) {
await deleteToken(); // Use helper console.warn('[AuthContext] logout: No refresh token found to send to backend.');
await apiClient.post("/auth/logout"); }
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(() => ({ const contextValue = useMemo(() => ({
authToken, isAuthenticated: isAuthenticatedState,
isAuthenticated: !!authToken,
isLoading, isLoading,
login, login,
logout, logout,
}), [authToken, isLoading, login, logout]); }), [isAuthenticatedState, isLoading, login, logout]);
return ( return (
<AuthContext.Provider value={contextValue}> <AuthContext.Provider value={contextValue}>

View File

@@ -1,9 +1,20 @@
// src/screens/ChatScreen.tsx // src/screens/ChatScreen.tsx
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform, TextInput as RNTextInput, NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native'; import {
import { Text, useTheme, TextInput, Button, IconButton, PaperProvider } from 'react-native-paper'; 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 { SafeAreaView } from 'react-native-safe-area-context';
import apiClient from '../api/client'; // Import the apiClient 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 // Define the structure for a message
interface Message { interface Message {
@@ -26,73 +37,49 @@ interface ChatHistoryResponse {
timestamp: string; // Backend sends ISO string 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 ChatScreen = () => {
const theme = useTheme(); const theme = useTheme();
const route = useRoute<ChatScreenRouteProp>(); // Get route params
const initialQuestion = route.params?.initialQuestion; // Extract initialQuestion
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false); // Loading state for sending messages
const [isHistoryLoading, setIsHistoryLoading] = useState(true); // Add state for history loading const [isHistoryLoading, setIsHistoryLoading] = useState(true); // Loading state for initial history fetch
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
// --- Load messages from backend API on mount --- // --- Function to send a message to the backend --- (Extracted logic)
useEffect(() => { const sendMessageToApi = useCallback(async (textToSend: string) => {
const loadHistory = async () => { if (!textToSend) return; // Don't send empty messages
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
const userMessage: Message = { const userMessage: Message = {
id: Date.now().toString() + '-user', // Temporary frontend ID id: Date.now().toString() + '-user', // Temporary frontend ID
text: trimmedText, text: textToSend,
sender: 'user', sender: 'user',
timestamp: new Date(), timestamp: new Date(),
}; };
// Add user message optimistically // Add user message optimistically
setMessages(prevMessages => [...prevMessages, userMessage]); setMessages(prevMessages => [...prevMessages, userMessage]);
setInputText('');
setIsLoading(true); setIsLoading(true);
// Scroll to bottom after sending user message // Scroll to bottom after adding user message
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100); setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
// --- Call Backend API --- // --- Call Backend API ---
try { try {
console.log(`[ChatScreen] Sending to /nlp/process-command: ${trimmedText}`); console.log(`[ChatScreen] Sending to /nlp/process-command: ${textToSend}`);
const response = await apiClient.post<NlpResponse>('/nlp/process-command', { user_input: trimmedText }); const response = await apiClient.post<NlpResponse>('/nlp/process-command', { user_input: textToSend });
console.log("[ChatScreen] Received response:", response.data); console.log("[ChatScreen] Received response:", response.data);
const aiResponses: Message[] = []; const aiResponses: Message[] = [];
@@ -100,7 +87,7 @@ const ChatScreen = () => {
response.data.responses.forEach((responseText, index) => { response.data.responses.forEach((responseText, index) => {
aiResponses.push({ aiResponses.push({
id: `${Date.now()}-ai-${index}`, // Temporary frontend ID id: `${Date.now()}-ai-${index}`, // Temporary frontend ID
text: responseText || "...", text: responseText || "...", // Handle potentially empty strings
sender: 'ai', sender: 'ai',
timestamp: new Date(), timestamp: new Date(),
}); });
@@ -109,7 +96,7 @@ const ChatScreen = () => {
console.warn("[ChatScreen] Received invalid or empty responses array:", response.data); console.warn("[ChatScreen] Received invalid or empty responses array:", response.data);
aiResponses.push({ aiResponses.push({
id: Date.now().toString() + '-ai-fallback', 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', sender: 'ai',
timestamp: new Date(), timestamp: new Date(),
}); });
@@ -133,13 +120,92 @@ const ChatScreen = () => {
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100); setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
} }
// --- End API Call --- // --- 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>) => { const handleKeyPress = (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
// Check if Enter is pressed without Shift key
if (e.nativeEvent.key === 'Enter' && !(e.nativeEvent as any).shiftKey) { if (e.nativeEvent.key === 'Enter' && !(e.nativeEvent as any).shiftKey) {
e.preventDefault(); // Prevent new line e.preventDefault(); // Prevent default behavior (like newline)
handleSend(); handleSend(); // Trigger send action
} }
}; };
@@ -164,6 +230,12 @@ const ChatScreen = () => {
flex: 1, flex: 1,
backgroundColor: theme.colors.background, backgroundColor: theme.colors.background,
}, },
loadingContainer: { // Centering container for loading indicator
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.colors.background,
},
keyboardAvoidingContainer: { // Style for KAV keyboardAvoidingContainer: { // Style for KAV
flex: 1, flex: 1,
}, },
@@ -181,39 +253,33 @@ const ChatScreen = () => {
paddingVertical: 8, // Add some vertical padding paddingVertical: 8, // Add some vertical padding
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: theme.colors.outlineVariant, borderTopColor: theme.colors.outlineVariant,
backgroundColor: theme.colors.background, // Or theme.colors.surface backgroundColor: theme.colors.surface, // Use surface color for input area
}, },
textInput: { textInput: {
flex: 1, // Take available horizontal space flex: 1, // Take available horizontal space
marginRight: 8, marginRight: 8,
backgroundColor: theme.colors.surface, backgroundColor: theme.colors.surface, // Match container background
paddingTop: 10, // Keep the vertical alignment fix for placeholder // Remove explicit padding if mode="outlined" handles it well
paddingHorizontal: 10,
// Add some vertical padding inside the input itself
paddingVertical: Platform.OS === 'ios' ? 10 : 5, // Adjust padding for different platforms if needed
maxHeight: 100, // Optional: prevent input from getting too tall with multiline maxHeight: 100, // Optional: prevent input from getting too tall with multiline
}, },
sendButton: { sendButton: {
marginVertical: 4, // Adjust vertical alignment if needed margin: 0, // Remove default margins if IconButton has them
// Ensure button doesn't shrink
height: 40, // Match TextInput height approx.
justifyContent: 'center',
}, },
messageBubble: { messageBubble: {
maxWidth: '80%', maxWidth: '80%',
padding: 10, padding: 12, // Slightly larger padding
borderRadius: 15, borderRadius: 18, // More rounded corners
marginBottom: 10, marginBottom: 10,
}, },
userBubble: { userBubble: {
alignSelf: 'flex-end', alignSelf: 'flex-end',
backgroundColor: theme.colors.primary, backgroundColor: theme.colors.primary,
borderBottomRightRadius: 5, borderBottomRightRadius: 5, // Keep the chat bubble tail effect
}, },
aiBubble: { aiBubble: {
alignSelf: 'flex-start', alignSelf: 'flex-start',
backgroundColor: theme.colors.surfaceVariant, backgroundColor: theme.colors.surfaceVariant,
borderBottomLeftRadius: 5, borderBottomLeftRadius: 5, // Keep the chat bubble tail effect
}, },
timestamp: { timestamp: {
fontSize: 10, 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) { if (isHistoryLoading) {
return ( return (
<SafeAreaView style={styles.container} edges={['bottom', 'left', 'right']}> <SafeAreaView style={styles.loadingContainer} edges={['bottom', 'left', 'right']}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <ActivityIndicator animating={true} size="large" color={theme.colors.primary} />
<Text>Loading chat history...</Text> <Text style={{ marginTop: 10, color: theme.colors.onBackground }}>Loading chat history...</Text>
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -237,13 +302,9 @@ const ChatScreen = () => {
return ( return (
<SafeAreaView style={styles.container} edges={['bottom', 'left', 'right']}> <SafeAreaView style={styles.container} edges={['bottom', 'left', 'right']}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoidingContainer} // Use style with flex: 1 style={styles.keyboardAvoidingContainer}
// Use 'padding' for both iOS and Android behavior={Platform.OS === "ios" ? "padding" : "height"} // Use height for Android if padding causes issues
behavior={Platform.OS === "ios" ? "padding" : "padding"} keyboardVerticalOffset={Platform.OS === "ios" ? 60 : 0} // Adjust as needed
// 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
> >
{/* List container takes available space */} {/* List container takes available space */}
<View style={styles.listContainer}> <View style={styles.listContainer}>
@@ -252,9 +313,11 @@ const ChatScreen = () => {
data={messages} data={messages}
renderItem={renderMessage} renderItem={renderMessage}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={styles.messageList} // Padding inside the scrollable content contentContainerStyle={styles.messageList}
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })} // Optimization: remove onContentSizeChange/onLayout if not strictly needed for scrolling
onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })} // onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })}
// onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })}
// Consider initialScrollIndex or other props if performance is an issue
/> />
</View> </View>
@@ -264,13 +327,14 @@ const ChatScreen = () => {
style={styles.textInput} style={styles.textInput}
value={inputText} value={inputText}
onChangeText={setInputText} onChangeText={setInputText}
placeholder="Type your message..." placeholder="Ask MAIA..."
mode="outlined" // Keep outlined or flat as preferred mode="outlined"
multiline multiline
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress} // Use onKeyPress for web/desktop-like Enter behavior
blurOnSubmit={false} // Keep false for multiline + send button blurOnSubmit={false} // Keep false for multiline + send button
disabled={isLoading} disabled={isLoading} // Disable input while AI is responding
dense // Try making the input slightly smaller vertically outlineStyle={{ borderRadius: 20 }} // Make input more rounded
dense // Reduce vertical padding
/> />
<IconButton <IconButton
icon="send" icon="send"
@@ -280,7 +344,8 @@ const ChatScreen = () => {
mode="contained" mode="contained"
iconColor={theme.colors.onPrimary} iconColor={theme.colors.onPrimary}
containerColor={theme.colors.primary} containerColor={theme.colors.primary}
style={styles.sendButton} // Apply style for alignment style={styles.sendButton}
animated // Add subtle animation
/> />
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>

View File

@@ -1,18 +1,212 @@
// src/screens/DashboardScreen.tsx // src/screens/DashboardScreen.tsx
import React from 'react'; import React, { useState, useEffect } from 'react'; // Added useEffect
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet, ScrollView } from 'react-native'; // Added ScrollView
import { Text, useTheme } from 'react-native-paper'; 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 DashboardScreen = () => {
const theme = useTheme(); 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({ 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,
// 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 ( return (
<View style={styles.container}> // Wrap content in ScrollView to handle potential overflow
<Text style={styles.text}>Dashboard</Text> <ScrollView style={{ flex: 1, backgroundColor: theme.colors.background }} contentContainerStyle={styles.scrollViewContent}>
</View> <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"
}
}