From 076343c7f174be2fcbc8b7332e53230fe834767f Mon Sep 17 00:00:00 2001 From: Hassan Alkhafaji Date: Tue, 14 Apr 2026 08:54:50 -0400 Subject: [PATCH 1/4] Connected most features together. There seems to be a bug with our save chats --- LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift | 3 + .../LexAI_iOS/Managers/FirebaseManager.swift | 11 +- .../Models/ChatPlaceholderText.swift | 10 +- LexAI_iOS/LexAI_iOS/Views/AuthView.swift | 2 +- LexAI_iOS/LexAI_iOS/Views/ChatView.swift | 163 +++++++++++++++--- LexAI_iOS/LexAI_iOS/Views/ContentView.swift | 1 + LexAI_iOS/LexAI_iOS/Views/HomeView.swift | 3 + .../ChatPlaceholderTextTests.swift | 12 +- lexai-functions/main.py | 36 +++- 9 files changed, 202 insertions(+), 39 deletions(-) diff --git a/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift b/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift index 2d4e971..fd5a7fe 100644 --- a/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift +++ b/LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift @@ -15,6 +15,9 @@ import FirebaseAppCheck class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + return true + } #if DEBUG AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory()) #endif diff --git a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift index b6a4cd4..bf8b1a7 100644 --- a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift +++ b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift @@ -8,6 +8,7 @@ import Foundation import Combine +import FirebaseCore import FirebaseAuth import FirebaseFirestore @@ -19,9 +20,15 @@ class FirebaseManager: ObservableObject { @Published var isLoading = false private var authStateListener: AuthStateDidChangeListenerHandle? - private let db = Firestore.firestore() + private lazy var db = Firestore.firestore() + private let isPreview: Bool + + init(isPreview: Bool = false) { + let runningInPreviews = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + self.isPreview = isPreview || runningInPreviews + guard !self.isPreview else { return } + guard FirebaseApp.app() != nil else { return } - init() { authStateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in Task { @MainActor in self?.user = user diff --git a/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift b/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift index f8c4192..293dd04 100644 --- a/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift +++ b/LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift @@ -10,15 +10,15 @@ enum ChatPlaceholderText { static func placeholder(forSelectedLanguage language: String) -> String { switch language { case "Spanish": - return "Mensaje..." + return "Haz una pregunta sobre una ley..." case "French": - return "Message..." + return "Posez une question sur une loi..." case "Arabic": - return "رسالة..." + return "اطرح سؤالا حول قانون..." case "German": - return "Nachricht..." + return "Stellen Sie eine Frage zu einem Gesetz..." default: - return "Message..." + return "Ask a question about a law..." } } } diff --git a/LexAI_iOS/LexAI_iOS/Views/AuthView.swift b/LexAI_iOS/LexAI_iOS/Views/AuthView.swift index 55c82d0..00b3021 100644 --- a/LexAI_iOS/LexAI_iOS/Views/AuthView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/AuthView.swift @@ -226,7 +226,7 @@ struct AuthView: View { #Preview { AuthView() - .environmentObject(FirebaseManager()) + .environmentObject(FirebaseManager(isPreview: true)) } diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index fdb5b22..0a94a0d 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -3,6 +3,9 @@ import SwiftUI import FirebaseFunctions +import UniformTypeIdentifiers +import PDFKit +import FirebaseAuth private let bottomAnchorId = "bottom" @@ -14,7 +17,11 @@ struct ChatView: View { var sessionID: UUID? = nil @State private var inputText: String = "" + @State private var showScanDocuments = false + @State private var showFilePicker = false @State private var isAwaitingReply = false + @EnvironmentObject var firebaseManager: FirebaseManager + @Environment(\.scenePhase) private var scenePhase private let functions = Functions.functions() var body: some View { @@ -42,6 +49,36 @@ struct ChatView: View { ) .ignoresSafeArea() ) + .fullScreenCover(isPresented: $showScanDocuments) { + #if targetEnvironment(simulator) + VStack(spacing: 20) { + Text("Document Scanner Preview") + .font(.headline) + .padding() + Button("Dismiss") { showScanDocuments = false } + .buttonStyle(.borderedProminent) + } + #else + ScanDocumentsView(isPresented: $showScanDocuments) { scannedText in + messages.append(ChatMessage(text: scannedText, isFromUser: true)) + } + #endif + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.plainText, .pdf], + allowsMultipleSelection: false + ) { result in + handleFileImport(result) + } + .onDisappear { + saveChatIfNeeded() + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .background || newPhase == .inactive { + saveChatIfNeeded() + } + } } private var messageList: some View { @@ -82,34 +119,116 @@ struct ChatView: View { } private var inputBar: some View { - HStack(alignment: .bottom, spacing: 12) { - 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) + VStack(spacing: 10) { + HStack(spacing: 10) { + actionButton(icon: "document.viewfinder", label: "Scan Document") { + showScanDocuments = true + } + actionButton(icon: "arrow.up.doc", label: "Upload Document") { + showFilePicker = true + } + Spacer() + } + .padding(.horizontal, 4) + + HStack(alignment: .bottom, spacing: 12) { + 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) } - .disabled(inputText.isEmpty || isAwaitingReply) - .padding(.bottom, 4) } .padding(.horizontal, 4) .padding(.top) } + private func actionButton(icon: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + Text(label) + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.white.opacity(0.22), in: Capsule()) + } + .buttonStyle(.plain) + } + + 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)) + } + + case .failure(let error): + print("File import error: \(error)") + } + } + + private func extractTextFromPDF(url: URL) -> String? { + guard let pdf = PDFDocument(url: url) else { return nil } + var text = "" + for i in 0.. str: return "" +def _extract_matches(results: Any) -> list[Any]: + """Support both dict-like and object-like Pinecone query responses.""" + if isinstance(results, dict): + return results.get("matches", []) or [] + matches = getattr(results, "matches", None) + if matches is not None: + return list(matches) + if hasattr(results, "to_dict"): + return (results.to_dict() or {}).get("matches", []) or [] + return [] + + +def _extract_metadata(match: Any) -> Any: + """Support both dict-like and object-like Pinecone match records.""" + if isinstance(match, dict): + return match.get("metadata") + metadata = getattr(match, "metadata", None) + if metadata is not None: + return metadata + if hasattr(match, "to_dict"): + return (match.to_dict() or {}).get("metadata") + return None + + def query_pinecone(query_embedding: list, top_k: int = 5) -> list: _, index = _get_pinecone() # Prefer chunk vectors (embed_and_store sets chunk_idx). If index has no chunk_idx, fall back unfiltered. @@ -111,10 +135,16 @@ def query_pinecone(query_embedding: list, top_k: int = 5) -> list: } if use_chunk_filter: kwargs["filter"] = {"chunk_idx": {"$gte": 0}} - results = index.query(**kwargs) + try: + results = index.query(**kwargs) + except Exception: + if use_chunk_filter: + # Some indexes don't support this metadata filter shape; retry unfiltered. + continue + raise chunks: list[str] = [] - for match in results.get("matches", []): - text = _legislation_text_from_metadata(match.get("metadata")) + for match in _extract_matches(results): + text = _legislation_text_from_metadata(_extract_metadata(match)) if text: chunks.append(text) if chunks: From 9d7cd71e4c172c97436a752a448b0a8884f60210 Mon Sep 17 00:00:00 2001 From: Hassan Alkhafaji Date: Tue, 14 Apr 2026 09:27:39 -0400 Subject: [PATCH 2/4] Had to fix a lot of the functionality that was acting different once all together --- .../LexAI_iOS/Managers/FirebaseManager.swift | 34 +++- LexAI_iOS/LexAI_iOS/Views/ChatView.swift | 22 ++- LexAI_iOS/LexAI_iOS/Views/HomeView.swift | 171 +++++++++++++++++- LexAI_iOS/LexAI_iOS/Views/SideBarView.swift | 34 ++-- .../LexAI_iOSTests/FirebaseManagerTests.swift | 5 +- 5 files changed, 240 insertions(+), 26 deletions(-) diff --git a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift index bf8b1a7..5599e0b 100644 --- a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift +++ b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift @@ -170,7 +170,7 @@ class FirebaseManager: ObservableObject { // MARK: - Legacy: single-prompt chat history (kept for backwards compatibility) - func saveChat(prompt: ChatPrompt, completion: @escaping (Bool) -> Void) { + func saveChat(prompt: ChatPrompt, completion: @escaping (String?) -> Void) { let data: [String: Any] = [ "prompt": prompt.prompt, "documents": prompt.documents, @@ -179,8 +179,13 @@ class FirebaseManager: ObservableObject { "user": prompt.user, "timestamp": FieldValue.serverTimestamp() ] - db.collection("chatHistory").addDocument(data: data) { error in - completion(error == nil) + var ref: DocumentReference? + ref = db.collection("chatHistory").addDocument(data: data) { error in + if error != nil { + completion(nil) + return + } + completion(ref?.documentID) } } @@ -191,6 +196,7 @@ class FirebaseManager: ObservableObject { let chats: [ChatPrompt] = snapshot?.documents.compactMap { doc in let d = doc.data() return ChatPrompt( + id: doc.documentID, prompt: d["prompt"] as? String ?? "", documents: d["documents"] as? [String] ?? [], location: d["location"] as? String ?? "", @@ -276,9 +282,31 @@ class FirebaseManager: ObservableObject { // MARK: - Models struct ChatPrompt { + var id: String? let prompt: String let documents: [String] let location: String let language: String let user: String + + var previewTitle: String { + let firstLine = prompt + .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? prompt + + let cleaned: String + if firstLine.hasPrefix("User: ") { + cleaned = String(firstLine.dropFirst("User: ".count)) + } else { + cleaned = firstLine + } + + let words = cleaned.split(whereSeparator: { $0.isWhitespace }) + guard !words.isEmpty else { return "Conversation" } + + let maxWords = 8 + let title = words.prefix(maxWords).joined(separator: " ") + return words.count > maxWords ? title + "..." : title + } } diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index 0a94a0d..159a535 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -12,9 +12,11 @@ private let bottomAnchorId = "bottom" struct ChatView: View { @Binding var messages: [ChatMessage] @Binding var selectedLanguage: String + @Binding var activeChatDocumentID: String? var vm: SidebarViewModel? = nil var sessionID: UUID? = nil + var onChatPersisted: ((String) -> Void)? = nil @State private var inputText: String = "" @State private var showScanDocuments = false @@ -211,6 +213,7 @@ struct ChatView: View { } private func saveChatIfNeeded() { + print("saveChatIfNeeded called, message count: \(messages.count)") guard !messages.isEmpty else { return } guard let userId = firebaseManager.user?.uid else { return } @@ -219,6 +222,7 @@ struct ChatView: View { .joined(separator: "\n") let chatPrompt = ChatPrompt( + id: activeChatDocumentID, prompt: transcript, documents: [], location: "", @@ -226,7 +230,23 @@ struct ChatView: View { user: userId ) - firebaseManager.saveChat(prompt: chatPrompt) { _ in } + if let existingID = activeChatDocumentID, !existingID.isEmpty { + firebaseManager.updateChat(chatId: existingID, newPrompt: transcript) { success in + print("Save result: \(success)") + if success { + onChatPersisted?(transcript) + } + } + } else { + firebaseManager.saveChat(prompt: chatPrompt) { newDocumentID in + let success = (newDocumentID != nil) + print("Save result: \(success)") + if let newDocumentID { + activeChatDocumentID = newDocumentID + onChatPersisted?(transcript) + } + } + } } private func sendMessage() { diff --git a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift index 38bcfd9..504e550 100644 --- a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift @@ -1,4 +1,5 @@ import SwiftUI +import FirebaseAuth struct HomeView: View { @@ -10,6 +11,8 @@ struct HomeView: View { @StateObject private var sidebarVM = SidebarViewModel() @State private var messages: [ChatMessage] = [] @State private var chatViewResetID = UUID() + @State private var activeChatDocumentID: String? + @State private var chatBySessionID: [UUID: ChatPrompt] = [:] @EnvironmentObject var firebaseManager: FirebaseManager private let languages = ["English", "Spanish", "French", "Arabic", "German"] @@ -19,7 +22,16 @@ struct HomeView: View { GeometryReader { geo in ZStack(alignment: .leading) { VStack { - ChatView(messages: $messages, selectedLanguage: $selectedLanguage) + ChatView( + messages: $messages, + selectedLanguage: $selectedLanguage, + activeChatDocumentID: $activeChatDocumentID, + vm: sidebarVM, + sessionID: sidebarVM.activeSessionID, + onChatPersisted: { transcript in + reloadChatHistory(selectTranscript: transcript) + } + ) .environmentObject(firebaseManager) .id(chatViewResetID) } @@ -30,11 +42,37 @@ struct HomeView: View { .onTapGesture { isSidebarOpen = false } } - SideBarView(isOpen: $isSidebarOpen, vm: sidebarVM, onNewChat: { - // Reset both chat data and local ChatView state for a true fresh thread. - messages = [] - chatViewResetID = UUID() - }) + SideBarView( + isOpen: $isSidebarOpen, + vm: sidebarVM, + onSelectSession: { session in + if let chat = chatBySessionID[session.id] { + messages = parseTranscript(chat.prompt) + activeChatDocumentID = chat.id + } + }, + onNewChat: { + persistCurrentChatIfNeeded { + messages = [] + activeChatDocumentID = nil + sidebarVM.activeSessionID = nil + chatViewResetID = UUID() + reloadChatHistory() + } + }, + onDeleteSession: { session in + guard let chatID = chatBySessionID[session.id]?.id else { return } + firebaseManager.deleteChat(chatId: chatID) { success in + if success { + if activeChatDocumentID == chatID { + messages = [] + activeChatDocumentID = nil + } + reloadChatHistory() + } + } + } + ) .frame(width: geo.size.width * 0.80) .offset(x: isSidebarOpen ? 0 : -(geo.size.width * 0.80)) .shadow(color: .black.opacity(isSidebarOpen ? 0.2 : 0), radius: 16, x: 4, y: 0) @@ -89,6 +127,127 @@ struct HomeView: View { LegalDisclaimerAlert() } } + .task { + reloadChatHistory() + } + .onChange(of: firebaseManager.user?.uid) { _, _ in + reloadChatHistory() + } + .onChange(of: isSidebarOpen) { _, isOpen in + if isOpen { + reloadChatHistory() + } + } + } + + private func reloadChatHistory(selectTranscript: String? = nil) { + guard let userId = firebaseManager.user?.uid, !userId.isEmpty else { + sidebarVM.sessions = [] + chatBySessionID = [:] + return + } + + firebaseManager.fetchChats(userId: userId) { chats in + var mapping: [UUID: ChatPrompt] = [:] + var sessions: [ChatSession] = [] + + for chat in chats { + let sid = UUID() + mapping[sid] = chat + sessions.append( + ChatSession( + id: sid, + title: chat.previewTitle, + preview: chat.previewTitle + ) + ) + } + + chatBySessionID = mapping + sidebarVM.sessions = sessions + + if let transcript = selectTranscript, + let matched = chats.first(where: { $0.prompt == transcript }), + let matchedID = matched.id { + activeChatDocumentID = matchedID + } + + if let activeID = activeChatDocumentID, + let matchingPair = mapping.first(where: { $0.value.id == activeID }) { + sidebarVM.activeSessionID = matchingPair.key + } + } + } + + private func parseTranscript(_ transcript: String) -> [ChatMessage] { + let lines = transcript.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var parsed: [ChatMessage] = [] + var currentSpeakerIsUser: Bool? + var currentText: String = "" + + func flushCurrent() { + guard let isUser = currentSpeakerIsUser else { return } + let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + parsed.append(ChatMessage(text: text, isFromUser: isUser)) + } + currentSpeakerIsUser = nil + currentText = "" + } + + for line in lines { + if line.hasPrefix("User: ") { + flushCurrent() + currentSpeakerIsUser = true + currentText = String(line.dropFirst("User: ".count)) + } else if line.hasPrefix("LexAI: ") { + flushCurrent() + currentSpeakerIsUser = false + currentText = String(line.dropFirst("LexAI: ".count)) + } else if currentSpeakerIsUser != nil { + currentText += currentText.isEmpty ? line : "\n" + line + } + } + + flushCurrent() + return parsed + } + + private func persistCurrentChatIfNeeded(completion: @escaping () -> Void) { + guard !messages.isEmpty else { + completion() + return + } + guard let userId = firebaseManager.user?.uid, !userId.isEmpty else { + completion() + return + } + + let transcript = messages + .map { ($0.isFromUser ? "User" : "LexAI") + ": " + $0.text } + .joined(separator: "\n") + + if let existingID = activeChatDocumentID, !existingID.isEmpty { + firebaseManager.updateChat(chatId: existingID, newPrompt: transcript) { _ in + completion() + } + return + } + + let prompt = ChatPrompt( + id: nil, + prompt: transcript, + documents: [], + location: "", + language: selectedLanguage, + user: userId + ) + firebaseManager.saveChat(prompt: prompt) { newID in + if let newID { + activeChatDocumentID = newID + } + completion() + } } } diff --git a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift index 45c7ce1..ac78e35 100644 --- a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift @@ -214,11 +214,10 @@ final class SidebarViewModel: ObservableObject { func updateSession(id: UUID, messages: [ChatMessage]) { guard let i = sessions.firstIndex(where: { $0.id == id }) else { return } sessions[i].messages = messages - // Title: use the first user message as-is (truncated to 50 chars). - // Only overwrite while the session still carries the default placeholder. + // Title: use the first user message for context. if sessions[i].title == "New Conversation", let firstUserMsg = messages.first(where: { $0.isFromUser }) { - sessions[i].title = String(firstUserMsg.text.prefix(50)) + sessions[i].title = generateTitle(from: firstUserMsg.text) } if let lastAI = messages.last(where: { !$0.isFromUser }) { sessions[i].preview = String(lastAI.text.prefix(60)) @@ -242,14 +241,13 @@ final class SidebarViewModel: ObservableObject { private func generateTitle(from query: String) -> String { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count > 50 else { return trimmed } - // Truncate at the last word boundary before 50 chars - let index = trimmed.index(trimmed.startIndex, offsetBy: 50) - let cut = trimmed[.. maxWords ? title + "..." : title } } @@ -260,7 +258,9 @@ final class SidebarViewModel: ObservableObject { struct SideBarView: View { @Binding var isOpen: Bool @ObservedObject var vm: SidebarViewModel + var onSelectSession: ((ChatSession) -> Void)? = nil var onNewChat: (() -> Void)? = nil + var onDeleteSession: ((ChatSession) -> Void)? = nil @State private var renamingSession: ChatSession? = nil @State private var renameText: String = "" @@ -288,7 +288,6 @@ struct SideBarView: View { // MARK: New Chat Button { - vm.newSession() onNewChat?() isOpen = false } label: { @@ -446,6 +445,7 @@ struct SideBarView: View { .contentShape(Rectangle()) .onTapGesture { vm.activeSessionID = session.id + onSelectSession?(session) isOpen = false } .contextMenu { @@ -462,14 +462,20 @@ struct SideBarView: View { Label("Rename", systemImage: "pencil") } Button(role: .destructive) { - withAnimation { vm.delete(session) } + withAnimation { + vm.delete(session) + onDeleteSession?(session) + } } label: { Label("Delete", systemImage: "trash") } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { - withAnimation { vm.delete(session) } + withAnimation { + vm.delete(session) + onDeleteSession?(session) + } } label: { Label("Delete", systemImage: "trash") } diff --git a/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift b/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift index ba55e7d..d991d1b 100644 --- a/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift +++ b/LexAI_iOS/LexAI_iOSTests/FirebaseManagerTests.swift @@ -7,6 +7,7 @@ final class FirebaseManagerTests: XCTestCase { let manager = FirebaseManager() let chat = ChatPrompt( + id: nil, prompt: "Test", documents: ["doc1"], location: "United States", @@ -16,8 +17,8 @@ final class FirebaseManagerTests: XCTestCase { let expectation = self.expectation(description: "Save chat completes.") - manager.saveChat(prompt: chat) { success in - XCTAssertNotNil(success) // check + manager.saveChat(prompt: chat) { chatID in + XCTAssertNotNil(chatID) // check expectation.fulfill() } From fd8f9ae546757c2c995d129f305e90f456fdd617 Mon Sep 17 00:00:00 2001 From: Hassan Alkhafaji Date: Tue, 14 Apr 2026 09:46:47 -0400 Subject: [PATCH 3/4] Added a signout button so we can change accounts --- .../LexAI_iOS/Managers/FirebaseManager.swift | 2 +- LexAI_iOS/LexAI_iOS/Views/SideBarView.swift | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift index 5599e0b..ded28f2 100644 --- a/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift +++ b/LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift @@ -32,7 +32,7 @@ class FirebaseManager: ObservableObject { authStateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in Task { @MainActor in self?.user = user - self?.isAuthenticated = user != nil + self?.isAuthenticated = (user?.isAnonymous == false) } } if Auth.auth().currentUser == nil { diff --git a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift index ac78e35..d1342bd 100644 --- a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift @@ -261,17 +261,24 @@ struct SideBarView: View { var onSelectSession: ((ChatSession) -> Void)? = nil var onNewChat: (() -> Void)? = nil var onDeleteSession: ((ChatSession) -> Void)? = nil + @EnvironmentObject var firebaseManager: FirebaseManager @State private var renamingSession: ChatSession? = nil @State private var renameText: String = "" + @State private var showSignOutAlert = false var body: some View { VStack(alignment: .leading, spacing: 0) { // MARK: Header HStack(spacing: 10) { - Image(systemName: "scale.3d") - .font(.system(size: 16, weight: .semibold)) + Button { + showSignOutAlert = true + } label: { + Image(systemName: "person.circle") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.secondary) + } Text("LexAI") .font(.system(size: 17, weight: .semibold)) Spacer() @@ -419,6 +426,14 @@ struct SideBarView: View { } Button("Cancel", role: .cancel) { renamingSession = nil } } + .alert("Sign Out", isPresented: $showSignOutAlert) { + Button("Cancel", role: .cancel) {} + Button("Sign Out", role: .destructive) { + firebaseManager.signOut() + } + } message: { + Text("Are you sure you want to sign out?") + } } // MARK: Session Row @@ -505,6 +520,7 @@ struct SideBarView: View { .onTapGesture { isOpen = false } } SideBarView(isOpen: $isOpen, vm: vm) + .environmentObject(FirebaseManager(isPreview: true)) .frame(width: geo.size.width * 0.80) .offset(x: isOpen ? 0 : -(geo.size.width * 0.80)) .shadow(color: .black.opacity(0.2), radius: 16, x: 4, y: 0) From 269bbb25b05a00faf0781a71f4a202eb4d6c06ff Mon Sep 17 00:00:00 2001 From: Hassan Alkhafaji Date: Tue, 14 Apr 2026 10:10:42 -0400 Subject: [PATCH 4/4] Successfully merged everyones work together with ALL included features due to git issues --- LexAI_iOS/LexAI_iOS/Views/ChatView.swift | 7 ++++++- LexAI_iOS/LexAI_iOS/Views/HomeView.swift | 20 ++++++++++++++++---- LexAI_iOS/LexAI_iOS/Views/SideBarView.swift | 4 ++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift index 159a535..9783e84 100644 --- a/LexAI_iOS/LexAI_iOS/Views/ChatView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/ChatView.swift @@ -13,6 +13,7 @@ struct ChatView: View { @Binding var messages: [ChatMessage] @Binding var selectedLanguage: String @Binding var activeChatDocumentID: String? + @Binding var hasSavedBeforeLeaving: Bool var vm: SidebarViewModel? = nil var sessionID: UUID? = nil @@ -213,7 +214,8 @@ struct ChatView: View { } private func saveChatIfNeeded() { - print("saveChatIfNeeded called, message count: \(messages.count)") + print("saveChatIfNeeded called — messages: \(messages.count), chatId: \(activeChatDocumentID ?? "nil")") + guard !hasSavedBeforeLeaving else { return } guard !messages.isEmpty else { return } guard let userId = firebaseManager.user?.uid else { return } @@ -234,6 +236,7 @@ struct ChatView: View { firebaseManager.updateChat(chatId: existingID, newPrompt: transcript) { success in print("Save result: \(success)") if success { + hasSavedBeforeLeaving = true onChatPersisted?(transcript) } } @@ -243,6 +246,7 @@ struct ChatView: View { print("Save result: \(success)") if let newDocumentID { activeChatDocumentID = newDocumentID + hasSavedBeforeLeaving = true onChatPersisted?(transcript) } } @@ -252,6 +256,7 @@ struct ChatView: View { private func sendMessage() { let text = ChatInputValidator.trimmedMessage(inputText) guard ChatInputValidator.shouldSendMessage(inputText) else { return } + hasSavedBeforeLeaving = false inputText = "" diff --git a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift index 504e550..8d7f03e 100644 --- a/LexAI_iOS/LexAI_iOS/Views/HomeView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/HomeView.swift @@ -12,6 +12,7 @@ struct HomeView: View { @State private var messages: [ChatMessage] = [] @State private var chatViewResetID = UUID() @State private var activeChatDocumentID: String? + @State private var hasSavedBeforeLeaving = false @State private var chatBySessionID: [UUID: ChatPrompt] = [:] @EnvironmentObject var firebaseManager: FirebaseManager @@ -26,6 +27,7 @@ struct HomeView: View { messages: $messages, selectedLanguage: $selectedLanguage, activeChatDocumentID: $activeChatDocumentID, + hasSavedBeforeLeaving: $hasSavedBeforeLeaving, vm: sidebarVM, sessionID: sidebarVM.activeSessionID, onChatPersisted: { transcript in @@ -47,15 +49,19 @@ struct HomeView: View { vm: sidebarVM, onSelectSession: { session in if let chat = chatBySessionID[session.id] { - messages = parseTranscript(chat.prompt) - activeChatDocumentID = chat.id + saveCurrentConversation { + messages = parseTranscript(chat.prompt) + activeChatDocumentID = chat.id + hasSavedBeforeLeaving = false + } } }, onNewChat: { - persistCurrentChatIfNeeded { + saveCurrentConversation { messages = [] activeChatDocumentID = nil sidebarVM.activeSessionID = nil + hasSavedBeforeLeaving = false chatViewResetID = UUID() reloadChatHistory() } @@ -67,10 +73,14 @@ struct HomeView: View { if activeChatDocumentID == chatID { messages = [] activeChatDocumentID = nil + hasSavedBeforeLeaving = false } reloadChatHistory() } } + }, + onSidebarAppear: { + reloadChatHistory() } ) .frame(width: geo.size.width * 0.80) @@ -213,7 +223,7 @@ struct HomeView: View { return parsed } - private func persistCurrentChatIfNeeded(completion: @escaping () -> Void) { + private func saveCurrentConversation(completion: @escaping () -> Void) { guard !messages.isEmpty else { completion() return @@ -229,6 +239,7 @@ struct HomeView: View { if let existingID = activeChatDocumentID, !existingID.isEmpty { firebaseManager.updateChat(chatId: existingID, newPrompt: transcript) { _ in + hasSavedBeforeLeaving = true completion() } return @@ -245,6 +256,7 @@ struct HomeView: View { firebaseManager.saveChat(prompt: prompt) { newID in if let newID { activeChatDocumentID = newID + hasSavedBeforeLeaving = true } completion() } diff --git a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift index d1342bd..fc323a5 100644 --- a/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift +++ b/LexAI_iOS/LexAI_iOS/Views/SideBarView.swift @@ -261,6 +261,7 @@ struct SideBarView: View { var onSelectSession: ((ChatSession) -> Void)? = nil var onNewChat: (() -> Void)? = nil var onDeleteSession: ((ChatSession) -> Void)? = nil + var onSidebarAppear: (() -> Void)? = nil @EnvironmentObject var firebaseManager: FirebaseManager @State private var renamingSession: ChatSession? = nil @@ -434,6 +435,9 @@ struct SideBarView: View { } message: { Text("Are you sure you want to sign out?") } + .onAppear { + onSidebarAppear?() + } } // MARK: Session Row