Files
frontend/src/screens/FlowScreen.tsx
2026-02-03 21:37:41 -08:00

1767 lines
54 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 { AIRole } from '../types';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { aiService, AIMessage } from '../services/ai.service';
import { langGraphService } from '../services/langgraph.service';
import { HumanMessage, AIMessage as LangChainAIMessage, SystemMessage } from "@langchain/core/messages";
import { assetsService } from '../services/assets.service';
import { useAuth } from '../context/AuthContext';
import { AI_CONFIG, getVaultStorageKeys } from '../config';
import { storageService } from '../services/storage.service';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SentinelVault } from '../utils/crypto_core';
import { Buffer } from 'buffer';
// =============================================================================
// 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, user, signOut, aiRoles, refreshAIRoles } = 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);
// AI Role state - start with null to detect first load
const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null);
const [showRoleModal, setShowRoleModal] = useState(false);
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// History modal state
const [showHistoryModal, setShowHistoryModal] = useState(false);
const modalSlideAnim = useRef(new Animated.Value(0)).current;
// Summary state
const [showSummaryConfirmModal, setShowSummaryConfirmModal] = useState(false);
const [showSummaryResultModal, setShowSummaryResultModal] = useState(false);
const [isSummarizing, setIsSummarizing] = useState(false);
const [generatedSummary, setGeneratedSummary] = useState('');
// Save to Vault state
const [showVaultConfirmModal, setShowVaultConfirmModal] = useState(false);
const [showSaveResultModal, setShowSaveResultModal] = useState(false);
const [saveResult, setSaveResult] = useState<{ success: boolean; message: string }>({ success: true, message: '' });
const [isSavingToVault, setIsSavingToVault] = useState(false);
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'
});
// Load history on mount
useEffect(() => {
const loadHistory = async () => {
if (!user) return;
try {
console.log('[FlowScreen] Loading chat history...');
const savedHistory = await storageService.getChatHistory(user.id);
if (savedHistory && savedHistory.length > 0) {
const formattedHistory = savedHistory.map((session: any) => ({
...session,
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
messages: session.messages.map((msg: any) => ({
...msg,
createdAt: new Date(msg.createdAt)
}))
}));
setChatHistory(formattedHistory);
console.log('[FlowScreen] Chat history loaded:', formattedHistory.length, 'sessions');
} else {
console.log('[FlowScreen] No chat history found');
}
} catch (error) {
console.error('Failed to load history:', error);
}
};
loadHistory();
}, [user]);
// Load messages whenever role changes
useEffect(() => {
const loadRoleMessages = async () => {
if (!user || !selectedRole) return;
try {
const savedMessages = await storageService.getCurrentChat(selectedRole?.id || '', user.id);
if (savedMessages) {
const formattedMessages = savedMessages.map((msg: any) => ({
...msg,
createdAt: new Date(msg.createdAt)
}));
setMessages(formattedMessages);
} else {
setMessages([]);
}
} catch (error) {
if (selectedRole) {
console.error(`Failed to load messages for role ${selectedRole?.id}:`, error);
}
setMessages([]);
}
};
loadRoleMessages();
}, [selectedRole?.id, user]);
// Ensure we have a valid selected role from the dynamic list
useEffect(() => {
if (aiRoles.length > 0) {
if (!selectedRole) {
// Initial load or first time roles become available
setSelectedRole(aiRoles[0]);
} else {
// If roles refreshed, make sure current selectedRole is still valid or updated
const updatedRole = aiRoles.find(r => r.id === selectedRole?.id);
if (updatedRole) {
setSelectedRole(updatedRole);
} else {
// Current role no longer exists in dynamic list, fallback to first
setSelectedRole(aiRoles[0]);
}
}
} else if (!selectedRole) {
// Fallback if no dynamic roles yet
setSelectedRole(AI_CONFIG.ROLES[0]);
}
}, [aiRoles]);
// Save current messages for the active role when they change
useEffect(() => {
if (user && selectedRole && messages.length >= 0) { // Save even if empty to allow clearing
storageService.saveCurrentChat(selectedRole?.id || '', messages, user.id);
}
if (messages.length > 0) {
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [messages, selectedRole?.id, user]);
// Save history when it changes
useEffect(() => {
if (user) {
storageService.saveChatHistory(chatHistory, user.id);
}
}, [chatHistory, user]);
// 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 || !selectedRole) 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 {
// 1. Convert current messages history to LangChain format
const history: (HumanMessage | LangChainAIMessage | SystemMessage)[] = messages.map(msg => {
if (msg.role === 'user') return new HumanMessage(msg.content);
return new LangChainAIMessage(msg.content);
});
// 2. Add system prompt
const systemPrompt = new SystemMessage(selectedRole?.systemPrompt || '');
// 3. Add current new message
const currentMsg = new HumanMessage(userMessage);
// 4. Combine all messages for LangGraph processing
const fullMessages = [systemPrompt, ...history, currentMsg];
// 5. Execute via LangGraph service (handles token limits and context)
const aiResponse = await langGraphService.execute(fullMessages, 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 and storage for this role
setMessages([]);
if (user && selectedRole) {
storageService.saveCurrentChat(selectedRole?.id || '', [], user.id);
}
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))
},
]
);
};
/**
* Handle generating summary for current conversation
*/
const handleGenerateSummary = async () => {
if (messages.length === 0) {
Alert.alert('No Messages', 'There are no messages to summarize.');
return;
}
if (!token) {
Alert.alert('Login Required', 'Please login to generate a summary.');
return;
}
setShowSummaryConfirmModal(false);
setIsSummarizing(true);
try {
// Convert messages to AIMessage format
const aiMessages: AIMessage[] = messages.map(msg => ({
role: msg.role,
content: msg.content,
}));
const summary = await aiService.summarizeChat(aiMessages, token);
setGeneratedSummary(summary);
setShowSummaryResultModal(true);
} catch (error) {
console.error('Failed to generate summary:', error);
Alert.alert('Error', 'Failed to generate summary. Please try again later.');
} finally {
setIsSummarizing(false);
}
};
/**
* Handle saving the generated summary to the vault
*/
const handleSaveToVault = async () => {
if (!generatedSummary || isSavingToVault) return;
if (!token) {
Alert.alert('Login Required', 'Please login to save to vault.');
return;
}
setShowVaultConfirmModal(false);
setIsSavingToVault(true);
try {
// Retrieve vault keys
if (!user) {
Alert.alert('Error', 'User information not found. Please login again.');
return;
}
const vaultKeys = getVaultStorageKeys(user.id);
const shareServer = await AsyncStorage.getItem(vaultKeys.SHARE_SERVER);
const aesKeyHex = await AsyncStorage.getItem(vaultKeys.AES_KEY);
if (!shareServer || !aesKeyHex) {
Alert.alert(
'Vault Not Initialized',
'Your vault is not fully initialized. Please set it up in the Vault tab first.'
);
return;
}
// Encrypt summary with AES key
const vault = new SentinelVault();
const aesKey = Buffer.from(aesKeyHex, 'hex');
const encryptedSummary = vault.encryptData(aesKey, generatedSummary).toString('hex');
// Create asset in backend
const createdAsset = await assetsService.createAsset({
title: `Chat Summary - ${new Date().toLocaleDateString()}`,
private_key_shard: shareServer,
content_inner_encrypted: encryptedSummary,
}, token);
// Backup plaintext content locally
if (createdAsset && createdAsset.id && user?.id) {
await storageService.saveAssetBackup(createdAsset.id, generatedSummary, user.id);
}
setSaveResult({ success: true, message: 'Summary encrypted and saved to your vault successfully.' });
setShowSaveResultModal(true);
} catch (error) {
console.error('Failed to save to vault:', error);
setSaveResult({ success: false, message: 'Failed to save summary to vault. Please try again.' });
setShowSaveResultModal(true);
} finally {
setIsSavingToVault(false);
}
};
/**
* Handle closing all summary related modals after successful save or manual close of result
*/
const handleFinishSaveFlow = () => {
setShowSaveResultModal(false);
if (saveResult.success) {
setShowSummaryResultModal(false);
setShowVaultConfirmModal(false);
}
};
// =============================================================================
// 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}>Chatting with {selectedRole?.name || 'AI'}</Text>
<Text style={styles.emptySubtitle}>
{selectedRole?.description || 'Loading AI Assistant...'}
</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>
{/* Role Header Dropdown */}
<TouchableOpacity
style={styles.headerRoleButton}
onPress={() => setShowRoleModal(true)}
activeOpacity={0.7}
>
{selectedRole && (
<Ionicons
name={(selectedRole?.icon || 'help-outline') as any}
size={16}
color={colors.nautical.teal}
/>
)}
<Text style={styles.headerRoleText} numberOfLines={1}>
{selectedRole?.name || 'Loading...'}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
</TouchableOpacity>
{/* Summary Button */}
<TouchableOpacity
style={[styles.historyButton, { marginRight: spacing.sm }]}
onPress={() => setShowSummaryConfirmModal(true)}
disabled={messages.length === 0 || isSummarizing}
>
<Ionicons
name="document-text-outline"
size={20}
color={messages.length === 0 || isSummarizing ? colors.flow.textSecondary : colors.flow.primary}
/>
</TouchableOpacity>
{/* 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>
<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>
{/* Role Selection Modal */}
<Modal
visible={showRoleModal}
animationType="fade"
transparent
onRequestClose={() => setShowRoleModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowRoleModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
<View style={[styles.modalContent, styles.roleModalContent]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Choose AI Assistant</Text>
<ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}>
{aiRoles.map((role) => (
<View key={role.id} style={styles.roleItemContainer}>
<View
style={[
styles.roleItem,
selectedRole?.id === role.id && styles.roleItemActive
]}
>
<TouchableOpacity
style={styles.roleSelectionArea}
onPress={() => {
setSelectedRole(role);
setShowRoleModal(false);
}}
>
<View style={[
styles.roleItemIcon,
selectedRole?.id === role.id && styles.roleItemIconActive
]}>
<Ionicons
name={role.icon as any}
size={20}
color={selectedRole?.id === role.id ? '#fff' : colors.nautical.teal}
/>
</View>
<Text style={[
styles.roleItemName,
selectedRole?.id === role.id && styles.roleItemNameActive
]}>
{role.name}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.infoButton}
onPress={() => {
setExpandedRoleId(expandedRoleId === role.id ? null : role.id);
}}
>
<Ionicons
name={expandedRoleId === role.id ? "close-circle-outline" : "information-circle-outline"}
size={24}
color={expandedRoleId === role.id ? colors.nautical.coral : colors.flow.textSecondary}
/>
</TouchableOpacity>
</View>
{expandedRoleId === role.id && (
<View style={styles.roleDescription}>
<Text style={styles.roleDescriptionText}>{role.description}</Text>
</View>
)}
</View>
))}
</ScrollView>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowRoleModal(false)}
>
<Text style={styles.closeButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Confirmation Modal */}
<Modal
visible={showSummaryConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowSummaryConfirmModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowSummaryConfirmModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Generate Summary</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
Would you like to generate a summary for the current conversation?
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.actionButton, styles.cancelButton]}
onPress={() => setShowSummaryConfirmModal(false)}
>
<Text style={styles.cancelButtonText}>No</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={handleGenerateSummary}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Yes, Generate</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Result Modal */}
<Modal
visible={showSummaryResultModal}
transparent
animationType="slide"
onRequestClose={() => setShowSummaryResultModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowSummaryResultModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { maxHeight: '70%' }]}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Conversation Summary</Text>
<TouchableOpacity onPress={() => setShowSummaryResultModal(false)}>
<Ionicons name="close" size={24} color={colors.flow.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.summaryContainer} showsVerticalScrollIndicator={false}>
<View style={styles.summaryCard}>
<Text style={styles.summaryText}>{generatedSummary}</Text>
</View>
</ScrollView>
<View style={styles.summaryActions}>
<TouchableOpacity
style={[styles.actionButton, styles.saveToVaultButton]}
onPress={() => setShowVaultConfirmModal(true)}
disabled={isSavingToVault}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
{isSavingToVault ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="shield-checkmark-outline" size={20} color="#fff" />
<Text style={styles.confirmButtonText}>Save to Vault</Text>
</>
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowSummaryResultModal(false)}
>
<Text style={styles.closeButtonText}>Done</Text>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Save to Vault Confirmation Modal */}
<Modal
visible={showVaultConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowVaultConfirmModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowVaultConfirmModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Save to Vault</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
Would you like to securely save this summary to your digital vault?
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.actionButton, styles.cancelButton]}
onPress={() => setShowVaultConfirmModal(false)}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={handleSaveToVault}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Yes, Save</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Save Result Modal */}
<Modal
visible={showSaveResultModal}
transparent
animationType="fade"
onRequestClose={handleFinishSaveFlow}
>
<TouchableWithoutFeedback onPress={handleFinishSaveFlow}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl, alignItems: 'center' }]}>
<View style={styles.modalHandle} />
<View style={[
styles.resultIconContainer,
saveResult.success ? styles.successIconBg : styles.errorIconBg
]}>
<Ionicons
name={saveResult.success ? "checkmark-circle" : "alert-circle"}
size={64}
color={saveResult.success ? colors.nautical.teal : colors.nautical.coral}
/>
</View>
<Text style={styles.modalTitle}>
{saveResult.success ? 'Success!' : 'Oops!'}
</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base, textAlign: 'center' }]}>
{saveResult.message}
</Text>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton, { width: '100%' }]}
onPress={handleFinishSaveFlow}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Confirm</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Loading Modal */}
<Modal
visible={isSummarizing}
transparent
animationType="fade"
>
<View style={styles.loadingOverlay}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.nautical.teal} />
<Text style={styles.loadingText}>Generating Summary...</Text>
</View>
</View>
</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.sm,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
flex: 1,
},
headerRoleButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: borderRadius.full,
marginHorizontal: spacing.sm,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
maxWidth: '40%',
},
headerRoleText: {
fontSize: typography.fontSize.xs,
fontWeight: '600',
color: colors.flow.text,
marginHorizontal: 4,
},
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',
},
// Role selection styles
roleDropdown: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
marginBottom: spacing.md,
...shadows.soft,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
},
roleIcon: {
marginRight: spacing.sm,
},
roleDropdownText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.flow.text,
marginRight: spacing.xs,
},
roleModalContent: {
paddingBottom: spacing.xl,
},
roleList: {
marginTop: spacing.sm,
maxHeight: 400,
},
roleItemContainer: {
marginBottom: spacing.sm,
},
roleItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: borderRadius.lg,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: 'transparent',
overflow: 'hidden',
},
roleItemActive: {
backgroundColor: colors.nautical.paleAqua,
borderColor: colors.nautical.lightMint,
},
roleSelectionArea: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
roleItemIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.flow.backgroundGradientStart,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
roleItemIconActive: {
backgroundColor: colors.nautical.teal,
},
roleItemName: {
fontSize: typography.fontSize.base,
fontWeight: '500',
color: colors.flow.text,
},
roleItemNameActive: {
fontWeight: '700',
color: colors.nautical.teal,
},
infoButton: {
padding: spacing.md,
justifyContent: 'center',
alignItems: 'center',
},
roleDescription: {
paddingHorizontal: spacing.md + 36 + spacing.md, // icon width + margins
paddingBottom: spacing.sm,
paddingTop: 0,
},
roleDescriptionText: {
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
fontStyle: 'italic',
lineHeight: 18,
},
// 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',
},
// Summary Modal styles
modalSubtitle: {
fontSize: typography.fontSize.base,
color: colors.flow.textSecondary,
lineHeight: 22,
},
modalActions: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.base,
},
actionButton: {
flex: 1,
height: 50,
borderRadius: borderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
actionButtonGradient: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
cancelButton: {
backgroundColor: colors.nautical.paleAqua,
},
confirmButton: {
// Gradient handled in child
},
cancelButtonText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.flow.textSecondary,
},
confirmButtonText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: '#fff',
},
summaryContainer: {
marginVertical: spacing.md,
},
summaryCard: {
backgroundColor: colors.nautical.paleAqua + '40', // 25% opacity
padding: spacing.md,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.nautical.lightMint,
},
summaryText: {
fontSize: typography.fontSize.base,
color: colors.flow.text,
lineHeight: 24,
},
summaryActions: {
marginTop: spacing.md,
gap: spacing.sm,
},
saveToVaultButton: {
height: 54,
},
resultIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
successIconBg: {
backgroundColor: colors.nautical.paleAqua,
},
errorIconBg: {
backgroundColor: 'rgba(231, 76, 60, 0.1)', // coral at 10%
},
loadingOverlay: {
flex: 1,
backgroundColor: 'rgba(26, 58, 74, 0.6)',
justifyContent: 'center',
alignItems: 'center',
},
loadingContainer: {
backgroundColor: colors.flow.cardBackground,
padding: spacing.xl,
borderRadius: borderRadius.xl,
alignItems: 'center',
...shadows.soft,
gap: spacing.md,
},
loadingText: {
fontSize: typography.fontSize.base,
color: colors.flow.text,
fontWeight: '600',
},
});