Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions LexAI_iOS/LexAI_iOS/LexAI_iOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 41 additions & 6 deletions LexAI_iOS/LexAI_iOS/Managers/FirebaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import Foundation
import Combine
import FirebaseCore
import FirebaseAuth
import FirebaseFirestore

Expand All @@ -19,13 +20,19 @@ 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
self?.isAuthenticated = user != nil
self?.isAuthenticated = (user?.isAnonymous == false)
}
}
if Auth.auth().currentUser == nil {
Expand Down Expand Up @@ -163,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,
Expand All @@ -172,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)
}
}

Expand All @@ -184,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 ?? "",
Expand Down Expand Up @@ -269,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
}
}
10 changes: 5 additions & 5 deletions LexAI_iOS/LexAI_iOS/Models/ChatPlaceholderText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
}
}
}
2 changes: 1 addition & 1 deletion LexAI_iOS/LexAI_iOS/Views/AuthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ struct AuthView: View {

#Preview {
AuthView()
.environmentObject(FirebaseManager())
.environmentObject(FirebaseManager(isPreview: true))
}


Expand Down
188 changes: 166 additions & 22 deletions LexAI_iOS/LexAI_iOS/Views/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,28 @@

import SwiftUI
import FirebaseFunctions
import UniformTypeIdentifiers
import PDFKit
import FirebaseAuth

private let bottomAnchorId = "bottom"

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
var onChatPersisted: ((String) -> Void)? = 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 {
Expand Down Expand Up @@ -42,6 +52,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 {
Expand Down Expand Up @@ -82,37 +122,141 @@ 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)")
Comment thread
HKhafajii marked this conversation as resolved.
}
}

private func extractTextFromPDF(url: URL) -> String? {
guard let pdf = PDFDocument(url: url) else { return nil }
var text = ""
for i in 0..<pdf.pageCount {
Comment thread
HKhafajii marked this conversation as resolved.
if let page = pdf.page(at: i), let content = page.string {
text += content + "\n"
}
}
return text.isEmpty ? nil : text
}

private func saveChatIfNeeded() {
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 }

let transcript = messages
.map { ($0.isFromUser ? "User" : "LexAI") + ": " + $0.text }
.joined(separator: "\n")

let chatPrompt = ChatPrompt(
id: activeChatDocumentID,
prompt: transcript,
documents: [],
location: "",
language: selectedLanguage,
user: userId
)

if let existingID = activeChatDocumentID, !existingID.isEmpty {
firebaseManager.updateChat(chatId: existingID, newPrompt: transcript) { success in
print("Save result: \(success)")
if success {
hasSavedBeforeLeaving = true
onChatPersisted?(transcript)
}
}
} else {
firebaseManager.saveChat(prompt: chatPrompt) { newDocumentID in
let success = (newDocumentID != nil)
print("Save result: \(success)")
if let newDocumentID {
activeChatDocumentID = newDocumentID
hasSavedBeforeLeaving = true
onChatPersisted?(transcript)
}
}
}
}

private func sendMessage() {
let text = ChatInputValidator.trimmedMessage(inputText)
guard ChatInputValidator.shouldSendMessage(inputText) else { return }
hasSavedBeforeLeaving = false

inputText = ""

Expand Down
1 change: 1 addition & 0 deletions LexAI_iOS/LexAI_iOS/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct ContentView: View {
Group {
if authManager.isAuthenticated {
HomeView()
.environmentObject(authManager)
} else {
AuthView()
.environmentObject(authManager)
Expand Down
Loading
Loading