Files
frontend/src/screens/FlowScreen.tsx
2026-01-28 16:27:00 -08:00

1001 lines
27 KiB
TypeScript

/**
* FlowScreen - AI Chat Interface
*
* Main chat screen for AI conversations with history management.
* Features:
* - Current conversation displayed in main window
* - Chat history accessible from top-right button
* - ChatGPT-style input bar at bottom
*/
import React, { useState, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
TouchableWithoutFeedback,
Modal,
TextInput,
SafeAreaView,
ActivityIndicator,
Alert,
FlatList,
Animated,
Image,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { aiService } from '../services/ai.service';
import { useAuth } from '../context/AuthContext';
// =============================================================================
// Type Definitions
// =============================================================================
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
imageUri?: string;
createdAt: Date;
}
interface ChatSession {
id: string;
title: string;
messages: ChatMessage[];
createdAt: Date;
updatedAt: Date;
}
// =============================================================================
// Component
// =============================================================================
export default function FlowScreen() {
const { token, signOut } = useAuth();
const scrollViewRef = useRef<ScrollView>(null);
// Current conversation state
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newContent, setNewContent] = useState('');
const [isSending, setIsSending] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
// History modal state
const [showHistoryModal, setShowHistoryModal] = useState(false);
const modalSlideAnim = useRef(new Animated.Value(0)).current;
const [chatHistory, setChatHistory] = useState<ChatSession[]>([
// Sample history data
{
id: '1',
title: 'Morning reflection',
messages: [],
createdAt: new Date('2024-01-18T10:30:00'),
updatedAt: new Date('2024-01-18T10:45:00'),
},
{
id: '2',
title: 'Project brainstorm',
messages: [],
createdAt: new Date('2024-01-17T14:00:00'),
updatedAt: new Date('2024-01-17T15:30:00'),
},
{
id: '3',
title: 'Evening thoughts',
messages: [],
createdAt: new Date('2024-01-16T20:00:00'),
updatedAt: new Date('2024-01-16T20:30:00'),
},
]);
// Header date display
const today = new Date();
const dateStr = today.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric'
});
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (messages.length > 0) {
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [messages]);
// Modal animation control
const openHistoryModal = () => {
setShowHistoryModal(true);
Animated.spring(modalSlideAnim, {
toValue: 1,
useNativeDriver: true,
tension: 65,
friction: 11,
}).start();
};
const closeHistoryModal = () => {
Animated.timing(modalSlideAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
setShowHistoryModal(false);
});
};
// =============================================================================
// Event Handlers
// =============================================================================
/**
* Handle sending a message to AI
*/
const handleSendMessage = async () => {
if (!newContent.trim() || isSending) return;
// Check authentication
if (!token) {
Alert.alert(
'Login Required',
'Please login to send messages',
[{ text: 'OK', onPress: () => signOut() }]
);
return;
}
const userMessage = newContent.trim();
setIsSending(true);
setNewContent('');
// Add user message immediately
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: userMessage,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
// Call AI proxy
const aiResponse = await aiService.sendMessage(userMessage, token);
// Add AI response
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI request failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Handle authentication errors (401, credentials, unauthorized)
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('credentials') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Not authenticated') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert(
'Session Expired',
'Your login session has expired. Please login again.',
[{ text: 'OK' }]
);
return;
}
// Show error as AI message
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `Error: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
}
};
/**
* Handle voice recording toggle
*/
const handleVoiceRecord = () => {
setIsRecording(!isRecording);
// TODO: Implement voice recording functionality
};
/**
* Handle image attachment - pick image and analyze with AI
*/
const handleAddImage = async () => {
// Request permission
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Required', 'Please grant permission to access photos');
return;
}
// Pick image
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: true,
});
if (!result.canceled && result.assets[0]) {
const imageAsset = result.assets[0];
setSelectedImage(imageAsset.uri);
// Check authentication
if (!token) {
Alert.alert(
'Login Required',
'Please login to analyze images',
[{ text: 'OK', onPress: () => signOut() }]
);
return;
}
setIsSending(true);
// Add user message with image
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: 'Analyze this image',
imageUri: imageAsset.uri,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
// Call AI with image (using base64)
const aiResponse = await aiService.sendMessageWithImage(
'Please describe and analyze this image in detail.',
imageAsset.base64 || '',
token
);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI image analysis failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Handle authentication errors
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert(
'Session Expired',
'Your login session has expired. Please login again.',
[{ text: 'OK' }]
);
return;
}
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `⚠️ Error analyzing image: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
setSelectedImage(null);
}
}
};
/**
* Start a new conversation
*/
const handleNewChat = () => {
// Save current conversation to history if it has messages
if (messages.length > 0) {
const newSession: ChatSession = {
id: Date.now().toString(),
title: messages[0]?.content.substring(0, 30) + '...' || 'New conversation',
messages: [...messages],
createdAt: messages[0]?.createdAt || new Date(),
updatedAt: new Date(),
};
setChatHistory(prev => [newSession, ...prev]);
}
// Clear current messages
setMessages([]);
closeHistoryModal();
};
/**
* Load a conversation from history
*/
const handleLoadHistory = (session: ChatSession) => {
// Save current conversation first if it has messages
if (messages.length > 0) {
const currentSession: ChatSession = {
id: Date.now().toString(),
title: messages[0]?.content.substring(0, 30) + '...' || 'Conversation',
messages: [...messages],
createdAt: messages[0]?.createdAt || new Date(),
updatedAt: new Date(),
};
setChatHistory(prev => [currentSession, ...prev.filter(s => s.id !== session.id)]);
}
// Load selected conversation
setMessages(session.messages);
closeHistoryModal();
};
/**
* Delete a conversation from history
*/
const handleDeleteHistory = (sessionId: string) => {
Alert.alert(
'Delete Conversation',
'Are you sure you want to delete this conversation?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => setChatHistory(prev => prev.filter(s => s.id !== sessionId))
},
]
);
};
// =============================================================================
// Helper Functions
// =============================================================================
const formatTime = (date: Date) => {
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
// =============================================================================
// Render Functions
// =============================================================================
/**
* Render a single chat message bubble
*/
const renderMessage = (message: ChatMessage, index: number) => {
const isUser = message.role === 'user';
return (
<View
key={message.id}
style={[
styles.messageBubble,
isUser ? styles.userBubble : styles.aiBubble
]}
>
{!isUser && (
<View style={styles.aiAvatar}>
<Feather name="feather" size={16} color={colors.nautical.teal} />
</View>
)}
<View style={[
styles.messageContent,
isUser ? styles.userContent : styles.aiContent
]}>
{/* Show image if present */}
{message.imageUri && (
<Image
source={{ uri: message.imageUri }}
style={styles.messageImage}
resizeMode="cover"
/>
)}
<Text style={[
styles.messageText,
isUser ? styles.userText : styles.aiText
]}>
{message.content}
</Text>
<Text style={[
styles.messageTime,
isUser ? styles.userMessageTime : styles.aiMessageTime
]}>
{formatTime(message.createdAt)}
</Text>
</View>
</View>
);
};
/**
* Render empty state when no messages
*/
const renderEmptyState = () => (
<View style={styles.emptyState}>
<View style={styles.emptyIcon}>
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
</View>
<Text style={styles.emptyTitle}>Start a conversation</Text>
<Text style={styles.emptySubtitle}>
Ask me anything or share your thoughts
</Text>
</View>
);
/**
* Render history item in modal
*/
const renderHistoryItem = ({ item }: { item: ChatSession }) => (
<TouchableOpacity
style={styles.historyItem}
onPress={() => handleLoadHistory(item)}
onLongPress={() => handleDeleteHistory(item.id)}
>
<View style={styles.historyItemIcon}>
<Ionicons name="chatbubble-outline" size={20} color={colors.flow.primary} />
</View>
<View style={styles.historyItemContent}>
<Text style={styles.historyItemTitle} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.historyItemDate}>
{formatDate(item.updatedAt)} {item.messages.length} messages
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.flow.textSecondary} />
</TouchableOpacity>
);
// =============================================================================
// Main Render
// =============================================================================
return (
<View style={styles.container}>
<LinearGradient
colors={[colors.flow.backgroundGradientStart, colors.flow.backgroundGradientEnd]}
style={styles.gradient}
>
<SafeAreaView style={styles.safeArea}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={styles.iconCircle}>
<FontAwesome5 name="scroll" size={18} color={colors.flow.primary} />
</View>
<View>
<Text style={styles.headerTitle}>Flow</Text>
<Text style={styles.headerDate}>{dateStr}</Text>
</View>
</View>
{/* History Button */}
<TouchableOpacity
style={styles.historyButton}
onPress={openHistoryModal}
>
<Ionicons name="time-outline" size={20} color={colors.flow.primary} />
</TouchableOpacity>
</View>
{/* Chat Messages */}
<ScrollView
ref={scrollViewRef}
style={styles.messagesContainer}
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.messagesContent,
messages.length === 0 && styles.emptyContent
]}
>
{messages.length === 0 ? (
renderEmptyState()
) : (
messages.map((message, index) => renderMessage(message, index))
)}
{/* Loading indicator when sending */}
{isSending && (
<View style={[styles.messageBubble, styles.aiBubble]}>
<View style={styles.aiAvatar}>
<Feather name="feather" size={16} color={colors.nautical.teal} />
</View>
<View style={[styles.messageContent, styles.aiContent]}>
<ActivityIndicator size="small" color={colors.nautical.teal} />
</View>
</View>
)}
<View style={{ height: 20 }} />
</ScrollView>
{/* Bottom Input Bar */}
<View style={styles.inputBarContainer}>
<View style={styles.inputBar}>
{/* Image attachment button */}
<TouchableOpacity
style={styles.inputBarButton}
onPress={handleAddImage}
activeOpacity={0.7}
>
<Feather name="image" size={22} color={colors.flow.textSecondary} />
</TouchableOpacity>
{/* Text Input */}
<View style={styles.inputWrapper}>
<TextInput
style={styles.inputBarText}
placeholder="Message..."
placeholderTextColor={colors.flow.textSecondary}
value={newContent}
onChangeText={setNewContent}
multiline
maxLength={500}
/>
</View>
{/* Send or Voice button */}
{newContent.trim() || isSending ? (
<TouchableOpacity
style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
onPress={handleSendMessage}
activeOpacity={0.8}
disabled={isSending}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.sendButtonGradient}
>
{isSending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="arrow-up" size={20} color="#fff" />
)}
</LinearGradient>
</TouchableOpacity>
) : (
<TouchableOpacity
style={[styles.inputBarButton, isRecording && styles.recordingButton]}
onPress={handleVoiceRecord}
activeOpacity={0.7}
>
<Feather
name="mic"
size={22}
color={isRecording ? '#fff' : colors.flow.textSecondary}
/>
</TouchableOpacity>
)}
</View>
</View>
</SafeAreaView>
</LinearGradient>
{/* History Modal - Background appears instantly, content slides up */}
<Modal
visible={showHistoryModal}
animationType="none"
transparent
onRequestClose={closeHistoryModal}
>
<TouchableWithoutFeedback onPress={closeHistoryModal}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
<Animated.View
style={[
styles.modalContent,
{
transform: [{
translateY: modalSlideAnim.interpolate({
inputRange: [0, 1],
outputRange: [600, 0],
})
}]
}
]}
>
<View style={styles.modalHandle} />
{/* Modal Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Chat History</Text>
<TouchableOpacity
style={styles.newChatButton}
onPress={handleNewChat}
>
<Ionicons name="add" size={20} color="#fff" />
<Text style={styles.newChatText}>New Chat</Text>
</TouchableOpacity>
</View>
{/* History List */}
<FlatList
data={chatHistory}
renderItem={renderHistoryItem}
keyExtractor={item => item.id}
style={styles.historyList}
ListEmptyComponent={
<View style={styles.historyEmpty}>
<Ionicons name="chatbubbles-outline" size={48} color={colors.flow.textSecondary} />
<Text style={styles.historyEmptyText}>No chat history yet</Text>
</View>
}
/>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={closeHistoryModal}
>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
</Animated.View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
</View>
);
}
// =============================================================================
// Styles
// =============================================================================
const styles = StyleSheet.create({
// Container styles
container: {
flex: 1
},
gradient: {
flex: 1
},
safeArea: {
flex: 1
},
// Header styles
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.base,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: colors.flow.cardBackground,
justifyContent: 'center',
alignItems: 'center',
...shadows.soft,
},
headerTitle: {
fontSize: typography.fontSize.xl,
fontWeight: '700',
color: colors.flow.text,
},
headerDate: {
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
},
historyButton: {
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: colors.flow.cardBackground,
justifyContent: 'center',
alignItems: 'center',
...shadows.soft,
},
// Messages container styles
messagesContainer: {
flex: 1
},
messagesContent: {
padding: spacing.base,
paddingTop: 0
},
emptyContent: {
flex: 1,
justifyContent: 'center',
},
// Empty state styles
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xxl,
},
emptyIcon: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: colors.flow.cardBackground,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.lg,
...shadows.soft,
},
emptyTitle: {
fontSize: typography.fontSize.lg,
fontWeight: '600',
color: colors.flow.text,
marginBottom: spacing.sm,
},
emptySubtitle: {
fontSize: typography.fontSize.base,
color: colors.flow.textSecondary,
textAlign: 'center',
},
// Message bubble styles
messageBubble: {
flexDirection: 'row',
marginBottom: spacing.md,
alignItems: 'flex-end',
},
userBubble: {
justifyContent: 'flex-end',
},
aiBubble: {
justifyContent: 'flex-start',
},
aiAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.flow.cardBackground,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.sm,
...shadows.soft,
},
messageContent: {
maxWidth: '75%',
borderRadius: borderRadius.xl,
padding: spacing.md,
...shadows.soft,
},
userContent: {
backgroundColor: colors.nautical.teal,
borderBottomRightRadius: 4,
marginLeft: 'auto',
},
aiContent: {
backgroundColor: colors.flow.cardBackground,
borderBottomLeftRadius: 4,
},
messageImage: {
width: '100%',
height: 200,
borderRadius: borderRadius.lg,
marginBottom: spacing.sm,
},
messageText: {
fontSize: typography.fontSize.base,
lineHeight: typography.fontSize.base * 1.5,
},
userText: {
color: '#fff',
},
aiText: {
color: colors.flow.text,
},
messageTime: {
fontSize: typography.fontSize.xs,
marginTop: spacing.xs,
textAlign: 'right',
},
userMessageTime: {
color: 'rgba(255, 255, 255, 0.7)',
},
aiMessageTime: {
color: colors.flow.textSecondary,
},
// Input bar styles
inputBarContainer: {
paddingHorizontal: spacing.base,
paddingBottom: spacing.lg,
paddingTop: spacing.sm,
backgroundColor: 'transparent',
},
inputBar: {
flexDirection: 'row',
alignItems: 'flex-end',
backgroundColor: colors.flow.cardBackground,
borderRadius: borderRadius.xl,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
...shadows.soft,
gap: spacing.xs,
},
inputBarButton: {
width: 40,
height: 40,
borderRadius: borderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
recordingButton: {
backgroundColor: colors.nautical.coral,
},
inputWrapper: {
flex: 1,
position: 'relative',
justifyContent: 'center',
},
inputBarText: {
fontSize: typography.fontSize.base,
color: colors.flow.text,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
maxHeight: 100,
minHeight: 40,
},
sendButton: {
width: 40,
height: 40,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
sendButtonDisabled: {
opacity: 0.7,
},
sendButtonGradient: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(26, 58, 74, 0.4)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: colors.flow.cardBackground,
borderTopLeftRadius: borderRadius.xxl,
borderTopRightRadius: borderRadius.xxl,
padding: spacing.lg,
paddingBottom: spacing.xxl,
maxHeight: '80%',
},
modalHandle: {
width: 36,
height: 4,
backgroundColor: colors.flow.cardBorder,
borderRadius: 2,
alignSelf: 'center',
marginBottom: spacing.md,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
},
modalTitle: {
fontSize: typography.fontSize.lg,
fontWeight: '700',
color: colors.flow.text,
},
newChatButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.nautical.teal,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
gap: spacing.xs,
},
newChatText: {
fontSize: typography.fontSize.sm,
fontWeight: '600',
color: '#fff',
},
// History list styles
historyList: {
flex: 1,
},
historyItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.flow.cardBorder,
},
historyItemIcon: {
width: 40,
height: 40,
borderRadius: borderRadius.lg,
backgroundColor: colors.nautical.lightMint,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
historyItemContent: {
flex: 1,
},
historyItemTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.flow.text,
marginBottom: 2,
},
historyItemDate: {
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
},
historyEmpty: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xxl,
},
historyEmptyText: {
fontSize: typography.fontSize.base,
color: colors.flow.textSecondary,
marginTop: spacing.md,
},
closeButton: {
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: colors.nautical.paleAqua,
alignItems: 'center',
marginTop: spacing.md,
},
closeButtonText: {
fontSize: typography.fontSize.base,
color: colors.flow.textSecondary,
fontWeight: '600',
},
});