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

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

View File

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

View File

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

View File

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