[NOT FULLY WORKING] Added frontend react native interface.
@@ -2,6 +2,7 @@
|
||||
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
|
||||
from typing import Any, Callable
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from core.database import get_engine, Base
|
||||
from modules import router
|
||||
import logging
|
||||
@@ -23,4 +24,18 @@ lifespan = lifespan_factory()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Include module router
|
||||
app.include_router(router)
|
||||
app.include_router(router)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:8081"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"]
|
||||
)
|
||||
|
||||
# Health endpoint
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -25,6 +25,8 @@ def get_events(
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None
|
||||
):
|
||||
start = None if start == "" else start
|
||||
end = None if end == "" else end
|
||||
return get_calendar_events(db, user.id, start, end)
|
||||
|
||||
@router.put("/events/{event_id}", response_model=CalendarEventResponse)
|
||||
|
||||
36
interfaces/nativeapp/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
32
interfaces/nativeapp/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// App.tsx
|
||||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { Provider as PaperProvider } from 'react-native-paper';
|
||||
import { NavigationContainer } from '@react-navigation/native'; // Always used
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
import { AuthProvider } from './src/contexts/AuthContext';
|
||||
import RootNavigator from './src/navigation/RootNavigator';
|
||||
import theme from './src/constants/theme';
|
||||
// Import the combined theme
|
||||
import { CombinedDarkTheme } from './src/navigation/WebAppLayout'; // Adjust import path if needed
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<PaperProvider theme={theme}>
|
||||
{/* NavigationContainer wraps RootNavigator for ALL platforms */}
|
||||
<NavigationContainer theme={CombinedDarkTheme}>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
<StatusBar
|
||||
style="light" // Assuming dark theme
|
||||
backgroundColor={Platform.OS === 'web' ? theme.colors.background : theme.colors.surface}
|
||||
/>
|
||||
</PaperProvider>
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
31
interfaces/nativeapp/app.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "webapp",
|
||||
"slug": "webapp",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
interfaces/nativeapp/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
interfaces/nativeapp/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
interfaces/nativeapp/assets/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
interfaces/nativeapp/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
8
interfaces/nativeapp/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
10666
interfaces/nativeapp/package-lock.json
generated
Normal file
41
interfaces/nativeapp/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "nativeapp",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~4.0.1",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@react-navigation/native-stack": "^7.3.10",
|
||||
"async-storage": "^0.1.0",
|
||||
"axios": "^1.8.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~52.0.46",
|
||||
"expo-secure-store": "~14.0.1",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-async-storage": "^0.0.1",
|
||||
"react-native-calendars": "^1.1311.0",
|
||||
"react-native-paper": "^5.13.2",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-vector-icons": "^10.2.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"@react-native-async-storage/async-storage": "1.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
12
interfaces/nativeapp/src/api/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import apiClient from './client';
|
||||
|
||||
|
||||
export const healthCheck = async (): Promise<{ status: string }> => {
|
||||
try {
|
||||
const response = await apiClient.get('/health');
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error("Error fetching backend health:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
19
interfaces/nativeapp/src/api/calendar.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import apiClient from './client';
|
||||
import { CalendarEvent } from '../types/calendar';
|
||||
|
||||
export const getCalendarEvents = async (start?: Date, end?: Date): Promise<CalendarEvent[]> => {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (start instanceof Date) {
|
||||
params.start = start.toISOString();
|
||||
}
|
||||
if (end instanceof Date) {
|
||||
params.end = end.toISOString();
|
||||
}
|
||||
const response = await apiClient.get('/calendar/events', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching calendar events", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
139
interfaces/nativeapp/src/api/client.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// src/api/client.ts
|
||||
import axios, { AxiosError } from 'axios'; // Import AxiosError
|
||||
import { Platform } from 'react-native';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
|
||||
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||
const TOKEN_KEY = 'maia_access_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 () => { ... };
|
||||
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
...(Platform.OS === 'web' ? { withCredentials: true } : {}),
|
||||
});
|
||||
|
||||
// --- Request Interceptor remains the same ---
|
||||
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
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
console.log('[API Client] Starting Request', config.method?.toUpperCase(), config.url);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[API Client] Request Setup Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// --- Modified Response Interceptor ---
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Success case
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => { // Explicitly type error as AxiosError
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Check if the error has a response object (i.e., server responded with error status)
|
||||
if (error.response) {
|
||||
// Server responded with an error status code (4xx, 5xx)
|
||||
console.error('[API Client] Response Error Status:', error.response.status);
|
||||
console.error('[API Client] Response Error Data:', error.response.data);
|
||||
|
||||
// Handle 401 specifically
|
||||
if (error.response.status === 401) {
|
||||
console.warn('[API Client] Unauthorized (401). Token might be expired or invalid.');
|
||||
|
||||
if (!originalRequest?._retry) {
|
||||
originalRequest._retry = true; // Mark the request as retried to avoid infinite loops
|
||||
|
||||
try {
|
||||
console.log('[API Client] Attempting token refresh...');
|
||||
const refreshResponse = await apiClient.post('/auth/refresh', {}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (refreshResponse.status === 200) {
|
||||
const newToken = refreshResponse.data?.accessToken;
|
||||
|
||||
if (newToken) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Update the Authorization header for future requests
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
|
||||
// Retry the original request with the new token
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('[API Client] Token refresh failed:', refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
delete apiClient.defaults.headers.common['Authorization'];
|
||||
|
||||
// How to trigger logout? Propagating error is simplest for now.
|
||||
}
|
||||
} 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;
|
||||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
BIN
interfaces/nativeapp/src/assets/MAIA_ICON.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
119
interfaces/nativeapp/src/components/WebSidebar.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// src/components/WebSidebar.tsx
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, Pressable } from 'react-native';
|
||||
import { Text, useTheme, Icon } from 'react-native-paper';
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import { NavigationContainerRef } from '@react-navigation/native'; // Import ref type
|
||||
import { RootStackParamList } from '../types/navigation'; // Import stack param list
|
||||
|
||||
// Define Props including the navigation ref
|
||||
interface WebSidebarProps {
|
||||
navigationRef: React.RefObject<NavigationContainerRef<RootStackParamList>>;
|
||||
currentRouteName?: string; // To highlight the active item
|
||||
}
|
||||
|
||||
// Define navigation items
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', icon: 'view-dashboard', label: 'Dashboard' },
|
||||
{ name: 'Chat', icon: 'chat', label: 'Chat' },
|
||||
{ name: 'Calendar', icon: 'calendar', label: 'Calendar' },
|
||||
{ name: 'Profile', icon: 'account-circle', label: 'Profile' },
|
||||
] as const; // Use 'as const' for stricter typing of names
|
||||
|
||||
const WebSidebar = ({ navigationRef, currentRouteName }: WebSidebarProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleNavigate = (screenName: keyof RootStackParamList) => {
|
||||
// Use the ref to navigate
|
||||
if (navigationRef.current) {
|
||||
navigationRef.current.navigate(screenName);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: 240, // Fixed width for the sidebar
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors.surface, // Sidebar background
|
||||
paddingTop: 40, // Space from top
|
||||
paddingHorizontal: 10,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.background, // Subtle border
|
||||
},
|
||||
logoArea: {
|
||||
marginBottom: 30,
|
||||
alignItems: 'center',
|
||||
// Add your logo image here if desired
|
||||
// <Image source={require('../assets/icon.png')} style={styles.logo} />
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: theme.colors.primary, // Use primary color for logo text
|
||||
},
|
||||
navItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: theme.roundness,
|
||||
marginBottom: 8,
|
||||
},
|
||||
activeNavItem: {
|
||||
backgroundColor: theme.colors.primary, // Highlight active item background
|
||||
},
|
||||
icon: {
|
||||
marginRight: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeLabel: {
|
||||
color: theme.colors.onPrimary, // Text color on primary background
|
||||
},
|
||||
inactiveLabel: {
|
||||
color: theme.colors.textSecondary, // Text color for inactive items
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.logoArea}>
|
||||
{/* Placeholder Logo Text */}
|
||||
<Text style={styles.logoText}>MAIA</Text>
|
||||
</View>
|
||||
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentRouteName === item.name;
|
||||
return (
|
||||
<Pressable
|
||||
key={item.name}
|
||||
onPress={() => handleNavigate(item.name)}
|
||||
style={[
|
||||
styles.navItem,
|
||||
isActive && styles.activeNavItem, // Apply active style conditionally
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon}
|
||||
size={24}
|
||||
color={isActive ? theme.colors.onPrimary : theme.colors.textSecondary}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
isActive ? styles.activeLabel : styles.inactiveLabel
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSidebar;
|
||||
11
interfaces/nativeapp/src/constants/colors.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/constants/colors.ts
|
||||
export const colors = {
|
||||
background: '#0a0a0a', // Dark blue-teal background
|
||||
primary: '#4DB6AC', // Main teal color for text, icons, active elements
|
||||
secondary: '#64FFDA', // Bright cyan accent for highlights, important actions
|
||||
surface: '#252525', // Slightly lighter background for cards/modals (optional)
|
||||
text: '#FFFFFF', // White text for high contrast on dark background
|
||||
textSecondary: '#B0BEC5', // Lighter gray for less important text
|
||||
error: '#FF5252', // Standard error color
|
||||
disabled: '#78909C', // Color for disabled elements
|
||||
};
|
||||
69
interfaces/nativeapp/src/constants/theme.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/constants/theme.ts
|
||||
import { MD3DarkTheme as DefaultTheme, configureFonts } from 'react-native-paper';
|
||||
import { colors } from './colors';
|
||||
|
||||
// const fontConfig = {
|
||||
// default: {
|
||||
// regular: {
|
||||
// fontFamily: 'Inter, sans-serif',
|
||||
// fontWeight: 'normal',
|
||||
// fontSize: 14,
|
||||
// lineHeight: 20,
|
||||
// letterSpacing: 0.25,
|
||||
// },
|
||||
// medium: {
|
||||
// fontFamily: 'Inter, sans-serif',
|
||||
// fontWeight: '500',
|
||||
// fontSize: 16,
|
||||
// lineHeight: 24,
|
||||
// letterSpacing: 0.15,
|
||||
// },
|
||||
// light: {
|
||||
// fontFamily: 'Inter, sans-serif',
|
||||
// fontWeight: '300',
|
||||
// fontSize: 12,
|
||||
// lineHeight: 16,
|
||||
// letterSpacing: 0.4,
|
||||
// },
|
||||
// thin: {
|
||||
// fontFamily: 'Inter, sans-serif',
|
||||
// fontWeight: '100',
|
||||
// fontSize: 10,
|
||||
// lineHeight: 14,
|
||||
// letterSpacing: 0.5,
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
// const fonts = configureFonts({ config: fontConfig });
|
||||
|
||||
const theme = {
|
||||
...DefaultTheme, // Use MD3 dark theme as a base
|
||||
// fonts: fonts,
|
||||
colors: {
|
||||
...DefaultTheme.colors, // Keep default colors unless overridden
|
||||
primary: colors.primary,
|
||||
accent: colors.secondary, // Note: Paper v5 uses 'secondary' often where 'accent' was used
|
||||
secondary: colors.secondary,
|
||||
tertiary: colors.secondary, // Assign accent to tertiary as well if needed
|
||||
background: colors.background,
|
||||
surface: colors.surface || colors.background, // Use surface or fallback to background
|
||||
text: colors.text,
|
||||
onPrimary: colors.background, // Text color on primary background
|
||||
onSecondary: colors.background, // Text color on secondary background
|
||||
onBackground: colors.text, // Text color on main background
|
||||
onSurface: colors.text, // Text color on surface background
|
||||
error: colors.error,
|
||||
disabled: colors.disabled,
|
||||
placeholder: colors.textSecondary,
|
||||
backdrop: 'rgba(0, 0, 0, 0.5)',
|
||||
// Adjust other colors as needed (surfaceVariant, primaryContainer, etc.)
|
||||
// You might need to experiment based on Paper v5's specific color roles
|
||||
primaryContainer: colors.primary, // Example adjustment
|
||||
secondaryContainer: colors.secondary, // Example adjustment
|
||||
|
||||
},
|
||||
// You can customize other theme aspects like roundness, animation scale etc.
|
||||
roundness: 4,
|
||||
};
|
||||
|
||||
export default theme;
|
||||
210
interfaces/nativeapp/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
// 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
|
||||
|
||||
interface AuthContextData {
|
||||
authToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextData>({
|
||||
authToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
login: async () => { throw new Error('AuthContext not initialized'); },
|
||||
logout: async () => { throw new Error('AuthContext not initialized'); },
|
||||
});
|
||||
|
||||
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 loadToken = useCallback(async () => {
|
||||
console.log("[AuthContext] loadToken: Starting..."); // Log: Start
|
||||
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
|
||||
} 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'];
|
||||
} finally {
|
||||
console.log("[AuthContext] loadToken: Entering finally block."); // Log: Finally Start
|
||||
setIsLoading(false);
|
||||
console.log("[AuthContext] loadToken: setIsLoading(false) called."); // Log: Finally End
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[AuthContext] useEffect: Component mounted, calling loadToken."); // Log: useEffect call
|
||||
loadToken();
|
||||
}, [loadToken]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
console.log("[AuthContext] login: Function called with:", username); // Log: Function entry
|
||||
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=',
|
||||
{
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("[AuthContext] login: apiClient.post successful, response status:", response?.status); // Log success
|
||||
|
||||
const { access_token } = response.data;
|
||||
|
||||
if (!access_token || typeof access_token !== 'string') {
|
||||
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
|
||||
|
||||
} 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(" 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
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
console.log('Logging out.');
|
||||
setAuthToken(null);
|
||||
delete apiClient.defaults.headers.common['Authorization'];
|
||||
await deleteToken(); // Use helper
|
||||
// Optional backend logout call
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
authToken,
|
||||
isAuthenticated: !!authToken,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
}), [authToken, isLoading, login, logout]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// --- useAuth and AuthLoadingScreen remain the same ---
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthLoadingScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: theme.colors.background }
|
||||
});
|
||||
return (<View style={styles.container}><ActivityIndicator size="large" color={theme.colors.primary} /></View>);
|
||||
}
|
||||
22
interfaces/nativeapp/src/navigation/AuthNavigator.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/navigation/AuthNavigator.tsx
|
||||
import React from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
|
||||
import LoginScreen from '../screens/LoginScreen';
|
||||
// Import SignUpScreen, ForgotPasswordScreen etc. if you have them
|
||||
|
||||
import { AuthStackParamList } from '../types/navigation';
|
||||
|
||||
const Stack = createNativeStackNavigator<AuthStackParamList>();
|
||||
|
||||
const AuthNavigator = () => {
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
{/* Add other auth screens here */}
|
||||
{/* <Stack.Screen name="SignUp" component={SignUpScreen} /> */}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthNavigator;
|
||||
73
interfaces/nativeapp/src/navigation/MobileTabNavigator.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// src/navigation/MobileTabNavigator.tsx
|
||||
import React from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { useTheme } from 'react-native-paper';
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
|
||||
import DashboardScreen from '../screens/DashboardScreen';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import CalendarScreen from '../screens/CalendarScreen';
|
||||
import ProfileScreen from '../screens/ProfileScreen';
|
||||
|
||||
import { MobileTabParamList } from '../types/navigation';
|
||||
|
||||
const Tab = createBottomTabNavigator<MobileTabParamList>();
|
||||
|
||||
const MobileTabNavigator = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
initialRouteName="DashboardTab"
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarActiveTintColor: theme.colors.secondary,
|
||||
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopColor: theme.colors.surface, // Or a subtle border
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
headerTintColor: theme.colors.text,
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
let iconName: string = 'help-circle'; // Default icon
|
||||
|
||||
if (route.name === 'DashboardTab') {
|
||||
iconName = focused ? 'view-dashboard' : 'view-dashboard-outline';
|
||||
} else if (route.name === 'ChatTab') {
|
||||
iconName = focused ? 'chat' : 'chat-outline';
|
||||
} else if (route.name === 'CalendarTab') {
|
||||
iconName = focused ? 'calendar' : 'calendar-outline';
|
||||
} else if (route.name === 'ProfileTab') {
|
||||
iconName = focused ? 'account-circle' : 'account-circle-outline';
|
||||
}
|
||||
return <MaterialCommunityIcons name={iconName} size={size} color={color} />;
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="DashboardTab"
|
||||
component={DashboardScreen}
|
||||
options={{ title: 'Dashboard', headerTitle: 'MAIA Dashboard' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="ChatTab"
|
||||
component={ChatScreen}
|
||||
options={{ title: 'Chat', headerTitle: 'MAIA Chat' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="CalendarTab"
|
||||
component={CalendarScreen}
|
||||
options={{ title: 'Calendar', headerTitle: 'Calendar' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="ProfileTab"
|
||||
component={ProfileScreen}
|
||||
options={{ title: 'Profile', headerTitle: 'Profile' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileTabNavigator;
|
||||
38
interfaces/nativeapp/src/navigation/RootNavigator.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/navigation/RootNavigator.tsx
|
||||
import React from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { useAuth, AuthLoadingScreen } from '../contexts/AuthContext';
|
||||
import AuthNavigator from './AuthNavigator'; // Unauthenticated flow
|
||||
import MobileTabNavigator from './MobileTabNavigator'; // Authenticated Mobile flow
|
||||
import WebAppLayout from './WebAppLayout'; // Authenticated Web flow
|
||||
|
||||
import { RootStackParamList } from '../types/navigation';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
const RootNavigator = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading screen while checking auth state
|
||||
if (isLoading) {
|
||||
return <AuthLoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{isAuthenticated ? (
|
||||
// User is logged in: Choose main app layout based on platform
|
||||
<Stack.Screen name="AppFlow">
|
||||
{() => Platform.OS === 'web' ? <WebAppLayout /> : <MobileTabNavigator />}
|
||||
</Stack.Screen>
|
||||
) : (
|
||||
// User is not logged in: Show authentication flow
|
||||
<Stack.Screen name="AuthFlow" component={AuthNavigator} />
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootNavigator;
|
||||
80
interfaces/nativeapp/src/navigation/WebAppLayout.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
// src/navigation/WebAppLayout.tsx
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import {
|
||||
NavigationContainer,
|
||||
NavigationContainerRef, // Correct type for ref
|
||||
DefaultTheme as NavigationDefaultTheme,
|
||||
DarkTheme as NavigationDarkTheme,
|
||||
NavigationIndependentTree
|
||||
} from '@react-navigation/native';
|
||||
|
||||
import WebSidebar from '../components/WebSidebar';
|
||||
import WebContentNavigator from './WebContentNavigator';
|
||||
import theme from '../constants/theme'; // Your Paper theme
|
||||
import { WebContentStackParamList, WebContentNavigationProp } from '../types/navigation'; // Import correct types
|
||||
|
||||
// Combine Paper theme with Navigation theme
|
||||
export const CombinedDarkTheme = {
|
||||
...NavigationDarkTheme,
|
||||
...theme,
|
||||
colors: {
|
||||
...NavigationDarkTheme.colors,
|
||||
...theme.colors,
|
||||
card: theme.colors.surface,
|
||||
border: theme.colors.surface,
|
||||
text: theme.colors.text,
|
||||
primary: theme.colors.primary,
|
||||
background: theme.colors.background,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const WebAppLayout = () => {
|
||||
// Use the specific ref type
|
||||
const navigationRef = useRef<WebContentNavigationProp>(null);
|
||||
const [currentWebRoute, setCurrentWebRoute] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleWebNavigationReady = () => {
|
||||
setCurrentWebRoute(navigationRef.current?.getCurrentRoute()?.name);
|
||||
};
|
||||
const handleWebNavigationStateChange = () => {
|
||||
const currentRoute = navigationRef.current?.getCurrentRoute();
|
||||
if (currentRoute) {
|
||||
setCurrentWebRoute(currentRoute.name);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
height: '100vh', // Full viewport height for web
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
contentArea: {
|
||||
flex: 1, // Content takes remaining space
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.webContainer}>
|
||||
<WebSidebar navigationRef={navigationRef} currentRouteName={currentWebRoute} />
|
||||
<View style={styles.contentArea}>
|
||||
<NavigationIndependentTree>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
theme={CombinedDarkTheme}
|
||||
onReady={handleWebNavigationReady}
|
||||
onStateChange={handleWebNavigationStateChange}
|
||||
>
|
||||
<WebContentNavigator />
|
||||
</NavigationContainer>
|
||||
</NavigationIndependentTree>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebAppLayout;
|
||||
45
interfaces/nativeapp/src/navigation/WebContentNavigator.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/navigation/WebContentNavigator.tsx
|
||||
import React from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { useTheme } from 'react-native-paper';
|
||||
|
||||
import DashboardScreen from '../screens/DashboardScreen';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import CalendarScreen from '../screens/CalendarScreen';
|
||||
import ProfileScreen from '../screens/ProfileScreen';
|
||||
|
||||
import { WebContentStackParamList } from '../types/navigation';
|
||||
|
||||
const Stack = createNativeStackNavigator<WebContentStackParamList>();
|
||||
|
||||
const WebContentNavigator = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
initialRouteName="Dashboard"
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
headerTintColor: theme.colors.text,
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
// Keep headers visible within the content area
|
||||
headerShown: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Dashboard" component={DashboardScreen} options={{ title: 'MAIA Dashboard' }}/>
|
||||
<Stack.Screen name="Chat" component={ChatScreen} options={{ title: 'MAIA Chat' }}/>
|
||||
<Stack.Screen name="Calendar" component={CalendarScreen} />
|
||||
<Stack.Screen name="Profile" component={ProfileScreen} />
|
||||
{/* Add other detail screens here if needed */}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebContentNavigator;
|
||||
321
interfaces/nativeapp/src/screens/CalendarScreen.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
// src/screens/CalendarScreen.tsx
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { View, StyleSheet, FlatList } from 'react-native';
|
||||
import { Calendar, DateData, LocaleConfig, CalendarProps, MarkingProps } from 'react-native-calendars'; // Import MarkingProps
|
||||
import { Text, useTheme, ActivityIndicator, List, Divider } from 'react-native-paper';
|
||||
import {
|
||||
format,
|
||||
parseISO,
|
||||
startOfMonth,
|
||||
endOfMonth, // Need endOfMonth
|
||||
getYear,
|
||||
getMonth,
|
||||
eachDayOfInterval, // Crucial for period marking
|
||||
isSameDay, // Helper for comparisons
|
||||
isValid, // Check if dates are valid
|
||||
} from 'date-fns';
|
||||
|
||||
import { getCalendarEvents } from '../api/calendar';
|
||||
import { CalendarEvent } from '../types/calendar'; // Use updated type
|
||||
|
||||
// Optional: Configure locale
|
||||
// LocaleConfig.locales['en'] = { ... }; LocaleConfig.defaultLocale = 'en';
|
||||
|
||||
const getTodayDateString = () => format(new Date(), 'yyyy-MM-dd');
|
||||
|
||||
const CalendarScreen = () => {
|
||||
const theme = useTheme();
|
||||
const todayString = useMemo(getTodayDateString, []);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<string>(todayString);
|
||||
const [currentMonthData, setCurrentMonthData] = useState<DateData | null>(null);
|
||||
|
||||
// Store events fetched from API *directly*
|
||||
// We process them for marking and display separately
|
||||
const [rawEvents, setRawEvents] = useState<CalendarEvent[]>([]);
|
||||
// Store events keyed by date *for the list display*
|
||||
const [eventsByDate, setEventsByDate] = useState<{ [key: string]: CalendarEvent[] }>({});
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// --- Fetching Logic ---
|
||||
const fetchEventsForMonth = useCallback(async (date: Date | DateData) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const targetYear = 'year' in date ? date.year : getYear(date);
|
||||
const targetMonth = 'month' in date ? date.month : getMonth(date) + 1;
|
||||
|
||||
if (isLoading && currentMonthData?.year === targetYear && currentMonthData?.month === targetMonth) {
|
||||
return;
|
||||
}
|
||||
if ('dateString' in date) {
|
||||
setCurrentMonthData(date);
|
||||
} else {
|
||||
// If called with Date, create approximate DateData
|
||||
const dateObj = date instanceof Date ? date : new Date(date.timestamp);
|
||||
setCurrentMonthData({
|
||||
year: targetYear,
|
||||
month: targetMonth,
|
||||
dateString: format(dateObj, 'yyyy-MM-dd'),
|
||||
day: dateObj.getDate(),
|
||||
timestamp: dateObj.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Fetching events potentially overlapping ${targetYear}-${targetMonth}`);
|
||||
const fetchedEvents = await getCalendarEvents(targetYear, targetMonth);
|
||||
setRawEvents(fetchedEvents); // Store the raw events for period marking
|
||||
|
||||
// Process events for the daily list view
|
||||
const newEventsByDate: { [key: string]: CalendarEvent[] } = {};
|
||||
fetchedEvents.forEach(event => {
|
||||
const startDate = parseISO(event.start);
|
||||
const endDate = parseISO(event.end);
|
||||
|
||||
if (!isValid(startDate) || !isValid(endDate)) {
|
||||
console.warn(`Invalid date found in event ${event.id}`);
|
||||
return; // Skip invalid events
|
||||
}
|
||||
|
||||
// Ensure end date is not before start date
|
||||
const end = endDate < startDate ? startDate : endDate;
|
||||
|
||||
const intervalDates = eachDayOfInterval({ start: startDate, end: end });
|
||||
intervalDates.forEach(dayInInterval => {
|
||||
const dateKey = format(dayInInterval, 'yyyy-MM-dd');
|
||||
if (!newEventsByDate[dateKey]) {
|
||||
newEventsByDate[dateKey] = [];
|
||||
}
|
||||
// Avoid duplicates if an event is already added for this key
|
||||
if (!newEventsByDate[dateKey].some(e => e.id === event.id)) {
|
||||
newEventsByDate[dateKey].push(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
setEventsByDate(newEventsByDate); // Update state for list view
|
||||
|
||||
} catch (err) {
|
||||
setError('Failed to load calendar events.');
|
||||
setRawEvents([]); // Clear events on error
|
||||
setEventsByDate({});
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, currentMonthData]); // Include dependencies
|
||||
|
||||
// --- Initial Fetch ---
|
||||
useEffect(() => {
|
||||
const initialDate = parseISO(todayString);
|
||||
fetchEventsForMonth(initialDate);
|
||||
}, [fetchEventsForMonth, todayString]);
|
||||
|
||||
// --- Callbacks for Calendar ---
|
||||
const onDayPress = useCallback((day: DateData) => {
|
||||
setSelectedDate(day.dateString);
|
||||
}, []);
|
||||
|
||||
const onMonthChange = useCallback((month: DateData) => {
|
||||
if (!currentMonthData || month.year !== currentMonthData.year || month.month !== currentMonthData.month) {
|
||||
fetchEventsForMonth(month);
|
||||
} else {
|
||||
setCurrentMonthData(month); // Just update the current data if same month
|
||||
}
|
||||
}, [fetchEventsForMonth, currentMonthData]);
|
||||
|
||||
// --- Calculate Marked Dates (Period Marking) ---
|
||||
const markedDates = useMemo(() => {
|
||||
const marks: { [key: string]: MarkingProps } = {}; // Use MarkingProps type
|
||||
|
||||
rawEvents.forEach(event => {
|
||||
const startDate = parseISO(event.start);
|
||||
const endDate = parseISO(event.end);
|
||||
const eventColor = event.color || theme.colors.primary; // Use event color or default
|
||||
|
||||
if (!isValid(startDate) || !isValid(endDate)) {
|
||||
return; // Skip invalid events
|
||||
}
|
||||
|
||||
// Ensure end date is not before start date
|
||||
const end = endDate < startDate ? startDate : endDate;
|
||||
|
||||
const intervalDates = eachDayOfInterval({ start: startDate, end: end });
|
||||
|
||||
intervalDates.forEach((dateInInterval, index) => {
|
||||
const dateString = format(dateInInterval, 'yyyy-MM-dd');
|
||||
const isStartingDay = index === 0;
|
||||
const isEndingDay = index === intervalDates.length - 1;
|
||||
|
||||
const marking: MarkingProps = {
|
||||
color: eventColor,
|
||||
textColor: theme.colors.onPrimary || '#ffffff', // Text color within the period mark
|
||||
};
|
||||
|
||||
if (isStartingDay) {
|
||||
marking.startingDay = true;
|
||||
}
|
||||
if (isEndingDay) {
|
||||
marking.endingDay = true;
|
||||
}
|
||||
// Handle single-day events (both start and end)
|
||||
if (intervalDates.length === 1) {
|
||||
marking.startingDay = true;
|
||||
marking.endingDay = true;
|
||||
}
|
||||
|
||||
// Merge markings if multiple events overlap on the same day
|
||||
marks[dateString] = {
|
||||
...(marks[dateString] || {}), // Keep existing marks
|
||||
...marking,
|
||||
// Ensure start/end flags aren't overwritten by non-start/end marks
|
||||
startingDay: marks[dateString]?.startingDay || marking.startingDay,
|
||||
endingDay: marks[dateString]?.endingDay || marking.endingDay,
|
||||
// We might need a more complex strategy if multiple periods
|
||||
// with different colors overlap on the same day.
|
||||
// For now, the last event processed might "win" the color.
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Add selected day marking (merge with period marking)
|
||||
if (selectedDate) {
|
||||
marks[selectedDate] = {
|
||||
...(marks[selectedDate] || {}), // Keep existing period/dot marks
|
||||
selected: true,
|
||||
// Keep the period color if it exists, otherwise use selection color
|
||||
color: marks[selectedDate]?.color || theme.colors.primary, // Period wins color? or selection? Choose one. Here period wins.
|
||||
// selectedColor: theme.colors.secondary, // Or use a distinct selection highlight color?
|
||||
// Ensure text color is appropriate for selected state
|
||||
textColor: theme.colors.onPrimary || '#ffffff',
|
||||
// If selected, don't let it look like starting/ending unless it truly is
|
||||
startingDay: marks[selectedDate]?.startingDay && marks[selectedDate]?.selected,
|
||||
endingDay: marks[selectedDate]?.endingDay && marks[selectedDate]?.selected,
|
||||
};
|
||||
}
|
||||
|
||||
// Add today marking (merge with period/selection marking)
|
||||
// Period marking visually indicates today already if colored. Add dot?
|
||||
marks[todayString] = {
|
||||
...(marks[todayString] || {}),
|
||||
// marked: true, // 'marked' is implicit with period marking color
|
||||
dotColor: theme.colors.secondary, // Add a distinct dot for today?
|
||||
// Or rely on the 'todayTextColor' in the theme prop
|
||||
};
|
||||
|
||||
return marks;
|
||||
}, [rawEvents, selectedDate, theme.colors, theme.dark, todayString]); // Include theme.dark if colors change
|
||||
|
||||
// --- Render Event Item ---
|
||||
const renderEventItem = ({ item }: { item: CalendarEvent }) => {
|
||||
const startDate = parseISO(item.start);
|
||||
const endDate = parseISO(item.end);
|
||||
let description = item.description || '';
|
||||
if (isValid(startDate)) {
|
||||
// Show date range if it spans multiple days or specific time if single day
|
||||
if (!isSameDay(startDate, endDate)) {
|
||||
description = `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}${item.description ? `\n${item.description}` : ''}`;
|
||||
} else {
|
||||
description = `Time: ${format(startDate, 'p')}${item.description ? `\n${item.description}` : ''}`; // 'p' is locale-specific time format
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
title={item.title}
|
||||
description={description}
|
||||
left={props => <List.Icon {...props} icon="circle-slice-8" color={item.color || theme.colors.primary} />} // Use a filled circle or similar
|
||||
style={styles.eventItem}
|
||||
titleStyle={{ color: theme.colors.text }}
|
||||
descriptionStyle={{ color: theme.colors.textSecondary }}
|
||||
descriptionNumberOfLines={3} // Allow more lines for range/details
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Styles ---
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: theme.colors.background },
|
||||
calendar: { /* ... */ },
|
||||
loadingContainer: { height: 100, justifyContent: 'center', alignItems: 'center' },
|
||||
eventListContainer: { flex: 1, paddingHorizontal: 16, paddingTop: 10 },
|
||||
eventListHeader: { fontSize: 16, fontWeight: 'bold', color: theme.colors.text, marginBottom: 10, marginTop: 10 },
|
||||
eventItem: { backgroundColor: theme.colors.surface, marginBottom: 8, borderRadius: theme.roundness },
|
||||
noEventsText: { textAlign: 'center', marginTop: 20, color: theme.colors.textSecondary, fontSize: 16 },
|
||||
errorText: { textAlign: 'center', marginTop: 20, color: theme.colors.error, fontSize: 16 },
|
||||
});
|
||||
|
||||
// --- Calendar Theme ---
|
||||
const calendarTheme: CalendarProps['theme'] = { // Use CalendarProps['theme'] for stricter typing
|
||||
backgroundColor: theme.colors.background,
|
||||
calendarBackground: theme.colors.surface,
|
||||
textSectionTitleColor: theme.colors.primary,
|
||||
selectedDayBackgroundColor: theme.colors.secondary, // Make selection distinct?
|
||||
selectedDayTextColor: theme.colors.background, // Text on selection
|
||||
todayTextColor: theme.colors.secondary, // Today's date number color
|
||||
dayTextColor: theme.colors.text,
|
||||
textDisabledColor: theme.colors.disabled,
|
||||
dotColor: theme.colors.secondary, // Color for the explicit 'today' dot
|
||||
selectedDotColor: theme.colors.primary,
|
||||
arrowColor: theme.colors.primary,
|
||||
monthTextColor: theme.colors.text,
|
||||
indicatorColor: theme.colors.primary,
|
||||
textDayFontWeight: '300',
|
||||
textMonthFontWeight: 'bold',
|
||||
textDayHeaderFontWeight: '500',
|
||||
textDayFontSize: 16,
|
||||
textMonthFontSize: 18,
|
||||
textDayHeaderFontSize: 14,
|
||||
// Period marking text color is handled by 'textColor' within the mark itself
|
||||
'stylesheet.calendar.header': { // Example of deeper theme customization if needed
|
||||
week: {
|
||||
marginTop: 5,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get events for the *selected* date from the processed map
|
||||
const eventsForSelectedDate = eventsByDate[selectedDate] || [];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Calendar
|
||||
key={theme.dark ? 'dark-calendar-period' : 'light-calendar-period'} // Change key if theme changes
|
||||
style={styles.calendar}
|
||||
theme={calendarTheme}
|
||||
current={format(currentMonthData ? new Date(currentMonthData.timestamp) : new Date(), 'yyyy-MM-dd')} // Ensure current reflects viewed month
|
||||
onDayPress={onDayPress}
|
||||
onMonthChange={onMonthChange}
|
||||
markedDates={markedDates}
|
||||
markingType={'period'} // *** SET MARKING TYPE TO PERIOD ***
|
||||
firstDay={1} // Optional: Start week on Monday
|
||||
/>
|
||||
|
||||
{isLoading && <ActivityIndicator animating={true} color={theme.colors.primary} size="large" style={styles.loadingContainer} />}
|
||||
{error && !isLoading && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<View style={styles.eventListContainer}>
|
||||
<Text style={styles.eventListHeader}>
|
||||
Events for {selectedDate === todayString ? 'Today' : format(parseISO(selectedDate), 'MMMM d, yyyy')}
|
||||
</Text>
|
||||
{eventsForSelectedDate.length > 0 ? (
|
||||
<FlatList
|
||||
data={eventsForSelectedDate}
|
||||
renderItem={renderEventItem}
|
||||
keyExtractor={(item) => item.id + item.start} // Key needs to be unique if event appears on multiple days in list potentially
|
||||
ItemSeparatorComponent={() => <View style={{ height: 5 }} />}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.noEventsText}>No events scheduled for this day.</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarScreen;
|
||||
19
interfaces/nativeapp/src/screens/ChatScreen.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// src/screens/DashboardScreen.tsx
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, useTheme } from 'react-native-paper';
|
||||
|
||||
const DashboardScreen = () => {
|
||||
const theme = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background },
|
||||
text: { fontSize: 20, color: theme.colors.text }
|
||||
});
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Chat</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardScreen;
|
||||
19
interfaces/nativeapp/src/screens/DashboardScreen.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// src/screens/DashboardScreen.tsx
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, useTheme } from 'react-native-paper';
|
||||
|
||||
const DashboardScreen = () => {
|
||||
const theme = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background },
|
||||
text: { fontSize: 20, color: theme.colors.text }
|
||||
});
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Dashboard</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardScreen;
|
||||
135
interfaces/nativeapp/src/screens/LoginScreen.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
// src/screens/LoginScreen.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { TextInput, Button, Text, useTheme, HelperText, ActivityIndicator, Avatar } from 'react-native-paper';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const LoginScreen = () => {
|
||||
const theme = useTheme();
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
console.log("[LoginScreen] handleLogin: Button pressed."); // Log: Button Press
|
||||
if (!username || !password) {
|
||||
console.log("[LoginScreen] handleLogin: Missing username or password.");
|
||||
setError('Please enter both username/email and password.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// --- Add Log Here ---
|
||||
console.log("[LoginScreen] handleLogin: Calling context login function...");
|
||||
await login(username, password);
|
||||
console.log("[LoginScreen] handleLogin: Context login function call finished (likely successful navigation).");
|
||||
// If successful, this component might unmount before this log appears fully.
|
||||
} catch (err: any) {
|
||||
console.log("[LoginScreen] handleLogin: Caught error from context login."); // Log: Error caught
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
'Login failed. Please check your credentials.';
|
||||
setError(errorMessage);
|
||||
// **Important**: Set loading false *only in the catch block* if navigation doesn't happen
|
||||
setIsLoading(false);
|
||||
console.log("[LoginScreen] handleLogin: Set loading to false after error.");
|
||||
}
|
||||
// **Remove potential premature setIsLoading(false) if it was outside the catch block**
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
input: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
button: {
|
||||
marginTop: 10,
|
||||
paddingVertical: 8, // Make button taller
|
||||
},
|
||||
errorText: {
|
||||
// Use HelperText's styling by setting type='error'
|
||||
textAlign: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
loadingContainer: {
|
||||
marginTop: 20,
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.logoContainer}>
|
||||
<Avatar.Image
|
||||
size={100}
|
||||
source={require('../assets/MAIA_ICON.png')}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.title}>MAIA Login</Text>
|
||||
<TextInput
|
||||
label="Username"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
secureTextEntry // Hides password input
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Display Error Message */}
|
||||
<HelperText type="error" visible={!!error} style={styles.errorText}>
|
||||
{error}
|
||||
</HelperText>
|
||||
|
||||
{/* Show loading indicator inline with button or replace it */}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator animating={true} color={theme.colors.primary} style={styles.loadingContainer}/>
|
||||
) : (
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleLogin}
|
||||
style={styles.button}
|
||||
disabled={isLoading} // Disable button while loading
|
||||
icon="login"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* TODO: Add Register here */}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginScreen;
|
||||
19
interfaces/nativeapp/src/screens/ProfileScreen.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// src/screens/DashboardScreen.tsx
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, useTheme } from 'react-native-paper';
|
||||
|
||||
const DashboardScreen = () => {
|
||||
const theme = useTheme();
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, backgroundColor: theme.colors.background },
|
||||
text: { fontSize: 20, color: theme.colors.text }
|
||||
});
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Profile</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardScreen;
|
||||
10
interfaces/nativeapp/src/types/calendar.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/types/calendar.ts
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string;
|
||||
location: string;
|
||||
}
|
||||
32
interfaces/nativeapp/src/types/navigation.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/types/navigation.ts
|
||||
|
||||
// Screens within the main Mobile Bottom Tab Navigator
|
||||
export type MobileTabParamList = {
|
||||
DashboardTab: undefined;
|
||||
ChatTab: undefined;
|
||||
CalendarTab: undefined;
|
||||
ProfileTab: undefined;
|
||||
};
|
||||
|
||||
// Screens within the Web Content Area Stack Navigator
|
||||
export type WebContentStackParamList = {
|
||||
Dashboard: undefined;
|
||||
Chat: undefined;
|
||||
Calendar: undefined;
|
||||
Profile: undefined;
|
||||
};
|
||||
|
||||
// Screens managed by the Root Navigator (Auth vs App)
|
||||
export type RootStackParamList = {
|
||||
AuthFlow: undefined; // Represents the stack for unauthenticated users
|
||||
AppFlow: undefined; // Represents the stack/layout for authenticated users
|
||||
};
|
||||
|
||||
// Screens within the Authentication Flow
|
||||
export type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
// Example: SignUp: undefined; ForgotPassword: undefined;
|
||||
};
|
||||
|
||||
// Type for the ref used in WebAppLayout
|
||||
export type WebContentNavigationProp = NavigationContainerRef<WebContentStackParamList>;
|
||||
6
interfaces/nativeapp/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||