diff --git a/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift b/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift index bf896a7..2d4e971 100644 --- a/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift +++ b/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift @@ -9,11 +9,15 @@ import SwiftUI import FirebaseCore import FirebaseAuth import FirebaseFunctions +import FirebaseAppCheck class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + #if DEBUG + AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory()) + #endif FirebaseApp.configure() //#if DEBUG diff --git a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift index 015f11a..d0dcfbb 100644 --- a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift +++ b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift @@ -8,9 +8,9 @@ import Foundation import Combine import FirebaseAuth -import FirebaseFirestore // new import +import FirebaseFirestore + -//changed class name + filename to FirebaseManager - Mirshod class FirebaseManager: ObservableObject { @Published var user: User? diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index 6218f83..d4fc469 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -5,8 +5,6 @@ import SwiftUI import FirebaseFunctions -import UniformTypeIdentifiers -import PDFKit import FirebaseAuth private let bottomAnchorId = "bottom" @@ -14,143 +12,88 @@ private let bottomAnchorId = "bottom" struct ChatView: View { @State private var messages: [ChatMessage] = [] @State private var inputText: String = "" - @State private var showDocumentMenu = false // bottom popup menu - @State private var showScanDocuments = false // camera scanner - @State private var showFilePicker = false // file importer + @State private var showScanDocuments = false @State private var isAwaitingReply = false @Binding var selectedLanguage: String // language in conversation + @EnvironmentObject var firebaseManager: FirebaseManager + @Environment(\.scenePhase) private var scenePhase // to detect app exit private let functions = Functions.functions() var body: some View { - ZStack(alignment: .bottom) { - VStack(spacing: 0) { - Text("LexAI") - .font(.system(size: 46)) - .fontWeight(.semibold) - .foregroundStyle(Color("grape")) - .shadow(radius: 14, x: 0, y: 12) - - messageList - inputBar - } - .padding() - .background( - LinearGradient( - colors: [ - Color.white, - Color("grape").opacity(0.6), - Color("grape").opacity(0.9), - Color("grape"), - ], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - ) - - // MARK: Dim tap-away layer - if showDocumentMenu { - Color.clear - .ignoresSafeArea() - .contentShape(Rectangle()) - .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { showDocumentMenu = false } } - } - - // MARK: Bottom popup menu (ChatGPT style) - if showDocumentMenu { - documentMenu - .transition(.move(edge: .bottom).combined(with: .opacity)) - .zIndex(10) - } + VStack(spacing: 0) { + Text("LexAI") + .font(.system(size: 46)) + .fontWeight(.semibold) + .foregroundStyle(Color("grape")) + .shadow(radius: 14, x: 0, y: 12) + + messageList + inputBar } - .animation(.easeInOut(duration: 0.22), value: showDocumentMenu) - // Camera scanner + .padding() + .background( + LinearGradient( + colors: [ + Color.white, + Color("grape").opacity(0.6), + Color("grape").opacity(0.9), + Color("grape"), + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + ) .fullScreenCover(isPresented: $showScanDocuments) { #if targetEnvironment(simulator) VStack(spacing: 20) { - Image(systemName: "camera.viewfinder") - .font(.system(size: 48)) - .foregroundStyle(Color("grape")) - Text("Camera scanner is not\navailable in the simulator.") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) + Text("Document Scanner Preview") + .font(.headline) + .padding() Button("Dismiss") { showScanDocuments = false } .buttonStyle(.borderedProminent) } - .padding() #else ScanDocumentsView(isPresented: $showScanDocuments) { scannedText in messages.append(ChatMessage(text: scannedText, isFromUser: true)) } #endif } - // File picker - .fileImporter( - isPresented: $showFilePicker, - allowedContentTypes: [.plainText, .pdf], - allowsMultipleSelection: false - ) { result in - handleFileImport(result) + // trigger 1: user leaves the view (new chat) + .onDisappear { + saveChatIfNeeded() } - } - // MARK: Document Menu - - private var documentMenu: some View { - VStack(alignment: .leading, spacing: 0) { - menuRow( - icon: "camera.viewfinder", - label: "Scan Document" - ) { - withAnimation { showDocumentMenu = false } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - showScanDocuments = true - } - } - - Divider().padding(.leading, 52) - - menuRow( - icon: "arrow.up.doc", - label: "Upload Document" - ) { - withAnimation { showDocumentMenu = false } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - showFilePicker = true - } + // trigger 2: app goes to background or is killed + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .background || newPhase == .inactive { + saveChatIfNeeded() } } - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.systemGray6)) - .shadow(color: .black.opacity(0.18), radius: 20, x: 0, y: -4) - ) - .padding(.horizontal, 16) - .padding(.bottom, 90) // clears the input bar } - private func menuRow(icon: String, label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 14) { - Image(systemName: icon) - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 28) - Text(label) - .font(.system(size: 16)) - .foregroundStyle(.primary) - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 15) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } + // MARK: - Save chat + private func saveChatIfNeeded() { + guard !messages.isEmpty else { return } + guard let userId = firebaseManager.user?.uid else { return } + + // Build a readable transcript from the message array + let transcript = messages + .map { ($0.isFromUser ? "User" : "LexAI") + ": " + $0.text } + .joined(separator: "\n") + + let chatPrompt = ChatPrompt( + prompt: transcript, + documents: [], + location: "", + language: selectedLanguage, + user: userId + ) - // MARK: Message List + firebaseManager.saveChat(prompt: chatPrompt) { _ in } + } private var messageList: some View { ScrollViewReader { proxy in @@ -159,6 +102,7 @@ struct ChatView: View { ForEach(messages) { message in MessageBubbleView(message: message) } + Color.clear .frame(height: 8) .id(bottomAnchorId) @@ -176,101 +120,55 @@ struct ChatView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - // MARK: Input Bar - private var inputBar: some View { - HStack(alignment: .bottom, spacing: 12) { - // + button — opens bottom menu - Button { - withAnimation(.easeInOut(duration: 0.22)) { - showDocumentMenu.toggle() + VStack { + HStack(alignment: .bottom, spacing: 12) { + Button(action: { showScanDocuments = true }) { + Image(systemName: "document.viewfinder") + .resizable() + .frame(width: 35, height: 35) + .fontWeight(.semibold) + .foregroundStyle(Color.white) + .shadow(radius: 8, x: 0, y: 8) } - } label: { - Image(systemName: showDocumentMenu ? "xmark" : "plus") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 35, height: 35) - .background(Color.white.opacity(0.25), in: Circle()) - .animation(.easeInOut(duration: 0.18), value: showDocumentMenu) - } - .padding(.bottom, 4) - - TextField( - ChatPlaceholderText.placeholder(forSelectedLanguage: selectedLanguage), - text: $inputText, - axis: .vertical - ) - .textFieldStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(.tertiarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .lineLimit(1...6) - - Button { sendMessage() } label: { - Image(systemName: isAwaitingReply ? "clock.arrow.circlepath" : "arrow.up.circle.fill") - .resizable() - .frame(width: 35, height: 35) - .foregroundStyle(inputText.isEmpty ? Color.white.opacity(0.6) : Color.white) - } - .disabled(inputText.isEmpty || isAwaitingReply) - .padding(.bottom, 4) - } - .padding(.horizontal, 4) - .padding(.top) - } - - // MARK: File Import Handler - - private func handleFileImport(_ result: Result<[URL], Error>) { - switch result { - case .success(let urls): - guard let url = urls.first else { return } - guard url.startAccessingSecurityScopedResource() else { return } - defer { url.stopAccessingSecurityScopedResource() } - - let extracted: String? - if url.pathExtension.lowercased() == "pdf" { - extracted = extractTextFromPDF(url: url) - } else { - extracted = try? String(contentsOf: url, encoding: .utf8) - } - - if let text = extracted, - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - messages.append(ChatMessage( - text: text.trimmingCharacters(in: .whitespacesAndNewlines), - isFromUser: true - )) - } + .padding(.bottom, 4) - case .failure(let error): - print("File import error: \(error)") - } - } + TextField(ChatPlaceholderText.placeholder(forSelectedLanguage: selectedLanguage), text: $inputText, axis: .vertical) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.tertiarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .lineLimit(1...6) + + Button { + sendMessage() + } label: { + Image(systemName: isAwaitingReply ? "clock.arrow.circlepath" : "arrow.up.circle.fill") + .resizable() + .frame(width: 35, height: 35) + .foregroundStyle(inputText.isEmpty ? Color.white.opacity(0.6) : Color.white) + } + .disabled(inputText.isEmpty || isAwaitingReply) + .padding(.bottom, 4) - private func extractTextFromPDF(url: URL) -> String? { - guard let pdf = PDFDocument(url: url) else { return nil } - var text = "" - for i in 0.. String { let callable = functions.httpsCallable("chat") + + // Build chat history from previous messages (exclude the one we just added) let chatHistory: [[String: String]] = messages.dropLast().map { msg in ["role": msg.isFromUser ? "user" : "assistant", "content": msg.text] } + let result = try await callable.call([ "prompt": prompt, "chat_history": chatHistory, "language": targetLanguage, ]) + + // Parse the response if let data = result.data as? [String: Any], let response = data["response"] as? String { return response @@ -299,13 +202,15 @@ struct ChatView: View { let error = data["error"] as? String { throw NSError(domain: "LexAI", code: -1, userInfo: [NSLocalizedDescriptionKey: error]) } + throw NSError(domain: "LexAI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to parse response"]) } } -// MARK: - Message Bubble +// MARK: - Message bubble private struct MessageBubbleView: View { + let message: ChatMessage var body: some View { @@ -327,4 +232,5 @@ private struct MessageBubbleView: View { #Preview { @Previewable @State var selectedLanguage = "English" return ChatView(selectedLanguage: $selectedLanguage) + .environmentObject(FirebaseManager()) }