From 4510c9c214197324cc9f47151bc540d1d180d809 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Wed, 18 Jun 2025 14:02:18 +0545 Subject: [PATCH 01/16] feat:reminder local notifications --- VITTY/VITTY.xcodeproj/project.pbxproj | 4 ++ .../ReminderNotifcationManager.swift | 62 +++++++++++++++++++ .../VITTY/Academics/View/CreateReminder.swift | 9 +++ VITTY/VITTY/Info.plist | 2 + VITTY/VITTYApp.swift | 1 + 5 files changed, 78 insertions(+) create mode 100644 VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index be5f1b1..764d52f 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE72D7C78B300C9D801 /* Courses.swift */; }; 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; + 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; @@ -175,6 +176,7 @@ 4B183EE72D7C78B300C9D801 /* Courses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Courses.swift; sourceTree = ""; }; 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = ""; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = ""; }; + 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderNotifcationManager.swift; sourceTree = ""; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = ""; }; 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = ""; }; @@ -528,6 +530,7 @@ 4BBB002E2D955104003B8FE2 /* VIewModel */ = { isa = PBXGroup; children = ( + 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */, 4BBB00302D95515C003B8FE2 /* AcademicsViewModel.swift */, ); path = VIewModel; @@ -1114,6 +1117,7 @@ 52D5AB972B6FFC8F00B2E66D /* LoginView.swift in Sources */, 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */, 4BBB00312D955163003B8FE2 /* AcademicsViewModel.swift in Sources */, + 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */, 314A409127383BEC0058082F /* ContentView.swift in Sources */, 520BA6482B4802EE00124850 /* AddFriendCard.swift in Sources */, 4B7DA5E32D70B2C3007354A3 /* Freinds.swift in Sources */, diff --git a/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift b/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift new file mode 100644 index 0000000..5cf4d87 --- /dev/null +++ b/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift @@ -0,0 +1,62 @@ +// +// ReminderNotifcationManager.swift +// VITTY +// +// Created by Rujin Devkota on 6/18/25. +// + +import Foundation +import UserNotifications + +class NotificationManager { + static let shared = NotificationManager() + + private init() {} + + func requestAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + print("Notification authorization error: \(error)") + } else { + print("Notification permission granted: \(granted)") + } + } + } + + func scheduleNotification(title: String, body: String, date: Date, identifier: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let triggerDate = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false) + + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Failed to schedule notification: \(error)") + } + } + } + + func scheduleReminderNotifications(title: String, date: Date,subject: String) { + + scheduleNotification( + title: "\(subject): \(title)", + body: "Your scheduled reminder is now.", + date: date, + identifier: "\(title)-exact" + ) + + + let tenMinutesBefore = Calendar.current.date(byAdding: .minute, value: -10, to: date)! + scheduleNotification( + title: "Upcoming Reminder: \(title)", + body: "Your reminder is in 10 minutes.", + date: tenMinutesBefore, + identifier: "\(title)-early" + ) + } +} diff --git a/VITTY/VITTY/Academics/View/CreateReminder.swift b/VITTY/VITTY/Academics/View/CreateReminder.swift index 1b97944..a42607d 100644 --- a/VITTY/VITTY/Academics/View/CreateReminder.swift +++ b/VITTY/VITTY/Academics/View/CreateReminder.swift @@ -53,12 +53,21 @@ struct ReminderView: View { modelContext.insert(newReminder) try modelContext.save() print("Saved successfully") + + // Schedule local notifications + NotificationManager.shared.scheduleReminderNotifications( + title: title, + date: startTime, + subject: courseName + ) + } catch { print("Failed to save: \(error.localizedDescription)") } presentationMode.wrappedValue.dismiss() } + .disabled(!isFormValid) .foregroundColor(isFormValid ? .red : .gray) } diff --git a/VITTY/VITTY/Info.plist b/VITTY/VITTY/Info.plist index cb3198c..3193b8a 100644 --- a/VITTY/VITTY/Info.plist +++ b/VITTY/VITTY/Info.plist @@ -47,5 +47,7 @@ UIViewControllerBasedStatusBarAppearance + NSUserNotificationUsageDescription + We use notifications to remind you about your academic events diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index 6bf71dc..81cf3fe 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -50,6 +50,7 @@ struct VITTYApp: App { init() { setupFirebase() + NotificationManager.shared.requestAuthorization() } var body: some Scene { From 27b12b0907bfa611d857641e0471907f26e70b58 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Thu, 19 Jun 2025 23:10:04 +0545 Subject: [PATCH 02/16] feat: settings view --- VITTY/VITTY.xcodeproj/project.pbxproj | 16 + VITTY/VITTY/Academics/Model/NotesModel.swift | 53 +- .../VIewModel/AcademicsViewModel.swift | 64 +- VITTY/VITTY/Academics/View/CourseRefs.swift | 72 +- VITTY/VITTY/Academics/View/Courses.swift | 35 +- .../Academics/View/ExistingHotelView.swift | 44 ++ VITTY/VITTY/Academics/View/Notes.swift | 633 +++++++++++------- VITTY/VITTY/Academics/View/NotesHelper.swift | 426 ++++++++++-- VITTY/VITTY/Home/View/HomeView.swift | 97 ++- VITTY/VITTY/Settings/View/SettingsView.swift | 333 +++++++-- .../ViewModel/SettingsViewModel.swift | 114 ++++ VITTY/VITTY/TimeTable/Models/TimeTable.swift | 338 +++++----- .../VITTY/TimeTable/Views/TimeTableView.swift | 32 +- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 55 +- VITTY/VITTYApp.swift | 2 +- 15 files changed, 1628 insertions(+), 686 deletions(-) create mode 100644 VITTY/VITTY/Academics/View/ExistingHotelView.swift create mode 100644 VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 764d52f..7603db9 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */; }; + 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */; }; + 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; @@ -177,6 +179,8 @@ 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = ""; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = ""; }; 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderNotifcationManager.swift; sourceTree = ""; }; + 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingHotelView.swift; sourceTree = ""; }; + 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = ""; }; 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = ""; }; @@ -439,6 +443,14 @@ path = Utilities; sourceTree = ""; }; + 4B37F1E72E04172E00DCEE5F /* ViewModel */ = { + isa = PBXGroup; + children = ( + 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; 4B7DA5DD2D7094CA007354A3 /* Academics */ = { isa = PBXGroup; children = ( @@ -523,6 +535,7 @@ 4B7DA5DE2D7094E3007354A3 /* Academics.swift */, 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */, 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */, + 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */, ); path = View; sourceTree = ""; @@ -893,6 +906,7 @@ 5D1FF2632A32643400B0620A /* Settings */ = { isa = PBXGroup; children = ( + 4B37F1E72E04172E00DCEE5F /* ViewModel */, 5D1FF2642A32643B00B0620A /* View */, ); path = Settings; @@ -1099,6 +1113,7 @@ 5DC0AF552AD2B586006B081D /* UserImage.swift in Sources */, 5238C7F42B4AB07400413946 /* FriendReqCard.swift in Sources */, 4B7DA5DC2D708BD3007354A3 /* LectureItemView.swift in Sources */, + 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */, 4BC853C32DF693780092B2E2 /* SaveTimeTableView.swift in Sources */, 52D5AB892B6FE3B200B2E66D /* AppUser.swift in Sources */, 31128D0C277300470084C9EA /* StringConstants.swift in Sources */, @@ -1119,6 +1134,7 @@ 4BBB00312D955163003B8FE2 /* AcademicsViewModel.swift in Sources */, 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */, 314A409127383BEC0058082F /* ContentView.swift in Sources */, + 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */, 520BA6482B4802EE00124850 /* AddFriendCard.swift in Sources */, 4B7DA5E32D70B2C3007354A3 /* Freinds.swift in Sources */, 4BBB00332D957A6A003B8FE2 /* NotesModel.swift in Sources */, diff --git a/VITTY/VITTY/Academics/Model/NotesModel.swift b/VITTY/VITTY/Academics/Model/NotesModel.swift index 2af20e1..4753e5d 100644 --- a/VITTY/VITTY/Academics/Model/NotesModel.swift +++ b/VITTY/VITTY/Academics/Model/NotesModel.swift @@ -1,23 +1,16 @@ -// -// NotesModel.swift -// VITTY -// -// Created by Rujin Devkota on 3/27/25. -// - import Foundation +import SwiftData +@Model +class CreateNoteModel { + var noteName: String + var userName: String + var courseId: String + var courseName: String + var noteContent: String + var createdAt: Date -struct CreateNoteModel: Codable { - - let noteName: String - let userName: String - let courseId: String - let courseName: String - let noteContent: String - let createdAt: Date - - init( noteName: String, userName: String, courseId: String, courseName: String, noteContent: String, createdAt: Date = Date()) { + init(noteName: String, userName: String, courseId: String, courseName: String, noteContent: String, createdAt: Date = Date()) { self.noteName = noteName self.userName = userName self.courseId = courseId @@ -25,4 +18,30 @@ struct CreateNoteModel: Codable { self.noteContent = noteContent self.createdAt = createdAt } + enum CodingKeys: String, CodingKey { + case noteName, userName, courseId, courseName, noteContent, createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + noteName = try container.decode(String.self, forKey: .noteName) + userName = try container.decode(String.self, forKey: .userName) + courseId = try container.decode(String.self, forKey: .courseId) + courseName = try container.decode(String.self, forKey: .courseName) + noteContent = try container.decode(String.self, forKey: .noteContent) + createdAt = try container.decode(Date.self, forKey: .createdAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(noteName, forKey: .noteName) + try container.encode(userName, forKey: .userName) + try container.encode(courseId, forKey: .courseId) + try container.encode(courseName, forKey: .courseName) + try container.encode(noteContent, forKey: .noteContent) + try container.encode(createdAt, forKey: .createdAt) + } } + + + diff --git a/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift b/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift index 8bd056d..7ee65af 100644 --- a/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift +++ b/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift @@ -22,39 +22,39 @@ import Alamofire category: String(describing: AcademicsViewModel.self) ) - func createNote(at url: URL, authToken: String, note: CreateNoteModel) { - self.loading = true - - let headers: HTTPHeaders = [ - "Authorization": "Bearer \(authToken)", - "Content-Type": "application/json" - ] - - do { - let jsonData = try JSONEncoder().encode(note) - - AF.request(url, method: .post, parameters: nil, encoding: JSONEncoding.default, headers: headers) - .responseData { response in - switch response.result { - case .success: - DispatchQueue.main.async { - self.notes.append(note) - self.loading = false - } - case .failure(let error): - self.logger.error("Error creating note: \(error.localizedDescription)") - self.error = true - self.loading = false - } - } - } catch { - self.logger.error("Error encoding JSON: \(error)") - self.error = true - self.loading = false - } - } - +// func createNote(at url: URL, authToken: String, note: CreateNoteModel) { +// self.loading = true +// +// let headers: HTTPHeaders = [ +// "Authorization": "Bearer \(authToken)", +// "Content-Type": "application/json" +// ] +// +// do { +// let jsonData = try JSONEncoder().encode(note) +// +// AF.request(url, method: .post, parameters: nil, encoding: JSONEncoding.default, headers: headers) +// .responseData { response in +// switch response.result { +// case .success: +// DispatchQueue.main.async { +// self.notes.append(note) +// self.loading = false +// } +// case .failure(let error): +// self.logger.error("Error creating note: \(error.localizedDescription)") +// self.error = true +// self.loading = false +// } +// } +// } catch { +// self.logger.error("Error encoding JSON: \(error)") +// self.error = true +// self.loading = false +// } +// } +    } diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index c3ddf79..d7d50a0 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -10,13 +10,18 @@ struct CourseRefs: View { @State private var showBottomSheet = false @State private var showReminderSheet = false @State private var navigateToNotesEditor = false + + @State private var showSheet = false + + @State private var myExistingNote: CreateNoteModel = CreateNoteModel(noteName: "", userName: "", courseId: "", courseName: "", noteContent: "") @Environment(\.dismiss) private var dismiss - + private let maxVisible = 4 // Fetch remainders with dynamic predicate @Query private var filteredRemainders: [Remainder] + @Query private var courseNotes: [CreateNoteModel] init(courseName: String, courseInstitution: String, slot: String, courseCode: String) { self.courseName = courseName @@ -24,17 +29,19 @@ struct CourseRefs: View { self.slot = slot self.courseCode = courseCode - // Dynamic predicate and descriptor - let predicate = #Predicate { + let reminderPredicate = #Predicate { $0.subject == courseName && $0.isCompleted == false } - - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.date, order: .forward)] + _filteredRemainders = Query( + FetchDescriptor(predicate: reminderPredicate, sortBy: [SortDescriptor(\.date, order: .forward)]) ) - _filteredRemainders = Query(descriptor) + let notesPredicate = #Predicate { + $0.courseId == courseCode + } + _courseNotes = Query( + FetchDescriptor(predicate: notesPredicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + ) } var body: some View { @@ -104,10 +111,36 @@ struct CourseRefs: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 15) { - CourseCardNotes(title: "Sample Title", description: "Data science and software engineering experience is recommended.") - CourseCardNotes(title: "More Information", description: "This certification is intended for you if you have both technical and non-technical backgrounds.") + if courseNotes.isEmpty { + Text("No notes found for this course") + .foregroundColor(.gray) + .padding() + } else { + ForEach(courseNotes, id: \.createdAt) { note in + + NavigationLink( + destination : NoteEditorView(existingNote: note, courseCode: courseCode, courseName: courseName) + ){ + CourseCardNotes( + title: note.noteName, + description: extractPlainTextFromNote(note.noteContent) + ) + } + +// CourseCardNotes( +// title: note.noteName, +// description: extractPlainTextFromNote(note.noteContent) +// ).onTapGesture { +// myExistingNote = note +// showSheet.toggle() +// } +// + + } + } } .padding() + } } @@ -129,7 +162,13 @@ struct CourseRefs: View { .padding(.bottom, 30) } } + }.onAppear{ + print("this is course code") + print(courseCode) } + .sheet(isPresented: $showSheet, content: { + ExistingHotelView(existingNote: myExistingNote) + }) .navigationBarHidden(true) .edgesIgnoringSafeArea(.bottom) .sheet(isPresented: $showBottomSheet) { @@ -159,10 +198,21 @@ struct CourseRefs: View { .presentationDetents([.fraction(0.8)]) } .navigationDestination(isPresented: $navigateToNotesEditor) { - NoteEditorView() + NoteEditorView(courseCode: courseCode, courseName: courseName) } } } + + private func extractPlainTextFromNote(_ noteContent: String) -> String { + + guard let data = Data(base64Encoded: noteContent), + let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + + return noteContent + } + + return attributedString.string + } } diff --git a/VITTY/VITTY/Academics/View/Courses.swift b/VITTY/VITTY/Academics/View/Courses.swift index 71a7421..817059e 100644 --- a/VITTY/VITTY/Academics/View/Courses.swift +++ b/VITTY/VITTY/Academics/View/Courses.swift @@ -60,32 +60,35 @@ struct CoursesView: View { let currentSemester = determineSemester(for: Date()) - + let groupedLectures = Dictionary(grouping: allLectures, by: { $0.name }) var result: [Course] = [] - for (title, lectures) in groupedLectures { - _ = lectures.map { $0.slot }.joined(separator: " + ") - let uniqueSlot = Set(lectures.map { $0.slot }).joined(separator: " + ") - _ = Set(lectures.map { $0.code }).joined(separator: " / ") - - - result.append( - Course( - title: title, - slot: uniqueSlot, - code: uniqueSlot, - semester: currentSemester, - isFavorite: false + + for title in groupedLectures.keys.sorted() { + if let lectures = groupedLectures[title] { + let uniqueSlot = Set(lectures.map { $0.slot }).sorted().joined(separator: " + ") + let uniqueCode = Set(lectures.map { $0.code }).sorted().joined(separator: " / ") + + result.append( + Course( + title: title, + slot: uniqueSlot, + code: uniqueCode, + semester: currentSemester, + isFavorite: false + ) ) - ) + } } - return result + + return result.sorted { $0.title < $1.title } } + private func determineSemester(for date: Date) -> String { let month = Calendar.current.component(.month, from: date) diff --git a/VITTY/VITTY/Academics/View/ExistingHotelView.swift b/VITTY/VITTY/Academics/View/ExistingHotelView.swift new file mode 100644 index 0000000..2682587 --- /dev/null +++ b/VITTY/VITTY/Academics/View/ExistingHotelView.swift @@ -0,0 +1,44 @@ +// +// ExistingHotelView.swift +// VITTY +// +// Created by Rujin Devkota on 6/19/25. +// + +import SwiftUI + +struct ExistingHotelView: View { + var existingNote: CreateNoteModel + + + + var body: some View { + VStack{ + Text("Note: \(existingNote)") + Text("Note name: \(existingNote.noteName)") + Text("user name: \(existingNote.userName)") + Text("course id: \(existingNote.courseId)") + Text("course name: \(existingNote.courseName)") + Text("note content: \(existingNote.noteContent)") + + Text("-----------------") + + if let attriString = try? AttributedString(markdown: existingNote.noteContent){ + Text("attr content: \(attriString)") + }else{ + Text("cant do baby doll") + } + + + + } + .onAppear { + print("existing hotel: \(existingNote)") + + } + } +} + +#Preview { + ExistingHotelView(existingNote: CreateNoteModel(noteName: "", userName: "", courseId: "", courseName: "", noteContent: "")) +} diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index 829f904..7564de1 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -6,7 +6,7 @@ struct RichTextView: UIViewRepresentable { @Binding var selectedRange: NSRange @Binding var typingAttributes: [NSAttributedString.Key: Any] @Binding var isEmpty: Bool - + func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.isEditable = true @@ -16,13 +16,46 @@ struct RichTextView: UIViewRepresentable { textView.typingAttributes = typingAttributes textView.backgroundColor = .clear textView.textColor = .white + + // Set initial content + textView.attributedText = attributedText + textView.selectedRange = selectedRange + return textView } func updateUIView(_ uiView: UITextView, context: Context) { - uiView.attributedText = attributedText - uiView.selectedRange = selectedRange - uiView.typingAttributes = typingAttributes + // Prevent infinite loops by checking if coordinator is updating + if context.coordinator.isUpdating { + return + } + + // Only update if the content is actually different + if !uiView.attributedText.isEqual(to: attributedText) { + let previousSelectedRange = uiView.selectedRange + context.coordinator.isUpdating = true + uiView.attributedText = attributedText + + // Restore selection if valid + if previousSelectedRange.location <= uiView.attributedText.length { + uiView.selectedRange = previousSelectedRange + } + context.coordinator.isUpdating = false + } + + // Only update selection if it's different and valid + if !NSEqualRanges(uiView.selectedRange, selectedRange) && + selectedRange.location <= uiView.attributedText.length && + NSMaxRange(selectedRange) <= uiView.attributedText.length { + context.coordinator.isUpdating = true + uiView.selectedRange = selectedRange + context.coordinator.isUpdating = false + } + + // Update typing attributes if they're different + if !NSDictionary(dictionary: uiView.typingAttributes).isEqual(to: typingAttributes) { + uiView.typingAttributes = typingAttributes + } } func makeCoordinator() -> Coordinator { @@ -31,65 +64,168 @@ struct RichTextView: UIViewRepresentable { class Coordinator: NSObject, UITextViewDelegate { var parent: RichTextView + var isUpdating = false init(_ parent: RichTextView) { self.parent = parent } func textViewDidChange(_ textView: UITextView) { + // Prevent recursive updates + guard !isUpdating else { return } + + isUpdating = true + defer { isUpdating = false } + + // Update parent state parent.attributedText = NSMutableAttributedString(attributedString: textView.attributedText) - // Update isEmpty state based on text content parent.isEmpty = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } func textViewDidChangeSelection(_ textView: UITextView) { + // Prevent recursive updates + guard !isUpdating else { return } + + isUpdating = true + defer { isUpdating = false } + parent.selectedRange = textView.selectedRange } } } +// Optimized NoteEditorView with better state management struct NoteEditorView: View { @Environment(\.dismiss) private var dismiss @Environment(AcademicsViewModel.self) private var academicsViewModel @Environment(AuthViewModel.self) private var authViewModel - @State private var attributedText = NSMutableAttributedString() // Start with empty text + @State private var attributedText = NSMutableAttributedString() @State private var selectedRange = NSRange(location: 0, length: 0) @State private var typingAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.white ] + + let existingNote: CreateNoteModel? @State private var selectedFont: UIFont = UIFont.systemFont(ofSize: 18) @State private var selectedColor: Color = .white @State private var showFontPicker = false @State private var showFontSizePicker = false - @State private var isEmpty = true // Track if the text view is empty + @State private var isEmpty = true + @State private var hasUnsavedChanges = false + @State private var isInitialized = false + + @Environment(\.modelContext) private var modelContext + let courseCode: String + let courseName: String + + init(existingNote: CreateNoteModel? = nil, courseCode: String, courseName: String) { + self.existingNote = existingNote + self.courseCode = existingNote?.courseId ?? courseCode + self.courseName = existingNote?.courseName ?? courseName + } + + private func initializeContent() { + guard !isInitialized else { return } + + if let note = existingNote { + Task { @MainActor in + await loadNoteContent(note) + isInitialized = true + } + } else { + // New note - initialize with empty content + attributedText = NSMutableAttributedString() + isEmpty = true + isInitialized = true + } + } + + @MainActor + private func loadNoteContent(_ note: CreateNoteModel) async { + do { + // Parse the base64 encoded attributed string + guard let data = Data(base64Encoded: note.noteContent) else { + print("Failed to decode base64 data") + // Fallback to plain text + attributedText = NSMutableAttributedString(string: note.noteContent) + isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return + } + + // Try to unarchive the attributed string + if let loadedAttributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { + attributedText = NSMutableAttributedString(attributedString: loadedAttributedString) + isEmpty = loadedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } else { + print("Failed to unarchive attributed string") + // Fallback to plain text + attributedText = NSMutableAttributedString(string: note.noteContent) + isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } catch { + print("Error loading note content: \(error)") + // Fallback to plain text + attributedText = NSMutableAttributedString(string: note.noteContent) + isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } func saveContent() { - let markdown = attributedText.toMarkdown() - let note = CreateNoteModel( - noteName:"", - userName: "", - courseId:"", - courseName: "", - noteContent: markdown, - createdAt: Date.now - ) - - let uRL = URL(string: "\(APIConstants.base_url)notes/save")! + + guard hasUnsavedChanges || existingNote == nil else { + dismiss() + return + } - academicsViewModel.createNote(at: uRL , - authToken:authViewModel.loggedInBackendUser?.token ?? "", note: note) + + do { + let data = try NSKeyedArchiver.archivedData(withRootObject: attributedText, requiringSecureCoding: false) + let dataString = data.base64EncodedString() + let title = generateSmartTitle(from: attributedText.string) + + if let note = existingNote { + note.noteName = title + note.noteContent = dataString + note.createdAt = Date.now + } else { + let newNote = CreateNoteModel( + noteName: title, + userName: authViewModel.loggedInBackendUser?.name ?? "", + courseId: courseCode, + courseName: courseName, + noteContent: dataString, + createdAt: Date.now + ) + modelContext.insert(newNote) + } + + try modelContext.save() + print("Note saved/updated in SwiftData.") + hasUnsavedChanges = false + dismiss() + } catch { + print("Error saving note: \(error)") + } } + func generateSmartTitle(from plainText: String) -> String { + let lines = plainText.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + if let firstLine = lines.first { + return String(firstLine.prefix(40)).trimmingCharacters(in: .whitespaces) + } + return "Untitled Note" + } + private let fonts: [UIFont] = [ UIFont.systemFont(ofSize: 18), - UIFont(name: "Times New Roman", size: 18)!, - UIFont(name: "Helvetica", size: 18)!, - UIFont(name: "Courier", size: 18)! + UIFont(name: "Times New Roman", size: 18) ?? UIFont.systemFont(ofSize: 18), + UIFont(name: "Helvetica", size: 18) ?? UIFont.systemFont(ofSize: 18), + UIFont(name: "Courier", size: 18) ?? UIFont.systemFont(ofSize: 18) ] - // Font sizes for the aA picker private let fontSizes: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 42, 48] var body: some View { @@ -97,212 +233,236 @@ struct NoteEditorView: View { Color("Background") .edgesIgnoringSafeArea(.all) - VStack { - HStack { - Button(action: { - dismiss() - }) { - Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) - } - Spacer() - Text("Note") - .foregroundColor(.white) - .font(.system(size: 25,weight: Font.Weight.bold)) - Spacer() - Button(action:{ - saveContent() - }){ - Image("save").resizable().frame(width: 30,height: 30) - } + if isInitialized { + VStack { + // Header + headerView + + // Text Editor + textEditorView + + // Toolbar + toolbarView } - .padding() + } else { + // Loading state + ProgressView("Loading...") + .foregroundColor(.white) + } - ZStack(alignment: .topLeading) { - RichTextView( - attributedText: $attributedText, - selectedRange: $selectedRange, - typingAttributes: $typingAttributes, - isEmpty: $isEmpty - ) - .padding() - .frame(maxHeight: .infinity) - - // Placeholder overlay - if isEmpty { - Text("Start typing here...") - .foregroundColor(.gray.opacity(0.6)) - .font(.system(size: 18)) - .padding(.horizontal, 20) - .padding(.vertical, 24) - .allowsHitTesting(false) // Allow taps to pass through to the text view - } + // Overlays + if showFontPicker { + fontPickerOverlay + } + + if showFontSizePicker { + fontSizePickerOverlay + } + } + .onAppear { + initializeContent() + } + .onChange(of: attributedText) { _, _ in + if isInitialized { + hasUnsavedChanges = true + } + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .animation(.easeInOut(duration: 0.3), value: showFontPicker) + .animation(.easeInOut(duration: 0.3), value: showFontSizePicker) + } + + // MARK: - View Components + + private var headerView: some View { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + } + Spacer() + Text("Note") + .foregroundColor(.white) + .font(.system(size: 25, weight: .bold)) + Spacer() + Button(action: { saveContent() }) { + Image("save") + .resizable() + .frame(width: 30, height: 30) + } + } + .padding() + } + + private var textEditorView: some View { + ZStack(alignment: .topLeading) { + RichTextView( + attributedText: $attributedText, + selectedRange: $selectedRange, + typingAttributes: $typingAttributes, + isEmpty: $isEmpty + ) + .padding() + .frame(maxHeight: .infinity) + + if isEmpty { + Text("Start typing here...") + .foregroundColor(.gray.opacity(0.6)) + .font(.system(size: 18)) + .padding(.horizontal, 20) + .padding(.vertical, 24) + .allowsHitTesting(false) + } + } + } + + private var toolbarView: some View { + HStack(spacing: 20) { + // Font picker button + Button(action: { + showFontPicker.toggle() + showFontSizePicker = false + }) { + Image(systemName: "textformat") + .foregroundColor(Color("Accent")) + } + + // Font size button + Button(action: { + showFontSizePicker.toggle() + showFontPicker = false + }) { + HStack(spacing: 2) { + Text("a") + .font(.system(size: 12)) + .foregroundColor(Color("Accent")) + Text("A") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(Color("Accent")) } + } + + // Formatting buttons + formatButton(action: toggleBold, icon: "bold", isActive: isBoldActive()) + formatButton(action: toggleItalic, icon: "italic", isActive: isItalicActive()) + formatButton(action: toggleUnderline, icon: "underline", isActive: isUnderlineActive()) - HStack(spacing: 20) { - // Font family picker - Button(action: { - showFontPicker.toggle() - showFontSizePicker = false - }) { - Image(systemName: "textformat") - .foregroundColor(Color("Accent")) - } - - // Font size picker (aA icon) + // Color picker + ColorPicker("", selection: $selectedColor, supportsOpacity: false) + .labelsHidden() + .frame(width: 30, height: 30) + .onChange(of: selectedColor) { _, newColor in + applyAttribute(.foregroundColor, value: UIColor(newColor)) + } + + // Bullet points button + Button(action: addBulletPoints) { + Image(systemName: "list.bullet") + .foregroundColor(Color("Accent")) + } + } + .padding() + .background(Color("Background").opacity(0.8)) + } + + private func formatButton(action: @escaping () -> Void, icon: String, isActive: Bool) -> some View { + Button(action: action) { + Image(systemName: icon) + .foregroundColor(Color("Accent")) + .padding(8) + .background(isActive ? Color("Accent").opacity(0.2) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isActive ? Color("Accent") : Color.clear, lineWidth: 1) + ) + } + } + + // MARK: - Overlay Views + + private var fontPickerOverlay: some View { + VStack { + Spacer() + VStack(spacing: 0) { + ForEach(fonts, id: \.fontName) { font in Button(action: { - showFontSizePicker.toggle() + selectedFont = font + applyFontFamily(font) showFontPicker = false }) { - HStack(spacing: 2) { - Text("a") - .font(.system(size: 12)) - .foregroundColor(Color("Accent")) - Text("A") - .font(.system(size: 18, weight: .bold)) - .foregroundColor(Color("Accent")) - } - } - - Button(action: { toggleBold() }) { - Image(systemName: "bold") - .foregroundColor(Color("Accent")) - .padding(8) - .background(isBoldActive() ? Color("Accent").opacity(0.2) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isBoldActive() ? Color("Accent") : Color.clear, lineWidth: 1) - ) - } - - Button(action: { toggleItalic() }) { - Image(systemName: "italic") - .foregroundColor(Color("Accent")) - .padding(8) - .background(isItalicActive() ? Color("Accent").opacity(0.2) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isItalicActive() ? Color("Accent") : Color.clear, lineWidth: 1) - ) - } - - Button(action: { toggleUnderline() }) { - Image(systemName: "underline") - .foregroundColor(Color("Accent")) - .padding(8) - .background(isUnderlineActive() ? Color("Accent").opacity(0.2) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isUnderlineActive() ? Color("Accent") : Color.clear, lineWidth: 1) - ) + Text(font.fontName.replacingOccurrences(of: "-", with: " ")) + .foregroundColor(.white) + .font(Font(font as CTFont)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } - - ColorPicker("", selection: $selectedColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 30, height: 30) - .onChange(of: selectedColor) { newColor in - applyAttribute(.foregroundColor, value: UIColor(newColor)) - } - - Button(action: { addBulletPoints() }) { - Image(systemName: "list.bullet") - .foregroundColor(Color("Accent")) - } - } - .padding() - .background(Color("Background").opacity(0.8)) - } - - // Font family picker overlay - if showFontPicker { - VStack { - Spacer() - VStack(spacing: 0) { - ForEach(fonts, id: \.fontName) { font in - Button(action: { - selectedFont = font - applyFontFamily(font) - showFontPicker = false - }) { - Text(font.fontName.replacingOccurrences(of: "-", with: " ")) - .foregroundColor(.white) - .font(Font(font as CTFont)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - if font != fonts.last { - Divider().background(Color.gray.opacity(0.3)) - } - } + if font != fonts.last { + Divider().background(Color.gray.opacity(0.3)) } - .background(Color("Background")) - .cornerRadius(10) - .shadow(color: .black.opacity(0.3), radius: 10) - .padding(.horizontal, 40) - .padding(.bottom, 100) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - .background(Color.black.opacity(0.3)) - .onTapGesture { - showFontPicker = false } } - - // Font size picker overlay - if showFontSizePicker { - VStack { - Spacer() - ScrollView { - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 10) { - ForEach(fontSizes, id: \.self) { size in - Button(action: { - applyFontSize(size) - showFontSizePicker = false - }) { - Text("\(Int(size))") - .foregroundColor(.white) - .font(.system(size: min(size, 24))) - .frame(width: 50, height: 40) - .background(Color("Accent").opacity(0.2)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color("Accent"), lineWidth: 1) - ) - } - } + .background(Color("Background")) + .cornerRadius(10) + .shadow(color: .black.opacity(0.3), radius: 10) + .padding(.horizontal, 40) + .padding(.bottom, 100) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + .background(Color.black.opacity(0.3)) + .onTapGesture { + showFontPicker = false + } + } + + private var fontSizePickerOverlay: some View { + VStack { + Spacer() + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 10) { + ForEach(fontSizes, id: \.self) { size in + Button(action: { + applyFontSize(size) + showFontSizePicker = false + }) { + Text("\(Int(size))") + .foregroundColor(.white) + .font(.system(size: min(size, 24))) + .frame(width: 50, height: 40) + .background(Color("Accent").opacity(0.2)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color("Accent"), lineWidth: 1) + ) } - .padding() } - .frame(maxHeight: 300) - .background(Color("Background")) - .cornerRadius(10) - .shadow(color: .black.opacity(0.3), radius: 10) - .padding(.horizontal, 40) - .padding(.bottom, 100) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - .background(Color.black.opacity(0.3)) - .onTapGesture { - showFontSizePicker = false } + .padding() } + .frame(maxHeight: 300) + .background(Color("Background")) + .cornerRadius(10) + .shadow(color: .black.opacity(0.3), radius: 10) + .padding(.horizontal, 40) + .padding(.bottom, 100) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + .background(Color.black.opacity(0.3)) + .onTapGesture { + showFontSizePicker = false } - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .animation(.easeInOut(duration: 0.3), value: showFontPicker) - .animation(.easeInOut(duration: 0.3), value: showFontSizePicker) } + // MARK: - Text Formatting Functions + func addBulletPoints() { guard selectedRange.length > 0 else { return } let selectedText = attributedText.attributedSubstring(from: selectedRange).string let lines = selectedText.components(separatedBy: "\n") - var bulletedText = lines.map { "• \($0)" }.joined(separator: "\n") + let bulletedText = lines.map { "• \($0)" }.joined(separator: "\n") let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) mutableAttributedString.replaceCharacters(in: selectedRange, with: bulletedText) @@ -312,58 +472,50 @@ struct NoteEditorView: View { } func isBoldActive() -> Bool { - if selectedRange.length > 0 { - if let font = attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont { - return font.fontDescriptor.symbolicTraits.contains(.traitBold) - } - } else { - if let font = typingAttributes[.font] as? UIFont { - return font.fontDescriptor.symbolicTraits.contains(.traitBold) - } - } - return false + let font = getCurrentFont() + return font.fontDescriptor.symbolicTraits.contains(.traitBold) } func isItalicActive() -> Bool { - if selectedRange.length > 0 { - if let font = attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont { - return font.fontDescriptor.symbolicTraits.contains(.traitItalic) - } - } else { - if let font = typingAttributes[.font] as? UIFont { - return font.fontDescriptor.symbolicTraits.contains(.traitItalic) - } - } - return false + let font = getCurrentFont() + return font.fontDescriptor.symbolicTraits.contains(.traitItalic) } func isUnderlineActive() -> Bool { - if selectedRange.length > 0 { - if let underline = attributedText.attribute(.underlineStyle, at: selectedRange.location, effectiveRange: nil) as? Int { - return underline == NSUnderlineStyle.single.rawValue - } + let underline = getCurrentUnderlineStyle() + return underline == NSUnderlineStyle.single.rawValue + } + + private func getCurrentFont() -> UIFont { + if selectedRange.length > 0 && selectedRange.location < attributedText.length { + return attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont ?? UIFont.systemFont(ofSize: 18) } else { - if let underline = typingAttributes[.underlineStyle] as? Int { - return underline == NSUnderlineStyle.single.rawValue - } + return typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + } + } + + private func getCurrentUnderlineStyle() -> Int { + if selectedRange.length > 0 && selectedRange.location < attributedText.length { + return attributedText.attribute(.underlineStyle, at: selectedRange.location, effectiveRange: nil) as? Int ?? 0 + } else { + return typingAttributes[.underlineStyle] as? Int ?? 0 } - return false } func applyFontFamily(_ font: UIFont) { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) + let currentFont = getCurrentFont() let newFont = UIFont(name: font.fontName, size: currentFont.pointSize) ?? font applyAttribute(.font, value: newFont) } func applyFontSize(_ size: CGFloat) { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) + let currentFont = getCurrentFont() let newFont = UIFont(descriptor: currentFont.fontDescriptor, size: size) applyAttribute(.font, value: newFont) } func toggleBold() { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) + let currentFont = getCurrentFont() var traits = currentFont.fontDescriptor.symbolicTraits if traits.contains(.traitBold) { traits.remove(.traitBold) @@ -377,7 +529,7 @@ struct NoteEditorView: View { } func toggleItalic() { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) + let currentFont = getCurrentFont() var traits = currentFont.fontDescriptor.symbolicTraits if traits.contains(.traitItalic) { traits.remove(.traitItalic) @@ -391,7 +543,7 @@ struct NoteEditorView: View { } func toggleUnderline() { - let currentUnderline = (selectedRange.length > 0 ? attributedText.attribute(.underlineStyle, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.underlineStyle]) as? Int ?? 0 + let currentUnderline = getCurrentUnderlineStyle() let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue applyAttribute(.underlineStyle, value: newUnderline) } @@ -404,5 +556,6 @@ struct NoteEditorView: View { } else { typingAttributes[key] = value } + hasUnsavedChanges = true } } diff --git a/VITTY/VITTY/Academics/View/NotesHelper.swift b/VITTY/VITTY/Academics/View/NotesHelper.swift index 55938e9..546d46c 100644 --- a/VITTY/VITTY/Academics/View/NotesHelper.swift +++ b/VITTY/VITTY/Academics/View/NotesHelper.swift @@ -1,61 +1,395 @@ -// -// NotesHelper.swift -// VITTY -// -// Created by Rujin Devkota on 3/5/25. - -// - -import Down - import Foundation import UIKit extension NSAttributedString { - func toMarkdown() -> String { - let mutableString = NSMutableString() - self.enumerateAttributes(in: NSRange(location: 0, length: self.length), options: []) { (attributes, range, _) in - let substring = self.attributedSubstring(from: range).string - var markdownString = substring +// func toMarkdown() -> String { +// let mutableString = NSMutableString() +// let fullRange = NSRange(location: 0, length: self.length) +// +// self.enumerateAttributes(in: fullRange, options: []) { (attributes, range, _) in +// let substring = self.attributedSubstring(from: range).string +// +// // Check for font attributes +// if let font = attributes[.font] as? UIFont { +// let traits = font.fontDescriptor.symbolicTraits +// +// if traits.contains(.traitBold) && traits.contains(.traitItalic) { +// mutableString.append("***\(substring)***") +// } else if traits.contains(.traitBold) { +// mutableString.append("**\(substring)**") +// } else if traits.contains(.traitItalic) { +// mutableString.append("*\(substring)*") +// } else { +// mutableString.append(substring) +// } +// } +// +// // Check for underline +// if let underline = attributes[.underlineStyle] as? Int, underline == NSUnderlineStyle.single.rawValue { +// mutableString.insert("", at: mutableString.length - substring.count) +// mutableString.append("") +// } +// +// // Check for color +// if let color = attributes[.foregroundColor] as? UIColor, color != UIColor.white { +// let hex = color.hexString +// mutableString.insert("", at: mutableString.length - substring.count) +// mutableString.append("") +// } +// +// // Handle bullet points (simple implementation) +// if substring.hasPrefix("• ") { +// mutableString.append("\n- \(substring.dropFirst(2))") +// } +// } +// +// return mutableString as String +// } +} - - if let font = attributes[.font] as? UIFont, font.fontDescriptor.symbolicTraits.contains(.traitBold) { - markdownString = "**\(markdownString)**" - } +//// MARK: - Markdown Parser Extension +//extension NSAttributedString { +// +// /// Converts NSAttributedString to Markdown format +// /// Handles bold, italic, underline, font sizes, colors, and bullet points +// func toMarkdown() -> String { +// var md = "" +// let fullText = string as NSString +// let lines = fullText.components(separatedBy: "\n") +// var location = 0 +// +// for (i, line) in lines.enumerated() { +// let length = (line as NSString).length +// +// // 1) Empty line? just emit newline +// if length == 0 { +// md += "\n" +// location += 1 // account for the stripped '\n' +// continue +// } +// +// let lineRange = NSRange(location: location, length: length) +// +// // 2) Heading detection based on font size at start of line +// if let font = attribute(.font, at: location, effectiveRange: nil) as? UIFont { +// switch font.pointSize { +// case let s where s > 24: md += "# "; +// case let s where s > 20: md += "## "; +// case let s where s > 18: md += "### "; +// default: break +// } +// } +// +// // 3) Enumerate each run within that line +// enumerateAttributes(in: lineRange, options: []) { attrs, runRange, _ in +// var substr = attributedSubstring(from: runRange).string +// +// // Escape literal Markdown markers so we don't mangle user-typed '*' etc. +// substr = substr +// .replacingOccurrences(of: "\\", with: "\\\\") +// .replacingOccurrences(of: "*", with: "\\*") +// .replacingOccurrences(of: "_", with: "\\_") +// +// // Build wrappers +// var prefix = "", suffix = "" +// +// // Bold / Italic +// if let font = attrs[.font] as? UIFont { +// let traits = font.fontDescriptor.symbolicTraits +// if traits.contains(.traitBold) { prefix += "**"; suffix = "**" + suffix } +// if traits.contains(.traitItalic) { prefix += "*"; suffix = "*" + suffix } +// } +// +// // Underline +// if let u = attrs[.underlineStyle] as? Int, +// u == NSUnderlineStyle.single.rawValue { +// prefix = "" + prefix +// suffix += "" +// } +// +// // Color +// if let color = attrs[.foregroundColor] as? UIColor, +// !isDefaultTextColor(color) { +// let hex = colorToHex(color) +// substr = "\(substr)" +// } +// +// md += prefix + substr + suffix +// } +// +// // 4) Re-append the newline (except after the last line) +// if i < lines.count - 1 { +// md += "\n" +// } +// location += length + 1 +// } +// +// return md +// } +// +// // MARK: - Helper Methods +// +// private func colorToHex(_ color: UIColor) -> String { +// var red: CGFloat = 0 +// var green: CGFloat = 0 +// var blue: CGFloat = 0 +// var alpha: CGFloat = 0 +// +// color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) +// +// let rgb = Int(red * 255) << 16 | Int(green * 255) << 8 | Int(blue * 255) +// return String(format: "#%06x", rgb) +// } +// +// private func isDefaultTextColor(_ color: UIColor) -> Bool { +// // Check if color is white or default text color +// var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 +// color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) +// return red > 0.9 && green > 0.9 && blue > 0.9 // Close to white +// } +// +// private func isNextCharacterBold(at index: Int) -> Bool { +// guard index < self.length else { return false } +// if let font = self.attribute(.font, at: index, effectiveRange: nil) as? UIFont { +// return font.fontDescriptor.symbolicTraits.contains(.traitBold) +// } +// return false +// } +// +// private func isNextCharacterItalic(at index: Int) -> Bool { +// guard index < self.length else { return false } +// if let font = self.attribute(.font, at: index, effectiveRange: nil) as? UIFont { +// return font.fontDescriptor.symbolicTraits.contains(.traitItalic) +// } +// return false +// } +// +// private func isNextCharacterUnderlined(at index: Int) -> Bool { +// guard index < self.length else { return false } +// if let underline = self.attribute(.underlineStyle, at: index, effectiveRange: nil) as? Int { +// return underline == NSUnderlineStyle.single.rawValue +// } +// return false +// } +//} - if let font = attributes[.font] as? UIFont, font.fontDescriptor.symbolicTraits.contains(.traitItalic) { - markdownString = "*\(markdownString)*" +// MARK: - Markdown to NSAttributedString Parser +extension String { + + /// Converts Markdown string to NSAttributedString + /// Handles bold, italic, underline, headings, colors, and bullet points + func fromMarkdown() -> NSMutableAttributedString { + let result = NSMutableAttributedString() + let lines = self.components(separatedBy: .newlines) + + for (index, line) in lines.enumerated() { + if index > 0 { + result.append(NSAttributedString(string: "\n")) } - + + if line.trimmingCharacters(in: .whitespaces).isEmpty { + continue + } + + let processedLine = processMarkdownLine(line) + result.append(processedLine) + } + + return result + } + + private func processMarkdownLine(_ line: String) -> NSAttributedString { + var workingLine = line + let result = NSMutableAttributedString() + + // Default attributes + var attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 18), + .foregroundColor: UIColor.white + ] - if let underline = attributes[.underlineStyle] as? Int, underline == NSUnderlineStyle.single.rawValue { - markdownString = "__\(markdownString)__" + // Handle headings + if workingLine.hasPrefix("### ") { + workingLine = String(workingLine.dropFirst(4)) + attributes[.font] = UIFont.boldSystemFont(ofSize: 20) + } else if workingLine.hasPrefix("## ") { + workingLine = String(workingLine.dropFirst(3)) + attributes[.font] = UIFont.boldSystemFont(ofSize: 24) + } else if workingLine.hasPrefix("# ") { + workingLine = String(workingLine.dropFirst(2)) + attributes[.font] = UIFont.boldSystemFont(ofSize: 28) + } + + // Handle bullet points + if workingLine.trimmingCharacters(in: .whitespaces).hasPrefix("- ") { + workingLine = workingLine.replacingOccurrences(of: "- ", with: "• ", options: [], range: workingLine.range(of: "- ")) + } + + // Process inline formatting + let processedString = processInlineFormatting(workingLine, baseAttributes: attributes) + result.append(processedString) + + return result + } + + private func processInlineFormatting(_ text: String, baseAttributes: [NSAttributedString.Key: Any]) -> NSAttributedString { + let result = NSMutableAttributedString() + var currentIndex = text.startIndex + var currentAttributes = baseAttributes + + while currentIndex < text.endIndex { + // Handle HTML color spans + if let colorRange = findColorSpan(in: text, from: currentIndex) { + // Add text before color span + if currentIndex < colorRange.range.lowerBound { + let beforeText = String(text[currentIndex..") { + if let endIndex = text.range(of: "", range: currentIndex.. NSAttributedString? { - let down = Down(markdownString: self) - return try? down.toAttributedString() + + private func findColorSpan(in text: String, from startIndex: String.Index) -> (range: Range, text: String, color: UIColor)? { + let searchText = String(text[startIndex...]) + let pattern = #"([^<]*)"# + + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let nsRange = NSRange(searchText.startIndex.. (NSAttributedString, String.Index) { + var currentIndex = startIndex + var currentAttributes = attributes + let result = NSMutableAttributedString() + + // Find next formatting marker + let remainingText = String(text[currentIndex...]) + let boldPattern = #"\*\*([^*]+)\*\*"# + let italicPattern = #"\*([^*]+)\*"# + + // Check for bold first (longer pattern) + if let boldRegex = try? NSRegularExpression(pattern: boldPattern), + let boldMatch = boldRegex.firstMatch(in: remainingText, range: NSRange(remainingText.startIndex.. remainingText.startIndex { + let beforeText = String(remainingText[remainingText.startIndex.. remainingText.startIndex { + let beforeText = String(remainingText[remainingText.startIndex.. UIColor? { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + return nil + } + + return UIColor( + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + alpha: Double(a) / 255 + ) } } diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index c3efa81..84494c6 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -1,21 +1,18 @@ import SwiftUI -import SwiftUI - struct HomeView: View { @Environment(AuthViewModel.self) private var authViewModel @State private var selectedPage = 1 @State private var showProfileSidebar: Bool = false - @State private var isCreatingGroup = false - - + var body: some View { NavigationStack { ZStack { BackgroundView() - + VStack(spacing: 0) { + // Top Bar HStack { Text( selectedPage == 3 ? "Academics" : @@ -23,33 +20,35 @@ struct HomeView: View { "Schedule" ) .font(Font.custom("Poppins-Bold", size: 26)) - + Spacer() - + if selectedPage != 2 { - Button { - withAnimation { - showProfileSidebar = true + ZStack { + if !showProfileSidebar { + Button { + withAnimation(.easeInOut(duration: 0.8 + + )) { + showProfileSidebar = true + } + } label: { + UserImage( + url: authViewModel.loggedInBackendUser?.picture ?? "", + height: 30, + width: 40 + ) + .transition(.scale.combined(with: .opacity)) + } } - } label: { - UserImage( - url: authViewModel.loggedInBackendUser?.picture ?? "", - height: 30, - width: 40 - ) } - }else{ - - - - - } } .padding(.horizontal) .padding(.top, 20) .padding(.bottom, 8) - + + // Main Content ZStack { switch selectedPage { case 1: @@ -59,49 +58,41 @@ struct HomeView: View { case 3: Academics() default: - Text("Error Lol") + Text("Error") } } .padding(.top, 4) - + Spacer() - + + // Bottom Navigation Bar BottomBarView(presentTab: $selectedPage) .padding(.bottom, 24) } - - - // In your HomeView + + if showProfileSidebar { - ZStack { - // Full screen overlay to darken the background - Color.black.opacity(0.3) - .edgesIgnoringSafeArea(.all) - .onTapGesture { - withAnimation { - showProfileSidebar = false - } - } - - - GeometryReader { geometry in - HStack(spacing: 0) { - Spacer() - - UserProfileSidebar(isPresented: $showProfileSidebar) - .frame(width: geometry.size.width * 0.75) - .transition(.move(edge: .trailing)) - .background(Color.clear) - .edgesIgnoringSafeArea(.all) + + Color.black.opacity(0.3) + .ignoresSafeArea() + .transition(.opacity) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.8)) { + showProfileSidebar = false } } + + // Sidebar + HStack { + Spacer() + UserProfileSidebar(isPresented: $showProfileSidebar) + .frame(width: UIScreen.main.bounds.width * 0.75) + .transition(.move(edge: .trailing)) } - .edgesIgnoringSafeArea(.all) } - } .ignoresSafeArea(edges: .bottom) - } } } + diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 335cc18..44cce07 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -1,52 +1,289 @@ -// -// SettingsView.swift -// VITTY -// -// Created by Ananya George on 12/24/21. -// - import SwiftUI +import SwiftData struct SettingsView: View { - let githubURL = URL(string: "https://github.com/GDGVIT/vitty-ios") - let gdscURL = URL(string: "https://dscvit.com/") - var body: some View { - ZStack { - BackgroundView() - List { - Section(header: Text("About")) { - HStack { - Image("github-icon") - .resizable() - .scaledToFit() - .frame(width: 35, height: 35) - Text("GitHub Repository") - } - .frame(height: 35) - .listRowBackground(Color("Secondary")) - .onTapGesture { - if let url = githubURL { - UIApplication.shared.open(url) - } - } - HStack { - Image("gdsc-logo") - .resizable() - .scaledToFit() - .frame(width: 30, height: 30) - Text("GDSC VIT") - } - .frame(height: 35) - .listRowBackground(Color("Secondary")) - .onTapGesture { - if let url = gdscURL { - UIApplication.shared.open(url) - } - } - } - } - .scrollContentBackground(.hidden) - } - .navigationTitle("Settings") - } + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query private var timeTables: [TimeTable] + + @StateObject private var viewModel = SettingsViewModel() + + + @State private var showDaySelection = false + @State private var selectedDay: String? = nil + + + private let selectedDayKey = "SelectedSaturdayDay" + + var body: some View { + NavigationStack { + ZStack { + BackgroundView() + + VStack { + headerView + + List { + SettingsSectionView(title: "Account Details") { + HStack(spacing: 12) { + UserImage( + url: authViewModel.loggedInBackendUser?.picture ?? "", + height: 60, + width: 60 + ) + VStack(alignment: .leading, spacing: 4) { + Text(authViewModel.loggedInFirebaseUser?.displayName ?? "") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.white) + + Text(authViewModel.loggedInFirebaseUser?.email ?? "") + .font(.system(size: 13)) + .foregroundColor(.gray.opacity(0.8)) + } + } + } + + SettingsSectionView(title: "Class Settings") { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.5)) { + showDaySelection.toggle() + } + } label: { + SettingsRowView( + icon: "calendar.badge.plus", + title: "Saturday Class", + subtitle: selectedDay == nil ? "Select a day to copy classes to Saturday" : "Copy \(selectedDay!) classes to Saturday" + ) + } + + if showDaySelection { + VStack(alignment: .leading, spacing: 8) { + ForEach(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], id: \.self) { day in + HStack(spacing: 12) { + Image(systemName: selectedDay == day ? "largecircle.fill.circle" : "circle") + .foregroundColor(.blue) + .font(.system(size: 16)) + Text(day) + .foregroundColor(.white) + .font(.system(size: 14)) + Spacer() + } + .padding(.leading, 16) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + selectedDay = day + UserDefaults.standard.set(day, forKey: selectedDayKey) + copyLecturesToSaturday(from: day) + + + withAnimation(.easeInOut(duration: 0.3)) { + showDaySelection = false + } + } + } + } + .padding(.top, 8) + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)), + removal: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)) + )) + } + + SettingsRowView( + icon: "pencil.and.ellipsis.rectangle", + title: "Update Timetable", + subtitle: "Keep your timetable up-to-date. Don't miss a class." + ).onTapGesture { + if let url = URL(string: "https://vitty.dscvit.com") { + UIApplication.shared.open(url) + } + } + + + } + } + + SettingsSectionView(title: "Notifications") { + Toggle(isOn: $viewModel.notificationsEnabled) { + HStack { + Image(systemName: "bell.badge.fill") + .foregroundColor(.white) + Text("Enable Notifications") + .foregroundColor(.white) + .font(.system(size: 15, weight: .semibold)) + } + } + .toggleStyle(SwitchToggleStyle(tint: .green)) + } + + SettingsSectionView(title: "About") { + AboutLinkView(image: "github-icon", title: "GitHub Repository", url: URL(string: "https://github.com/GDGVIT/vitty-ios")) + AboutLinkView(image: "gdsc-logo", title: "GDSC VIT", url: URL(string: "https://dscvit.com/")) + } + } + .scrollContentBackground(.hidden) + } + } + .navigationBarBackButtonHidden(true) + .interactiveDismissDisabled(true) + .onAppear { + viewModel.timetable = timeTables.first + viewModel.checkNotificationAuthorization() + loadSelectedDay() + print("Saturday before save:", timeTables[0].saturday.map { $0.name }) + + } + .alert("Notifications Disabled", isPresented: $viewModel.showNotificationDisabledAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("You will no longer receive class reminders.") + } + } + } + + + + private func loadSelectedDay() { + selectedDay = UserDefaults.standard.string(forKey: selectedDayKey) + } + + private func copyLecturesToSaturday(from day: String) { + guard let timeTable = timeTables.first else { return } + + let lecturesToCopy: [Lecture] + + switch day { + case "Monday": + lecturesToCopy = timeTable.monday + case "Tuesday": + lecturesToCopy = timeTable.tuesday + case "Wednesday": + lecturesToCopy = timeTable.wednesday + case "Thursday": + lecturesToCopy = timeTable.thursday + case "Friday": + lecturesToCopy = timeTable.friday + default: + lecturesToCopy = [] + } + + + let newTimeTable = TimeTable( + monday: timeTable.monday, + tuesday: timeTable.tuesday, + wednesday: timeTable.wednesday, + thursday: timeTable.thursday, + friday: timeTable.friday, + saturday: lecturesToCopy.map { lecture in + Lecture( + name: lecture.name, + code: lecture.code, + venue: lecture.venue, + slot: lecture.slot, + type: lecture.type, + startTime: lecture.startTime, + endTime: lecture.endTime + ) + }, + sunday: timeTable.sunday + ) + + + modelContext.delete(timeTable) + modelContext.insert(newTimeTable) + + + do { + try modelContext.save() + print("Successfully replaced timetable with copied lectures from \(day) to Saturday") + } catch { + print("Error saving context: \(error)") + } + } + + private var headerView: some View { + HStack { + Button(action: { + dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.title2) + } + Spacer() + Text("Settings") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + Spacer() + } + .padding(.horizontal) + .padding(.top) + } + + struct SettingsSectionView: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + Section(header: Text(title).foregroundColor(.white)) { + content() + .padding(.vertical, 6) + } + .listRowBackground(Color("Secondary")) + } + } + + struct SettingsRowView: View { + let icon: String + let title: String + let subtitle: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .foregroundColor(.white) + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + Text(subtitle) + .font(.system(size: 12)) + .foregroundColor(.gray.opacity(0.8)) + } + } + .padding(.vertical, 6) + } + } + + struct AboutLinkView: View { + let image: String + let title: String + let url: URL? + + var body: some View { + HStack(spacing: 12) { + Image(image) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.vertical, 6) + .onTapGesture { + if let url = url { + UIApplication.shared.open(url) + } + } + } + } } diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift new file mode 100644 index 0000000..8c9d67b --- /dev/null +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -0,0 +1,114 @@ +import Foundation +import SwiftUI +import UserNotifications + + +class SettingsViewModel : ObservableObject{ + var notificationsEnabled: Bool = false { + didSet { + UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") + if notificationsEnabled { + + if let timetable = self.timetable { + self.scheduleAllNotifications(from: timetable) + } + } else { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + showNotificationDisabledAlert = true + } + } + } + + var timetable: TimeTable? + var showNotificationDisabledAlert = false + + init(timetable: TimeTable? = nil) { + self.timetable = timetable + // Load the stored value + self.notificationsEnabled = UserDefaults.standard.bool(forKey: "notificationsEnabled") + checkNotificationAuthorization() + } + + func checkNotificationAuthorization() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + if settings.authorizationStatus != .authorized { + self.notificationsEnabled = false + } + } + } + } + + func requestNotificationPermission() { + // Since you handle permission elsewhere, this method can be simplified or removed + // Just schedule notifications if permission is already granted + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + if settings.authorizationStatus == .authorized { + if let timetable = self.timetable { + self.scheduleAllNotifications(from: timetable) + } + } else { + self.notificationsEnabled = false + } + } + } + } + + func scheduleAllNotifications(from timetable: TimeTable) { + let weekdays: [(Int, [Lecture])] = [ + (2, timetable.monday), + (3, timetable.tuesday), + (4, timetable.wednesday), + (5, timetable.thursday), + (6, timetable.friday), + (7, timetable.saturday), + (1, timetable.sunday) + ] + + for (weekday, lectures) in weekdays { + for lecture in lectures { + guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { continue } + + scheduleNotification(for: lecture.name, at: startDate, title: "Class Starting", minutesBefore: 0) + scheduleNotification(for: lecture.name, at: startDate, title: "Upcoming Class", minutesBefore: 10) + } + } + } + + private func scheduleNotification(for lectureName: String, at date: Date, title: String, minutesBefore: Int) { + let triggerDate = Calendar.current.date(byAdding: .minute, value: -minutesBefore, to: date) ?? date + + let content = UNMutableNotificationContent() + content.title = title + content.body = "\(lectureName) is starting soon." + content.sound = .default + + let triggerComponents = Calendar.current.dateComponents([.weekday, .hour, .minute], from: triggerDate) + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: true) + + let request = UNNotificationRequest( + identifier: "\(lectureName)-\(title)-\(triggerDate)", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) + } + + private func parseLectureTime(_ timeString: String, weekday: Int) -> Date? { + var cleaned = timeString.components(separatedBy: "T").last ?? "" + cleaned = cleaned.components(separatedBy: "Z").first ?? "" + + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + guard let time = formatter.date(from: cleaned) else { return nil } + + var components = Calendar.current.dateComponents([.year, .month, .weekOfYear], from: Date()) + components.weekday = weekday + components.hour = Calendar.current.component(.hour, from: time) + components.minute = Calendar.current.component(.minute, from: time) + + return Calendar.current.nextDate(after: Date(), matching: components, matchingPolicy: .nextTime) + } +} diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index 941d1b2..68c6fa8 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -4,6 +4,12 @@ // // Created by Chandram Dutta on 09/02/24. // +// +// TimeTable.swift +// VITTY +// +// Created by Chandram Dutta on 09/02/24. +// import Foundation import OSLog @@ -12,195 +18,195 @@ import SwiftData class TimeTableRaw: Codable { - let data: TimeTable + let data: TimeTable - enum CodingKeys: String, CodingKey { - case data - } + enum CodingKeys: String, CodingKey { + case data + } } @Model class TimeTable: Codable { - var monday: [Lecture] - var tuesday: [Lecture] - var wednesday: [Lecture] - var thursday: [Lecture] - var friday: [Lecture] - var saturday: [Lecture] - var sunday: [Lecture] + var monday: [Lecture] + var tuesday: [Lecture] + var wednesday: [Lecture] + var thursday: [Lecture] + var friday: [Lecture] + var saturday: [Lecture] + var sunday: [Lecture] @Transient - var logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: TimeTable.self - ) - ) - init( - monday: [Lecture], - tuesday: [Lecture], - wednesday: [Lecture], - thursday: [Lecture], - friday: [Lecture], - saturday: [Lecture], - sunday: [Lecture] - ) { - self.monday = monday - self.tuesday = tuesday - self.wednesday = wednesday - self.thursday = thursday - self.friday = friday - self.saturday = saturday - self.sunday = sunday - } + var logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String( + describing: TimeTable.self + ) + ) + init( + monday: [Lecture], + tuesday: [Lecture], + wednesday: [Lecture], + thursday: [Lecture], + friday: [Lecture], + saturday: [Lecture], + sunday: [Lecture] + ) { + self.monday = monday + self.tuesday = tuesday + self.wednesday = wednesday + self.thursday = thursday + self.friday = friday + self.saturday = saturday + self.sunday = sunday + } enum CodingKeys: String, CodingKey,Codable { - case monday = "Monday" - case tuesday = "Tuesday" - case wednesday = "Wednesday" - case thursday = "Thursday" - case friday = "Friday" - case saturday = "Saturday" - case sunday = "Sunday" - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - do { - monday = try container.decode([Lecture].self, forKey: .monday) - } - catch { - logger.error("Error decoding Monday lectures: \(error)") - monday = [] - } - - do { - tuesday = try container.decode([Lecture].self, forKey: .tuesday) - } - catch { - logger.error("Error decoding Tuesday lectures: \(error)") - tuesday = [] - } - - do { - wednesday = try container.decode([Lecture].self, forKey: .wednesday) - } - catch { - logger.error("Error decoding Wednesday lectures: \(error)") - wednesday = [] - } - - do { - thursday = try container.decode([Lecture].self, forKey: .thursday) - } - catch { - logger.error("Error decoding Thursday lectures: \(error)") - thursday = [] - } - - do { - friday = try container.decode([Lecture].self, forKey: .friday) - } - catch { - logger.error("Error decoding Friday lectures: \(error)") - friday = [] - } - - do { - saturday = try container.decode([Lecture].self, forKey: .saturday) - } - catch { - logger.error("Error decoding Saturday lectures: \(error)") - saturday = [] - } - - do { - sunday = try container.decode([Lecture].self, forKey: .sunday) - } - catch { - logger.error("Error decoding Sunday lectures: \(error)") - sunday = [] - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(monday, forKey: .monday) - try container.encode(tuesday, forKey: .tuesday) - try container.encode(wednesday, forKey: .wednesday) - try container.encode(thursday, forKey: .thursday) - try container.encode(friday, forKey: .friday) - try container.encode(saturday, forKey: .saturday) - try container.encode(sunday, forKey: .sunday) - } + case monday = "Monday" + case tuesday = "Tuesday" + case wednesday = "Wednesday" + case thursday = "Thursday" + case friday = "Friday" + case saturday = "Saturday" + case sunday = "Sunday" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + do { + monday = try container.decode([Lecture].self, forKey: .monday) + } + catch { + logger.error("Error decoding Monday lectures: \(error)") + monday = [] + } + + do { + tuesday = try container.decode([Lecture].self, forKey: .tuesday) + } + catch { + logger.error("Error decoding Tuesday lectures: \(error)") + tuesday = [] + } + + do { + wednesday = try container.decode([Lecture].self, forKey: .wednesday) + } + catch { + logger.error("Error decoding Wednesday lectures: \(error)") + wednesday = [] + } + + do { + thursday = try container.decode([Lecture].self, forKey: .thursday) + } + catch { + logger.error("Error decoding Thursday lectures: \(error)") + thursday = [] + } + + do { + friday = try container.decode([Lecture].self, forKey: .friday) + } + catch { + logger.error("Error decoding Friday lectures: \(error)") + friday = [] + } + + do { + saturday = try container.decode([Lecture].self, forKey: .saturday) + } + catch { + logger.error("Error decoding Saturday lectures: \(error)") + saturday = [] + } + + do { + sunday = try container.decode([Lecture].self, forKey: .sunday) + } + catch { + logger.error("Error decoding Sunday lectures: \(error)") + sunday = [] + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(monday, forKey: .monday) + try container.encode(tuesday, forKey: .tuesday) + try container.encode(wednesday, forKey: .wednesday) + try container.encode(thursday, forKey: .thursday) + try container.encode(friday, forKey: .friday) + try container.encode(saturday, forKey: .saturday) + try container.encode(sunday, forKey: .sunday) + } } @Model class Lecture: Codable, Identifiable, Comparable { - static func == (lhs: Lecture, rhs: Lecture) -> Bool { - return lhs.name == rhs.name - } + static func == (lhs: Lecture, rhs: Lecture) -> Bool { + return lhs.name == rhs.name + } - static func < (lhs: Lecture, rhs: Lecture) -> Bool { - return lhs.startTime < rhs.startTime - } + static func < (lhs: Lecture, rhs: Lecture) -> Bool { + return lhs.startTime < rhs.startTime + } - var name: String + var name: String var code: String var venue: String var slot: String var type: String var startTime: String - var endTime: String - - init( - name: String, - code: String, - venue: String, - slot: String, - type: String, - startTime: String, - endTime: String - ) { - self.name = name - self.code = code - self.venue = venue - self.slot = slot - self.type = type - self.startTime = startTime - self.endTime = endTime - } + var endTime: String + + init( + name: String, + code: String, + venue: String, + slot: String, + type: String, + startTime: String, + endTime: String + ) { + self.name = name + self.code = code + self.venue = venue + self.slot = slot + self.type = type + self.startTime = startTime + self.endTime = endTime + } enum CodingKeys: String, CodingKey,Codable { - case name, code, venue, slot, type - case startTime = "start_time" - case endTime = "end_time" - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - code = try container.decode(String.self, forKey: .code) - venue = try container.decode(String.self, forKey: .venue) - slot = try container.decode(String.self, forKey: .slot) - type = try container.decode(String.self, forKey: .type) - startTime = try container.decode(String.self, forKey: .startTime) - endTime = try container.decode(String.self, forKey: .endTime) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(code, forKey: .code) - try container.encode(venue, forKey: .venue) - try container.encode(slot, forKey: .slot) - try container.encode(type, forKey: .type) - try container.encode(startTime, forKey: .startTime) - try container.encode(endTime, forKey: .endTime) - } + case name, code, venue, slot, type + case startTime = "start_time" + case endTime = "end_time" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + code = try container.decode(String.self, forKey: .code) + venue = try container.decode(String.self, forKey: .venue) + slot = try container.decode(String.self, forKey: .slot) + type = try container.decode(String.self, forKey: .type) + startTime = try container.decode(String.self, forKey: .startTime) + endTime = try container.decode(String.self, forKey: .endTime) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(code, forKey: .code) + try container.encode(venue, forKey: .venue) + try container.encode(slot, forKey: .slot) + try container.encode(type, forKey: .type) + try container.encode(startTime, forKey: .startTime) + try container.encode(endTime, forKey: .endTime) + } } extension TimeTable { var isEmpty: Bool { @@ -253,7 +259,7 @@ extension TimeTable { - private func formatTime(time: String) -> String { + private func formatTime(time: String) -> String { var timeComponents = time.components(separatedBy: "T").last ?? "" timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 38dc13a..b32a10f 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -99,32 +99,16 @@ struct TimeTableView: View { LectureDetailView(lecture: lecture) } .onAppear { - print(authViewModel.loggedInBackendUser?.token ?? "auth auth token") logger.debug("onAppear triggered") + + if let existing = timetableItem.first { - logger.debug("exixting") - - if existing.isEmpty { - - logger.debug("is empty") - Task { - await viewModel.fetchTimeTable( - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), - authToken: authViewModel.loggedInBackendUser?.token ?? "" - ) - if let fetched = viewModel.timeTable { - context.insert(fetched) - } - } - } else { - - - viewModel.timeTable = existing - viewModel.changeDay() - viewModel.stage = .data - } + logger.debug("existing timetable found") + viewModel.timeTable = existing + viewModel.changeDay() + viewModel.stage = .data } else { - logger.debug("fetching") + logger.debug("no local timetable, fetching from API") Task { await viewModel.fetchTimeTable( username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), @@ -132,12 +116,12 @@ struct TimeTableView: View { ) if let fetched = viewModel.timeTable { context.insert(fetched) - } } } } + } } } diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index e0aff32..d6340ce 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -4,12 +4,11 @@ struct UserProfileSidebar: View { @Environment(AuthViewModel.self) private var authViewModel @Binding var isPresented: Bool @State private var ghostMode: Bool = false - + var body: some View { ZStack(alignment: .topTrailing) { - Button { - withAnimation { + withAnimation(.easeInOut(duration: 0.8)) { isPresented = false } } label: { @@ -17,80 +16,73 @@ struct UserProfileSidebar: View { .foregroundColor(.white) .padding() } - + VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 8) { UserImage( url: authViewModel.loggedInBackendUser?.picture ?? "", height: 60, width: 60 ) - + Text(authViewModel.loggedInBackendUser?.name ?? "User") .font(Font.custom("Poppins-Bold", size: 18)) .foregroundColor(.white) - + Text("@\(authViewModel.loggedInBackendUser?.username ?? "")") .font(Font.custom("Poppins-Regular", size: 14)) .foregroundColor(.white.opacity(0.8)) } .padding(.top, 40) - - Divider() - .background(Color.clear) - + + Divider().background(Color.clear) + NavigationLink { EmptyClassRoom() } label: { MenuOption(icon: "emptyclassroom", title: "Find Empty Classroom") } - + NavigationLink { SettingsView() } label: { MenuOption(icon: "settings", title: "Settings") } - - Divider() - .background(Color.clear) - + + Divider().background(Color.clear) + MenuOption(icon: "share", title: "Share") - MenuOption(icon: "support", title: "Support") - MenuOption(icon: "about", title: "About") - - Divider() - .background(Color.clear) - + + Divider().background(Color.clear) + VStack(alignment: .leading, spacing: 4) { Text("Ghost Mode") .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) - + Text("(your timetable will be visible only to you)") .font(Font.custom("Poppins-Regular", size: 12)) .foregroundColor(.white.opacity(0.7)) - + Toggle("", isOn: $ghostMode) .labelsHidden() .toggleStyle(SwitchToggleStyle(tint: Color("Accent"))) .padding(.top, 4) } - + Spacer() - - // Logout button + Button { authViewModel.signOut() } label: { HStack { Image(systemName: "rectangle.portrait.and.arrow.right") - .foregroundColor(Color.red) + .foregroundColor(.red) Text("log out") .font(Font.custom("Poppins-Medium", size: 16)) - .foregroundColor(Color.red) + .foregroundColor(.red) } } .padding(.bottom, 32) @@ -99,7 +91,6 @@ struct UserProfileSidebar: View { .frame(width: UIScreen.main.bounds.width * 0.75, alignment: .leading) .frame(maxHeight: .infinity) .background(Color("Background")) - } } } @@ -107,13 +98,13 @@ struct UserProfileSidebar: View { struct MenuOption: View { let icon: String let title: String - + var body: some View { HStack(spacing: 16) { Image(icon) .foregroundColor(.white) .frame(width: 24) - + Text(title) .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index 81cf3fe..ec5785d 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -60,7 +60,7 @@ struct VITTYApp: App { }.modelContainer(sharedModelContainer) } var sharedModelContainer: ModelContainer { - let schema = Schema([TimeTable.self,Remainder.self]) + let schema = Schema([TimeTable.self,Remainder.self,CreateNoteModel.self]) let config = ModelConfiguration( "group.com.gdscvit.vittyioswidget" From bbb74d728e1d7d0753c8e446b4438d1b123db15b Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Mon, 23 Jun 2025 17:55:16 +0545 Subject: [PATCH 03/16] fix: note view craches --- VITTY/VITTY/Academics/Model/NotesModel.swift | 53 ++++ VITTY/VITTY/Academics/View/CourseRefs.swift | 77 +++--- VITTY/VITTY/Academics/View/Courses.swift | 2 +- VITTY/VITTY/Academics/View/Notes.swift | 252 +++++++++++++----- VITTY/VITTY/Academics/View/NotesHelper.swift | 24 +- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 10 +- VITTY/VITTY/Settings/View/SettingsView.swift | 2 +- VITTY/VITTY/Shared/Constants.swift | 4 +- VITTY/VITTY/TimeTable/Models/TimeTable.swift | 8 + 9 files changed, 303 insertions(+), 129 deletions(-) diff --git a/VITTY/VITTY/Academics/Model/NotesModel.swift b/VITTY/VITTY/Academics/Model/NotesModel.swift index 4753e5d..9bb0e95 100644 --- a/VITTY/VITTY/Academics/Model/NotesModel.swift +++ b/VITTY/VITTY/Academics/Model/NotesModel.swift @@ -45,3 +45,56 @@ class CreateNoteModel { +extension CreateNoteModel { + + private static var plainTextCache: [String: String] = [:] + private static var attributedStringCache: [String: NSAttributedString] = [:] + + var cachedPlainText: String { + let cacheKey = "\(self.courseId)_\(self.createdAt.timeIntervalSince1970)" + + if let cached = Self.plainTextCache[cacheKey] { + return cached + } + + let plainText = extractPlainText() + Self.plainTextCache[cacheKey] = plainText + return plainText + } + + var cachedAttributedString: NSAttributedString? { + let cacheKey = "\(self.courseId)_\(self.createdAt.timeIntervalSince1970)" + + if let cached = Self.attributedStringCache[cacheKey] { + return cached + } + + let attributedString = extractAttributedString() + if let attributedString = attributedString { + Self.attributedStringCache[cacheKey] = attributedString + } + return attributedString + } + + private func extractPlainText() -> String { + guard let data = Data(base64Encoded: noteContent), + let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + return noteContent + } + return attributedString.string + } + + private func extractAttributedString() -> NSAttributedString? { + guard let data = Data(base64Encoded: noteContent), + let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + return NSAttributedString(string: noteContent) + } + return attributedString + } + + + static func clearCache() { + plainTextCache.removeAll() + attributedStringCache.removeAll() + } +} diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index d7d50a0..2492d9d 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -1,7 +1,7 @@ import SwiftUI import SwiftData -struct CourseRefs: View { +struct OCourseRefs: View { var courseName: String var courseInstitution: String var slot: String @@ -9,17 +9,17 @@ struct CourseRefs: View { @State private var showBottomSheet = false @State private var showReminderSheet = false + @State private var showNotes = false @State private var navigateToNotesEditor = false - - @State private var showSheet = false - - @State private var myExistingNote: CreateNoteModel = CreateNoteModel(noteName: "", userName: "", courseId: "", courseName: "", noteContent: "") + @State var showCourseNotes : Bool = false + @State private var selectedNote: CreateNoteModel? + @State private var preloadedAttributedString: NSAttributedString? @Environment(\.dismiss) private var dismiss private let maxVisible = 4 - // Fetch remainders with dynamic predicate + @Query private var filteredRemainders: [Remainder] @Query private var courseNotes: [CreateNoteModel] @@ -37,7 +37,7 @@ struct CourseRefs: View { ) let notesPredicate = #Predicate { - $0.courseId == courseCode + $0.courseId == courseCode } _courseNotes = Query( FetchDescriptor(predicate: notesPredicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) @@ -67,11 +67,7 @@ struct CourseRefs: View { Spacer() - Button(action: {}) { - Image(systemName: "magnifyingglass") - .foregroundColor(.white) - .font(.title2) - } + } .padding() @@ -117,30 +113,21 @@ struct CourseRefs: View { .padding() } else { ForEach(courseNotes, id: \.createdAt) { note in - - NavigationLink( - destination : NoteEditorView(existingNote: note, courseCode: courseCode, courseName: courseName) - ){ - CourseCardNotes( - title: note.noteName, - description: extractPlainTextFromNote(note.noteContent) - ) + CourseCardNotes( + title: note.noteName, + description: note.cachedPlainText + ) + .onTapGesture { + selectedNote = note + + Task { + preloadedAttributedString = note.cachedAttributedString + } } - -// CourseCardNotes( -// title: note.noteName, -// description: extractPlainTextFromNote(note.noteContent) -// ).onTapGesture { -// myExistingNote = note -// showSheet.toggle() -// } -// - } } } .padding() - } } @@ -162,13 +149,11 @@ struct CourseRefs: View { .padding(.bottom, 30) } } - }.onAppear{ + } + .onAppear { print("this is course code") print(courseCode) } - .sheet(isPresented: $showSheet, content: { - ExistingHotelView(existingNote: myExistingNote) - }) .navigationBarHidden(true) .edgesIgnoringSafeArea(.bottom) .sheet(isPresented: $showBottomSheet) { @@ -197,22 +182,20 @@ struct CourseRefs: View { ReminderView(courseName: courseName, slot: slot, courseCode: courseCode) .presentationDetents([.fraction(0.8)]) } + .navigationDestination(isPresented: $navigateToNotesEditor) { - NoteEditorView(courseCode: courseCode, courseName: courseName) + NoteEditorView(courseCode: courseCode, courseName: courseName) + } + .navigationDestination(item: $selectedNote) { note in + NoteEditorView( + existingNote: note, + preloadedAttributedString: preloadedAttributedString, + courseCode: courseCode, + courseName: courseName + ) } } } - - private func extractPlainTextFromNote(_ noteContent: String) -> String { - - guard let data = Data(base64Encoded: noteContent), - let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { - - return noteContent - } - - return attributedString.string - } } diff --git a/VITTY/VITTY/Academics/View/Courses.swift b/VITTY/VITTY/Academics/View/Courses.swift index 817059e..66cdf43 100644 --- a/VITTY/VITTY/Academics/View/Courses.swift +++ b/VITTY/VITTY/Academics/View/Courses.swift @@ -29,7 +29,7 @@ struct CoursesView: View { VStack(spacing: 16) { ForEach(filtered) { course in - NavigationLink(destination: CourseRefs(courseName: course.title, courseInstitution: course.code,slot:course.slot,courseCode: course.code)) { + NavigationLink(destination: OCourseRefs(courseName: course.title, courseInstitution: course.code,slot:course.slot,courseCode: course.code)) { CourseCardView(course: course) } } diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index 7564de1..25adeb3 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -6,6 +6,7 @@ struct RichTextView: UIViewRepresentable { @Binding var selectedRange: NSRange @Binding var typingAttributes: [NSAttributedString.Key: Any] @Binding var isEmpty: Bool + func makeUIView(context: Context) -> UITextView { let textView = UITextView() @@ -17,7 +18,7 @@ struct RichTextView: UIViewRepresentable { textView.backgroundColor = .clear textView.textColor = .white - // Set initial content + textView.attributedText = attributedText textView.selectedRange = selectedRange @@ -25,25 +26,27 @@ struct RichTextView: UIViewRepresentable { } func updateUIView(_ uiView: UITextView, context: Context) { - // Prevent infinite loops by checking if coordinator is updating + if context.coordinator.isUpdating { return } - // Only update if the content is actually different + if !uiView.attributedText.isEqual(to: attributedText) { let previousSelectedRange = uiView.selectedRange context.coordinator.isUpdating = true uiView.attributedText = attributedText - // Restore selection if valid + if previousSelectedRange.location <= uiView.attributedText.length { - uiView.selectedRange = previousSelectedRange + let maxRange = min(previousSelectedRange.location + previousSelectedRange.length, uiView.attributedText.length) + let validRange = NSRange(location: previousSelectedRange.location, length: maxRange - previousSelectedRange.location) + uiView.selectedRange = validRange } context.coordinator.isUpdating = false } - // Only update selection if it's different and valid + if !NSEqualRanges(uiView.selectedRange, selectedRange) && selectedRange.location <= uiView.attributedText.length && NSMaxRange(selectedRange) <= uiView.attributedText.length { @@ -52,7 +55,7 @@ struct RichTextView: UIViewRepresentable { context.coordinator.isUpdating = false } - // Update typing attributes if they're different + if !NSDictionary(dictionary: uiView.typingAttributes).isEqual(to: typingAttributes) { uiView.typingAttributes = typingAttributes } @@ -71,34 +74,49 @@ struct RichTextView: UIViewRepresentable { } func textViewDidChange(_ textView: UITextView) { - // Prevent recursive updates + guard !isUpdating else { return } isUpdating = true defer { isUpdating = false } - // Update parent state + parent.attributedText = NSMutableAttributedString(attributedString: textView.attributedText) parent.isEmpty = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + + parent.typingAttributes = textView.typingAttributes } func textViewDidChangeSelection(_ textView: UITextView) { - // Prevent recursive updates + guard !isUpdating else { return } isUpdating = true defer { isUpdating = false } parent.selectedRange = textView.selectedRange + + + if textView.selectedRange.length == 0 && textView.selectedRange.location > 0 { + + let location = min(textView.selectedRange.location - 1, textView.attributedText.length - 1) + if location >= 0 { + let attributes = textView.attributedText.attributes(at: location, effectiveRange: nil) + parent.typingAttributes = attributes + } + } } } } -// Optimized NoteEditorView with better state management + + struct NoteEditorView: View { @Environment(\.dismiss) private var dismiss @Environment(AcademicsViewModel.self) private var academicsViewModel @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.presentationMode) var presentationMode @State private var attributedText = NSMutableAttributedString() @State private var selectedRange = NSRange(location: 0, length: 0) @@ -108,6 +126,7 @@ struct NoteEditorView: View { ] let existingNote: CreateNoteModel? + let preloadedAttributedString: NSAttributedString? // Pre-processed content @State private var selectedFont: UIFont = UIFont.systemFont(ofSize: 18) @State private var selectedColor: Color = .white @State private var showFontPicker = false @@ -120,22 +139,40 @@ struct NoteEditorView: View { let courseCode: String let courseName: String - init(existingNote: CreateNoteModel? = nil, courseCode: String, courseName: String) { + init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String) { self.existingNote = existingNote + self.preloadedAttributedString = preloadedAttributedString self.courseCode = existingNote?.courseId ?? courseCode self.courseName = existingNote?.courseName ?? courseName } - + private func handleBackNavigation() { + + + // Fallback for older navigation approaches + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if presentationMode.wrappedValue.isPresented { + presentationMode.wrappedValue.dismiss() + } + } + } private func initializeContent() { guard !isInitialized else { return } if let note = existingNote { - Task { @MainActor in - await loadNoteContent(note) + if let preloaded = preloadedAttributedString { + + attributedText = NSMutableAttributedString(attributedString: preloaded) + isEmpty = preloaded.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty isInitialized = true + } else { + + Task { @MainActor in + await loadNoteContent(note) + isInitialized = true + } } } else { - // New note - initialize with empty content + attributedText = NSMutableAttributedString() isEmpty = true isInitialized = true @@ -144,42 +181,43 @@ struct NoteEditorView: View { @MainActor private func loadNoteContent(_ note: CreateNoteModel) async { + + if let cachedAttributedString = note.cachedAttributedString { + attributedText = NSMutableAttributedString(attributedString: cachedAttributedString) + isEmpty = cachedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return + } + + do { - // Parse the base64 encoded attributed string guard let data = Data(base64Encoded: note.noteContent) else { print("Failed to decode base64 data") - // Fallback to plain text attributedText = NSMutableAttributedString(string: note.noteContent) isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return } - // Try to unarchive the attributed string if let loadedAttributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { attributedText = NSMutableAttributedString(attributedString: loadedAttributedString) isEmpty = loadedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } else { print("Failed to unarchive attributed string") - // Fallback to plain text attributedText = NSMutableAttributedString(string: note.noteContent) isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } catch { print("Error loading note content: \(error)") - // Fallback to plain text attributedText = NSMutableAttributedString(string: note.noteContent) isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } func saveContent() { - guard hasUnsavedChanges || existingNote == nil else { - dismiss() + handleBackNavigation() return } - do { let data = try NSKeyedArchiver.archivedData(withRootObject: attributedText, requiringSecureCoding: false) let dataString = data.base64EncodedString() @@ -189,6 +227,8 @@ struct NoteEditorView: View { note.noteName = title note.noteContent = dataString note.createdAt = Date.now + + CreateNoteModel.clearCache() } else { let newNote = CreateNoteModel( noteName: title, @@ -235,22 +275,22 @@ struct NoteEditorView: View { if isInitialized { VStack { - // Header + headerView - // Text Editor + textEditorView - // Toolbar + toolbarView } } else { - // Loading state + ProgressView("Loading...") .foregroundColor(.white) } - // Overlays + if showFontPicker { fontPickerOverlay } @@ -277,7 +317,7 @@ struct NoteEditorView: View { private var headerView: some View { HStack { - Button(action: { dismiss() }) { + Button(action: { handleBackNavigation() }) { Image(systemName: "chevron.left") .foregroundColor(Color("Accent")) } @@ -319,7 +359,7 @@ struct NoteEditorView: View { private var toolbarView: some View { HStack(spacing: 20) { - // Font picker button + Button(action: { showFontPicker.toggle() showFontSizePicker = false @@ -328,7 +368,7 @@ struct NoteEditorView: View { .foregroundColor(Color("Accent")) } - // Font size button + Button(action: { showFontSizePicker.toggle() showFontPicker = false @@ -343,12 +383,12 @@ struct NoteEditorView: View { } } - // Formatting buttons + formatButton(action: toggleBold, icon: "bold", isActive: isBoldActive()) formatButton(action: toggleItalic, icon: "italic", isActive: isItalicActive()) formatButton(action: toggleUnderline, icon: "underline", isActive: isUnderlineActive()) - // Color picker + ColorPicker("", selection: $selectedColor, supportsOpacity: false) .labelsHidden() .frame(width: 30, height: 30) @@ -356,7 +396,7 @@ struct NoteEditorView: View { applyAttribute(.foregroundColor, value: UIColor(newColor)) } - // Bullet points button + Button(action: addBulletPoints) { Image(systemName: "list.bullet") .foregroundColor(Color("Accent")) @@ -455,7 +495,7 @@ struct NoteEditorView: View { } } - // MARK: - Text Formatting Functions + // MARK: - Text Formatting Functions (Rest of the formatting functions remain the same) func addBulletPoints() { guard selectedRange.length > 0 else { return } @@ -472,13 +512,11 @@ struct NoteEditorView: View { } func isBoldActive() -> Bool { - let font = getCurrentFont() - return font.fontDescriptor.symbolicTraits.contains(.traitBold) + return checkTraitActive(.traitBold) } func isItalicActive() -> Bool { - let font = getCurrentFont() - return font.fontDescriptor.symbolicTraits.contains(.traitItalic) + return checkTraitActive(.traitItalic) } func isUnderlineActive() -> Bool { @@ -486,6 +524,28 @@ struct NoteEditorView: View { return underline == NSUnderlineStyle.single.rawValue } + private func checkTraitActive(_ trait: UIFontDescriptor.SymbolicTraits) -> Bool { + if selectedRange.length > 0 { + var hasTraitThroughout = true + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + + attributedText.enumerateAttribute(.font, in: NSRange(location: selectedRange.location, length: endLocation - selectedRange.location), options: []) { value, range, stop in + if let font = value as? UIFont { + if !font.fontDescriptor.symbolicTraits.contains(trait) { + hasTraitThroughout = false + stop.pointee = true + } + } + } + return hasTraitThroughout + } else { + if let font = typingAttributes[.font] as? UIFont { + return font.fontDescriptor.symbolicTraits.contains(trait) + } + return false + } + } + private func getCurrentFont() -> UIFont { if selectedRange.length > 0 && selectedRange.location < attributedText.length { return attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont ?? UIFont.systemFont(ofSize: 18) @@ -503,8 +563,8 @@ struct NoteEditorView: View { } func applyFontFamily(_ font: UIFont) { - let currentFont = getCurrentFont() - let newFont = UIFont(name: font.fontName, size: currentFont.pointSize) ?? font + let size = getCurrentFont().pointSize + let newFont = UIFont(name: font.fontName, size: size) ?? font applyAttribute(.font, value: newFont) } @@ -515,43 +575,105 @@ struct NoteEditorView: View { } func toggleBold() { - let currentFont = getCurrentFont() - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitBold) { - traits.remove(.traitBold) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString } else { - traits.insert(.traitBold) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - applyAttribute(.font, value: newFont) + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } } + hasUnsavedChanges = true } func toggleItalic() { - let currentFont = getCurrentFont() - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitItalic) { - traits.remove(.traitItalic) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString } else { - traits.insert(.traitItalic) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - applyAttribute(.font, value: newFont) + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } } + hasUnsavedChanges = true } - + func toggleUnderline() { - let currentUnderline = getCurrentUnderlineStyle() - let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue - applyAttribute(.underlineStyle, value: newUnderline) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.underlineStyle, in: range, options: []) { value, subRange, _ in + let currentUnderline = value as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + mutableAttributedString.addAttribute(.underlineStyle, value: newUnderline, range: subRange) + } + attributedText = mutableAttributedString + } else { + let currentUnderline = typingAttributes[.underlineStyle] as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + typingAttributes[.underlineStyle] = newUnderline + } + hasUnsavedChanges = true } func applyAttribute(_ key: NSAttributedString.Key, value: Any) { if selectedRange.length > 0 { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) - mutableAttributedString.addAttribute(key, value: value, range: selectedRange) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + mutableAttributedString.addAttribute(key, value: value, range: range) attributedText = mutableAttributedString } else { typingAttributes[key] = value diff --git a/VITTY/VITTY/Academics/View/NotesHelper.swift b/VITTY/VITTY/Academics/View/NotesHelper.swift index 546d46c..157cb49 100644 --- a/VITTY/VITTY/Academics/View/NotesHelper.swift +++ b/VITTY/VITTY/Academics/View/NotesHelper.swift @@ -202,13 +202,13 @@ extension String { var workingLine = line let result = NSMutableAttributedString() - // Default attributes + var attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.white ] - // Handle headings + if workingLine.hasPrefix("### ") { workingLine = String(workingLine.dropFirst(4)) attributes[.font] = UIFont.boldSystemFont(ofSize: 20) @@ -220,12 +220,12 @@ extension String { attributes[.font] = UIFont.boldSystemFont(ofSize: 28) } - // Handle bullet points + if workingLine.trimmingCharacters(in: .whitespaces).hasPrefix("- ") { workingLine = workingLine.replacingOccurrences(of: "- ", with: "• ", options: [], range: workingLine.range(of: "- ")) } - // Process inline formatting + let processedString = processInlineFormatting(workingLine, baseAttributes: attributes) result.append(processedString) @@ -238,15 +238,15 @@ extension String { var currentAttributes = baseAttributes while currentIndex < text.endIndex { - // Handle HTML color spans + if let colorRange = findColorSpan(in: text, from: currentIndex) { - // Add text before color span + if currentIndex < colorRange.range.lowerBound { let beforeText = String(text[currentIndex..") { if let endIndex = text.range(of: "", range: currentIndex.. remainingText.startIndex { let beforeText = String(remainingText[remainingText.startIndex.. Bool { + return monday != other.monday || + tuesday != other.tuesday || + wednesday != other.wednesday || + thursday != other.thursday || + friday != other.friday || + sunday != other.sunday + } } From c97e775e0e568af69b0bf6f53d291b382477df0a Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Tue, 24 Jun 2025 10:07:49 +0545 Subject: [PATCH 04/16] fix: headers fix and freinds filters fix --- VITTY/VITTY/Academics/View/NotesHelper.swift | 3 +- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 17 +-- .../AddFriends/View/AddFriendsView.swift | 51 +++++-- VITTY/VITTY/Connect/Models/Friend.swift | 1 + .../Connect/Search/Views/SearchView.swift | 22 ++- .../View/Circles/Components/JoinGroup.swift | 6 +- VITTY/VITTY/Connect/View/ConnectPage.swift | 136 ++++++++++-------- .../Connect/View/Freinds/View/Freinds.swift | 48 +++++-- VITTY/VITTY/Shared/Constants.swift | 4 +- 9 files changed, 190 insertions(+), 98 deletions(-) diff --git a/VITTY/VITTY/Academics/View/NotesHelper.swift b/VITTY/VITTY/Academics/View/NotesHelper.swift index 157cb49..58aa545 100644 --- a/VITTY/VITTY/Academics/View/NotesHelper.swift +++ b/VITTY/VITTY/Academics/View/NotesHelper.swift @@ -3,7 +3,8 @@ import UIKit extension NSAttributedString { // func toMarkdown() -> String { -// let mutableString = NSMutableString() +// let mutableString = NSMutableString()login + // let fullRange = NSRange(location: 0, length: self.length) // // self.enumerateAttributes(in: fullRange, options: []) { (attributes, range, _) in diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 93a0fbd..9684adb 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -67,14 +67,15 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") do { - self.loggedInBackendUser = try await AuthAPIService.shared - .signInUser( - with: AuthRequestBody( - uuid: loggedInFirebaseUser?.uid ?? "", - reg_no: regNo, - username: username - ) - ) +// self.loggedInBackendUser = try await AuthAPIService.shared +// .signInUser( +// with: AuthRequestBody( +// uuid: loggedInFirebaseUser?.uid ?? "", +// reg_no: regNo, +// username: username +// ) +// ) + self.loggedInBackendUser = AppUser(name: "Rudrank Basant", picture: "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", role: "normal", token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJ1ZHJhbmsxMjNAZ21haWwuY29tIiwicm9sZSI6Im5vcm1hbCIsInVzZXJuYW1lIjoicnVkcmFuayJ9.m7YQwp7hLCBO1YXPNvwpaHCOXh5BZVa6BK7sTYVzUT4", username: "rudrank") } catch { diff --git a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift index fe5488c..6a68880 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift @@ -12,14 +12,20 @@ struct AddFriendsView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel @Environment(FriendRequestViewModel.self) private var friendRequestViewModel + @Environment(\.dismiss) private var dismiss @State private var isSearchViewPresented = false var body: some View { NavigationStack { ZStack { + headerView BackgroundView() VStack(alignment: .leading) { + Button(action: {dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + } if !suggestedFriendsViewModel.suggestedFriends.isEmpty || !friendRequestViewModel.requests.isEmpty { @@ -42,28 +48,18 @@ struct AddFriendsView: View { Text("Request and Suggestions") .multilineTextAlignment(.center) .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white) + .foregroundColor(Color.white).padding() Text("Your friend requests and suggested friends will be shown here") .multilineTextAlignment(.center) .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundColor(Color.white) + .foregroundColor(Color.white).padding() Spacer() } } - } + } .navigationBarBackButtonHidden(true) .toolbar { - Button(action: { - isSearchViewPresented = true - }) { - Image(systemName: "magnifyingglass") - .foregroundColor(.white) - } - .navigationDestination( - isPresented: $isSearchViewPresented, - destination: { SearchView() } - ) } - .navigationTitle("Add Friends") + } .onAppear { suggestedFriendsViewModel.fetchData( @@ -73,4 +69,31 @@ struct AddFriendsView: View { ) } } + private var headerView: some View { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + } + Spacer() + Text("Note") + .foregroundColor(.white) + .font(.system(size: 25, weight: .bold)) + Spacer() + Button(action: { + isSearchViewPresented = true + }) { + Image(systemName: "magnifyingglass") + .foregroundColor(.white) + } + .navigationDestination( + isPresented: $isSearchViewPresented, + destination: { SearchView() } + ) + + + }.padding() + } + + } diff --git a/VITTY/VITTY/Connect/Models/Friend.swift b/VITTY/VITTY/Connect/Models/Friend.swift index 8d0851c..191760c 100644 --- a/VITTY/VITTY/Connect/Models/Friend.swift +++ b/VITTY/VITTY/Connect/Models/Friend.swift @@ -65,3 +65,4 @@ extension Friend { ) } } + diff --git a/VITTY/VITTY/Connect/Search/Views/SearchView.swift b/VITTY/VITTY/Connect/Search/Views/SearchView.swift index 0f89a0b..0a2166a 100644 --- a/VITTY/VITTY/Connect/Search/Views/SearchView.swift +++ b/VITTY/VITTY/Connect/Search/Views/SearchView.swift @@ -27,6 +27,8 @@ struct SearchView: View { ZStack { BackgroundView() VStack(alignment: .leading) { + + headerView RoundedRectangle(cornerRadius: 20) .foregroundColor(Color("Secondary")) .frame(maxWidth: .infinity) @@ -75,11 +77,25 @@ struct SearchView: View { Spacer() } - } - .navigationTitle("Search") + }.navigationBarBackButtonHidden(true) + } } - + private var headerView: some View { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + } + Spacer() + Text("Search") + .foregroundColor(.white) + .font(.system(size: 22, weight: .bold)) + Spacer() + + } + .padding() + } func search() { loading = true let url = URL(string: "\(APIConstants.base_url)/api/v2/users/search?query=\(searchText)")! diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index d4fd066..d8b79f2 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -19,7 +19,7 @@ struct JoinGroup: View { Text("Join Group") .font(.system(size: 21, weight: .bold)) .foregroundColor(.white) - + Spacer().frame(width: 20) VStack(alignment: .leading, spacing: 10) { Text("Enter group code") .font(.system(size: 16, weight: .bold)) @@ -61,8 +61,8 @@ struct JoinGroup: View { Image(systemName: "qrcode.viewfinder") .resizable() .scaledToFit() - .frame(width: 120, height: 120) - .foregroundColor(Color.gray) + .frame(width: 100, height: 100) + .foregroundColor(Color.white) } .frame(width: screenWidth*0.8, height: screenHeight*0.25) .background(Color.black.opacity(0.3)) diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 87aada1..3ec7bc7 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -4,9 +4,22 @@ // // Created by Rujin Devkota on 2/27/25. - import SwiftUI +// Enum to manage different sheet types +enum SheetType: Identifiable { + case addCircleOptions + case createGroup + case joinGroup + + var id: Int { + switch self { + case .addCircleOptions: return 0 + case .createGroup: return 1 + case .joinGroup: return 2 + } + } +} struct ConnectPage: View { @Environment(AuthViewModel.self) private var authViewModel @@ -14,9 +27,8 @@ struct ConnectPage: View { @Environment(FriendRequestViewModel.self) private var friendRequestViewModel @State private var isShowingRequestView = false @State var isCircleView = false - @State var isAddCircleFunc = false - @State var showCreateGroupSheet = false - @State var showJoinGroupSheet = false + @State private var activeSheet: SheetType? + @Environment(\.dismiss) private var dismiss @Binding var isCreatingGroup : Bool @@ -27,8 +39,6 @@ struct ConnectPage: View { ZStack { BackgroundView() - - VStack(spacing: 0) { HStack { @@ -37,10 +47,8 @@ struct ConnectPage: View { isCircleView = false } AcademicsTabButton(title: "Circles", isActive: selectedTab == 1) { - selectedTab = 1 isCircleView = true - } } .padding(.top,20) @@ -53,10 +61,10 @@ struct ConnectPage: View { } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) } + if isCircleView == false { Button(action: { isShowingRequestView.toggle() - }) { Image(systemName: "person.fill.badge.plus") .foregroundColor(.white) @@ -66,60 +74,30 @@ struct ConnectPage: View { destination: { AddFriendsView() } - ).offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) - } else{ + ) + .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) + } else { Button(action: { - isAddCircleFunc.toggle() - + activeSheet = .addCircleOptions }) { Image(systemName: "person.fill.badge.plus") .foregroundColor(.white) } - .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) + .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } - - }.sheet(isPresented: $isAddCircleFunc){ - ZStack{ - Color("Background") - HStack(spacing: 40) { - - Button(action:{ - showJoinGroupSheet.toggle() - }) { - VStack { - Image("joingroup") - .resizable() - .frame(width: 55, height: 55) - Text("Join Group") - .font(.system(size: 15)) - .foregroundStyle(Color.white) - } - } - - Button(action:{ - showJoinGroupSheet.toggle() - }) { - VStack { - Image("creategroup") - .resizable() - .frame(width: 55, height: 55) - Text("Create Group") - .font(.system(size: 15)) - .foregroundStyle(Color.white) - } - } - }.presentationDetents([.height(200)]) - .padding(.top, 10) - }.background(Color("Background")) - } - .sheet(isPresented: $showCreateGroupSheet) { - CreateGroup(groupCode:.constant("")) } - .sheet(isPresented: $showJoinGroupSheet) { - JoinGroup(groupCode: .constant("")) + // Single sheet modifier handling all sheet presentations + .sheet(item: $activeSheet) { sheetType in + switch sheetType { + case .addCircleOptions: + AddCircleOptionsView(activeSheet: $activeSheet) + case .createGroup: + CreateGroup(groupCode: .constant("")) + case .joinGroup: + JoinGroup(groupCode: .constant("")) + } } .onAppear { - communityPageViewModel.fetchFriendsData( from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", token: authViewModel.loggedInBackendUser?.token ?? "", @@ -135,12 +113,57 @@ struct ConnectPage: View { authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: true ) - - } } } +// Separate view for the add circle options sheet +struct AddCircleOptionsView: View { + @Binding var activeSheet: SheetType? + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Color("Background") + HStack(spacing: 40) { + Button(action: { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + activeSheet = .joinGroup + } + }) { + VStack { + Image("joingroup") + .resizable() + .frame(width: 55, height: 55) + Text("Join Group") + .font(.system(size: 15)) + .foregroundStyle(Color.white) + } + } + + Button(action: { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + activeSheet = .createGroup + } + }) { + VStack { + Image("creategroup") + .resizable() + .frame(width: 55, height: 55) + Text("Create Group") + .font(.system(size: 15)) + .foregroundStyle(Color.white) + } + } + } + .padding(.top, 10) + } + .background(Color("Background")) + .presentationDetents([.height(150)]) + } +} struct FilterPill: View { let title: String @@ -161,5 +184,4 @@ struct FilterPill: View { ) ) } - } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index f56c353..029b05e 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -5,6 +5,7 @@ // Created by Rujin Devkota on 2/27/25. // import SwiftUI + struct FriendsView: View { @State private var searchText = "" @State private var selectedFilterOption = 0 @@ -17,7 +18,8 @@ struct FriendsView: View { SearchBar(searchText: $searchText) Spacer().frame(height: 8) - // Filter pills - always visible + + HStack { FilterPill(title: "Available", isSelected: selectedFilterOption == 0) .onTapGesture { @@ -32,7 +34,7 @@ struct FriendsView: View { .padding(.horizontal) Spacer().frame(height: 7) - // Conditional content based on state + if communityPageViewModel.errorFreinds { Spacer() VStack(spacing: 5) { @@ -51,21 +53,47 @@ struct FriendsView: View { ProgressView() Spacer() } else { - // Filter friends based on search text + let filteredFriends = communityPageViewModel.friends.filter { friend in + + let matchesSearch: Bool if searchText.isEmpty { - return true + matchesSearch = true } else { - return friend.username.localizedCaseInsensitiveContains(searchText) || + matchesSearch = friend.username.localizedCaseInsensitiveContains(searchText) || (friend.name.localizedCaseInsensitiveContains(searchText) ?? false) } + + + let matchesFilter: Bool + switch selectedFilterOption { + case 0: + matchesFilter = friend.currentStatus.status == "free" + case 1: + matchesFilter = true + default: + matchesFilter = true + } + + return matchesSearch && matchesFilter } if filteredFriends.isEmpty { Spacer() - Text("No friends match your search") - .font(Font.custom("Poppins-Regular", size: 16)) - .foregroundColor(.white) + VStack(spacing: 5) { + if selectedFilterOption == 0 && !searchText.isEmpty { + Text("No available friends match your search") + } else if selectedFilterOption == 0 { + Text("No friends are currently available") + } else if !searchText.isEmpty { + Text("No friends match your search") + } else { + Text("You don't have any friends yet") + } + } + .font(Font.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + .multilineTextAlignment(.center) Spacer() } else { ScrollView { @@ -79,10 +107,10 @@ struct FriendsView: View { .padding(.horizontal) } .safeAreaPadding(.bottom, 100) - } } - }.refreshable { + } + .refreshable { communityPageViewModel.fetchFriendsData( from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", token: authViewModel.loggedInBackendUser?.token ?? "", diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index ed4da45..1e3dafb 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -10,8 +10,8 @@ import Foundation class Constants { static let url = -// "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" - "https://vitty-api.dscvit.com/api/v2/" +// "https://vitty-api.dscvit.com/api/v2/" } From 333411683aaec58c37b61ad902756a6627da5310 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Wed, 25 Jun 2025 10:08:33 +0545 Subject: [PATCH 05/16] feat: group creation endpoint impl --- VITTY/VITTY.xcodeproj/project.pbxproj | 8 + VITTY/VITTY/Connect/Models/CircleModel.swift | 37 +- .../View/Circles/Components/CirclesRow.swift | 100 ++-- .../View/Circles/Components/CreateGroup.swift | 407 +++++++++++-- .../View/Circles/Components/JoinGroup.swift | 538 +++++++++++++++--- .../View/Circles/Components/QrCode.swift | 141 +++++ .../View/Circles/View/CircleRequests.swift | 269 +++++++++ .../View/Circles/View/InsideCircle.swift | 187 +++++- VITTY/VITTY/Connect/View/ConnectPage.swift | 6 +- .../ViewModel/CommunityPageViewModel.swift | 215 ++++++- .../Utilities/Constants/APIConstants.swift | 6 + 11 files changed, 1726 insertions(+), 188 deletions(-) create mode 100644 VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift create mode 100644 VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 7603db9..9b13248 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -27,9 +27,11 @@ 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE72D7C78B300C9D801 /* Courses.swift */; }; 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; + 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */; }; 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */; }; 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */; }; 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; + 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; @@ -178,9 +180,11 @@ 4B183EE72D7C78B300C9D801 /* Courses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Courses.swift; sourceTree = ""; }; 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = ""; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = ""; }; + 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = ""; }; 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderNotifcationManager.swift; sourceTree = ""; }; 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingHotelView.swift; sourceTree = ""; }; 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCode.swift; sourceTree = ""; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = ""; }; 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = ""; }; @@ -489,6 +493,7 @@ 4B7DA5EB2D71E0F4007354A3 /* Components */ = { isa = PBXGroup; children = ( + 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */, 4BF0C79E2D94694000016202 /* InsideCircleCards.swift */, 4B7DA5E62D71AC51007354A3 /* CirclesRow.swift */, 4B7DA5F12D7228E5007354A3 /* JoinGroup.swift */, @@ -501,6 +506,7 @@ 4B7DA5EC2D71E0FB007354A3 /* View */ = { isa = PBXGroup; children = ( + 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B7DA5E42D70B2C8007354A3 /* Circles.swift */, 4BF0C79C2D94680A00016202 /* InsideCircle.swift */, ); @@ -1126,11 +1132,13 @@ 4BF0C79D2D94681000016202 /* InsideCircle.swift in Sources */, 524B84332B46EF3A006D18BD /* ConnectPage.swift in Sources */, 521E1E8B2C21DF0D00E8C7D2 /* AddFriendCardSearch.swift in Sources */, + 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */, 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */, 5238C7F12B4AAE8700413946 /* FriendRequestView.swift in Sources */, 528CF1782B769E64007298A0 /* TimeTableAPIService.swift in Sources */, 52D5AB972B6FFC8F00B2E66D /* LoginView.swift in Sources */, 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */, + 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */, 4BBB00312D955163003B8FE2 /* AcademicsViewModel.swift in Sources */, 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */, 314A409127383BEC0058082F /* ContentView.swift in Sources */, diff --git a/VITTY/VITTY/Connect/Models/CircleModel.swift b/VITTY/VITTY/Connect/Models/CircleModel.swift index 83d6642..3c6a764 100644 --- a/VITTY/VITTY/Connect/Models/CircleModel.swift +++ b/VITTY/VITTY/Connect/Models/CircleModel.swift @@ -5,6 +5,11 @@ // Created by Rujin Devkota on 3/25/25. // +//TODO: the Circle doesnt have image in the endpoint , the circle members dont have thier venu status currently in the endpoint + + + + import Foundation struct CircleModel: Decodable { @@ -23,7 +28,6 @@ struct CircleResponse: Decodable { let data: [CircleModel] } - struct CircleMember: Identifiable { let id = UUID() let picture: String @@ -32,29 +36,40 @@ struct CircleMember: Identifiable { let venue: String? } -import Foundation - - -// TEMP beacuse the endpoint has to contain the status and venue need to update the db -struct CircleUserTemp: Codable{ - +struct CircleUserTemp: Codable { let email: String let name: String let picture: String let username: String + let status: String? + let venue: String? enum CodingKeys: String, CodingKey { - case email, name, picture, username - + case email, name, picture, username, status, venue } } - struct CircleUserResponseTemp: Codable { let data: [CircleUserTemp] - enum CodingKeys: String , CodingKey{ + enum CodingKeys: String, CodingKey { case data } } +// MARK: - Request Models +struct CircleRequest: Codable, Identifiable { + let id = UUID() + let circle_id: String + let circle_name: String + let from_username: String + let to_username: String + + enum CodingKeys: String, CodingKey { + case circle_id, circle_name, from_username, to_username + } +} + +struct CircleRequestResponse: Codable { + let data: [CircleRequest] +} diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift index f6a7a07..e1881e8 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift @@ -8,8 +8,31 @@ import SwiftUI struct CirclesRow: View { - let circle: CircleModel + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + + + private var circleMembers: [CircleUserTemp] { + communityPageViewModel.circleMembers(for: circle.circleID) + } + + + private var busyCount: Int { + circleMembers.filter { + $0.status != nil && $0.status != "available" && $0.status != "free" + }.count + } + + private var availableCount: Int { + circleMembers.filter { + $0.status == nil || $0.status == "available" || $0.status == "free" + }.count + } + + private var isLoadingMembers: Bool { + communityPageViewModel.isLoadingCircleMembers(for: circle.circleID) + } var body: some View { HStack { @@ -21,36 +44,40 @@ struct CirclesRow: View { .font(Font.custom("Poppins-SemiBold", size: 18)) .foregroundColor(Color.white) - HStack{ - - Image("inclass").resizable().frame(width: 20,height: 20) - - Text("3 busy").foregroundStyle(Color("Accent")) - Spacer().frame(width: 20) - - Image("available").resizable().frame(width: 20,height: 20) - - Text("2 available").foregroundStyle(Color("Accent")) - - - - + if isLoadingMembers { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Loading...") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + + if busyCount > 0 { + Image("inclass").resizable().frame(width: 20, height: 20) + Text("\(busyCount) busy").foregroundStyle(Color("Accent")) + + if availableCount > 0 { + Spacer().frame(width: 20) + } + } + + + if availableCount > 0 { + Image("available").resizable().frame(width: 20, height: 20) + Text("\(availableCount) available").foregroundStyle(Color("Accent")) + } + + + if circleMembers.isEmpty && !isLoadingMembers { + Text("No members") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent").opacity(0.7)) + } + } } - - -// if friend.currentStatus.status == "free" { -// HStack { -// Image("available").resizable().frame(width: 20, height: 20) -// Text("Available").foregroundStyle(Color("Accent")) -// } -// } else { -// HStack { -// Image("inclass") -// Text(friend.currentStatus.venue ?? "") -// .font(Font.custom("Poppins-Regular", size: 14)) -// .foregroundColor(Color("Accent")) -// } -// } } Spacer() } @@ -59,11 +86,19 @@ struct CirclesRow: View { RoundedRectangle(cornerRadius: 15) .fill(Color("Secondary")) ) + .onAppear { + + communityPageViewModel.fetchCircleMemberData( + from: "\(APIConstants.base_url)circles/\(circle.circleID)", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: true, + circleID: circle.circleID + ) + } } - func cleanName(_ fullName: String) -> String { - let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" // + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" let regex = try? NSRegularExpression(pattern: pattern, options: []) let range = NSRange(location: 0, length: fullName.utf16.count) @@ -72,4 +107,3 @@ struct CirclesRow: View { return cleanedName } } - diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift index b538944..44ab789 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -1,4 +1,10 @@ +// +// Freinds.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. import SwiftUI +import Alamofire struct CreateGroup: View { let screenHeight = UIScreen.main.bounds.height @@ -8,7 +14,16 @@ struct CreateGroup: View { @State private var groupName: String = "" @State private var selectedImage: UIImage? = nil @State private var showImagePicker = false - @State private var selectedFriends: [String] = ["A", "B", "C", "D", "E"] + @State private var selectedFriends: [Friend] = [] + @State private var showFriendSelector = false + @State private var isCreatingGroup = false + @State private var showAlert = false + @State private var alertMessage = "" + + @Environment(CommunityPageViewModel.self) private var viewModel + let token: String + + @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 20) { @@ -24,7 +39,7 @@ struct CreateGroup: View { Spacer().frame(height: 20) - // Group Icon Picker + Button(action: { showImagePicker = true }) { @@ -80,7 +95,7 @@ struct CreateGroup: View { Spacer() Button(action: { - + showFriendSelector = true }) { Image(systemName: "person.badge.plus") .foregroundColor(.white) @@ -90,49 +105,116 @@ struct CreateGroup: View { } - HStack { - Spacer().frame(width : 90) - ZStack { - ForEach(Array(selectedFriends.prefix(3).enumerated()), id: \.element) { index, friend in - Circle() - .fill(Color.green.opacity(0.8)) - .frame(width: 40, height: 40) - .overlay(Text(friend).foregroundColor(.white)) - .offset(x: CGFloat(index * -25)) - } - } - Spacer() - if selectedFriends.count > 3 { - Text("+ \(selectedFriends.count - 3) more") + if selectedFriends.isEmpty { + + VStack { + Image(systemName: "person.2") + .font(.system(size: 30)) .foregroundColor(.gray) - .padding(.trailing, 20) + Text("No friends selected") + .foregroundColor(.gray) + .font(.system(size: 14)) } + .frame(width: screenWidth * 0.9, height: 80) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 2) + ) + .background(Color.black.opacity(0.3)) + .cornerRadius(12) + .padding(.horizontal, 20) + } else { - + HStack { + if selectedFriends.count <= 3 { + + Spacer() + HStack(spacing: -15) { + ForEach(Array(selectedFriends.enumerated()), id: \.element.username) { index, friend in + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.green.opacity(0.8)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(.system(size: 16, weight: .bold)) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + } + Spacer() + } else { + + Spacer().frame(width: 30) + HStack(spacing: -15) { + ForEach(Array(selectedFriends.prefix(3).enumerated()), id: \.element.username) { index, friend in + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.green.opacity(0.8)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(.system(size: 16, weight: .bold)) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + } + Spacer() + Text("+ \(selectedFriends.count - 3) more") + .foregroundColor(.gray) + .font(.system(size: 14)) + Spacer().frame(width: 20) + } + } + .frame(width: screenWidth * 0.9, height: 80) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 2) + ) + .background(Color.black.opacity(0.3)) + .cornerRadius(12) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + .onTapGesture { + showFriendSelector = true + } } - .frame(width: screenWidth * 0.9, height: 80).overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color("Accent"), lineWidth: 2) - ) - .background(Color.black.opacity(0.3)) - .cornerRadius(12) - .padding(.horizontal, 20) Spacer() - + HStack { Spacer() Button(action: { - + createGroup() }) { - Text("Cretae ") - .font(.system(size: 18, weight: .bold)).foregroundStyle(Color.black) - - .frame(width: 90, height: 40) - .background(Color("Accent")) - .cornerRadius(10) + HStack { + if isCreatingGroup { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } + Text(isCreatingGroup ? "Creating..." : "Create") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(Color.black) + } + .frame(width: 120, height: 40) + .background(groupName.isEmpty ? Color.gray : Color("Accent")) + .cornerRadius(10) } + .disabled(groupName.isEmpty || isCreatingGroup) .padding(.trailing, 20) } .padding(.bottom, 20) @@ -140,10 +222,263 @@ struct CreateGroup: View { } .presentationDetents([.height(screenHeight * 0.65)]) .background(Color("Secondary")) + .sheet(isPresented: $showFriendSelector) { + FriendSelectorView( + friends: viewModel.friends, + selectedFriends: $selectedFriends, + loadingFriends: viewModel.loadingFreinds + ) + } + .alert("Group Creation", isPresented: $showAlert) { + Button("OK") { + if alertMessage.contains("successfully") { + dismiss() + } + } + } message: { + Text(alertMessage) + } + } + + private func createGroup() { + guard !groupName.isEmpty else { return } + + isCreatingGroup = true + + + let createURL = "\(APIConstants.base_url)circles/create/\(groupName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? groupName)" + + AF.request(createURL, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: CreateCircleResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + + self.sendInvitations(circleId: data.circleId) + + case .failure(let error): + self.isCreatingGroup = false + self.alertMessage = "Failed to create group: \(error.localizedDescription)" + self.showAlert = true + } + } + } + } + + private func sendInvitations(circleId: String) { + guard !selectedFriends.isEmpty else { + + self.isCreatingGroup = false + self.alertMessage = "Group created successfully!" + self.showAlert = true + return + } + + let dispatchGroup = DispatchGroup() + var invitationResults: [String: Bool] = [:] + + for friend in selectedFriends { + dispatchGroup.enter() + + let inviteURL = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(friend.username)" + + AF.request(inviteURL, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + invitationResults[friend.username] = response.error == nil + dispatchGroup.leave() + } + } + } + + dispatchGroup.notify(queue: .main) { + self.isCreatingGroup = false + + let successCount = invitationResults.values.filter { $0 }.count + let totalCount = self.selectedFriends.count + + if successCount == totalCount { + self.alertMessage = "Group created successfully! All invitations sent." + } else if successCount > 0 { + self.alertMessage = "Group created successfully! \(successCount) out of \(totalCount) invitations sent." + } else { + self.alertMessage = "Group created successfully, but failed to send invitations." + } + + self.showAlert = true + } } } -#Preview { - CreateGroup(groupCode: .constant("")) + +struct FriendSelectorView: View { + let friends: [Friend] + @Binding var selectedFriends: [Friend] + let loadingFriends: Bool + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack { + if loadingFriends { + ProgressView("Loading friends...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.white) + } else if friends.isEmpty { + VStack { + Image(systemName: "person.2.slash") + .font(.system(size: 50)) + .foregroundColor(.gray) + Text("No friends found") + .font(.title2) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(friends, id: \.username) { friend in + FriendRowView( + friend: friend, + isSelected: selectedFriends.contains { $0.username == friend.username } + ) { isSelected in + if isSelected { + selectedFriends.append(friend) + } else { + selectedFriends.removeAll { $0.username == friend.username } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + } + .background(Color("Background")) + .navigationTitle("Select Friends") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + leading: Button(action: { + dismiss() + }) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + }, + trailing: Button(action: { + dismiss() + }) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + } + ) + } + .background(Color("Background")) + } } + +struct FriendRowView: View { + let friend: Friend + let isSelected: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack { + + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.blue.opacity(0.3)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(Font.custom("Poppins-SemiBold", size: 16)) + ) + } + .frame(width: 48, height: 48) + .clipShape(Circle()) + + Spacer().frame(width: 20) + + + VStack(alignment: .leading, spacing: 4) { + Text(cleanName(friend.name)) + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(Color.white) + + if friend.currentStatus.status == "free" { + HStack { + Image("available") + .resizable() + .frame(width: 20, height: 20) + Text("Available") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + Image("inclass") + .resizable() + .frame(width: 20, height: 20) + Text(friend.currentStatus.venue ?? "In Class") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + } + } + + Spacer() + + + Button(action: { + onToggle(!isSelected) + }) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? Color("Accent") : .gray) + .font(.system(size: 24)) + } + } + .padding() + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + ) + .contentShape(Rectangle()) + .onTapGesture { + onToggle(!isSelected) + } + } + + func cleanName(_ fullName: String) -> String { + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + let range = NSRange(location: 0, length: fullName.utf16.count) + let cleanedName = regex?.stringByReplacingMatches(in: fullName, options: [], range: range, withTemplate: "").trimmingCharacters(in: .whitespaces) ?? fullName + + return cleanedName + } +} + + +struct CreateCircleResponse: Decodable { + let circleId: String + let message: String + + enum CodingKeys: String, CodingKey { + case circleId = "circle_id" + case message + } +} + + diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index d8b79f2..e1d65c5 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -1,94 +1,500 @@ +// JoinGroup.swift +// VITTY +// +// Created by Rujin Devkota on 2/28/25. +// import SwiftUI import AVFoundation +import UIKit struct JoinGroup: View { let screenHeight = UIScreen.main.bounds.height let screenWidth = UIScreen.main.bounds.width - + @Binding var groupCode: String @State private var isScanning = false @State private var scannedCode: String = "" - + @State private var showingAlert = false + @State private var alertMessage = "" + @State private var isJoining = false + @State private var showToast = false + @State private var toastMessage = "" + @State private var circleName = "" + @State private var localGroupCode = "" + + @Environment(AuthViewModel.self) private var authViewModel + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(\.dismiss) private var dismiss + var body: some View { - VStack(spacing: 20) { - Capsule() - .fill(Color.gray.opacity(0.5)) - .frame(width: 50, height: 5) - .padding(.top, 10) - - Text("Join Group") - .font(.system(size: 21, weight: .bold)) - .foregroundColor(.white) - Spacer().frame(width: 20) - VStack(alignment: .leading, spacing: 10) { - Text("Enter group code") - .font(.system(size: 16, weight: .bold)) + ZStack { + VStack(spacing: 20) { + Capsule() + .fill(Color.gray.opacity(0.5)) + .frame(width: 50, height: 5) + .padding(.top, 10) + + Text("Join Circle") + .font(.system(size: 21, weight: .bold)) + .foregroundColor(.white) + + Spacer().frame(width: 20) + + VStack(alignment: .leading, spacing: 10) { + Text("Enter circle code") + .font(.system(size: 16, weight: .bold)) .foregroundColor(Color("Accent")) - - TextField("", text: $groupCode) - .padding() + + TextField("Enter circle code", text: $localGroupCode) + .padding() + .background(Color.black.opacity(0.3)) + .cornerRadius(8) + .foregroundColor(.white) + .onChange(of: localGroupCode) { oldValue, newValue in + let filtered = newValue.filter { $0.isLetter || $0.isNumber } + localGroupCode = filtered + groupCode = filtered + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) + ) + } + .padding(.horizontal, 20) + + HStack { + Rectangle() + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + Text("OR") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 10) + Rectangle() + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + } + .padding(.horizontal, 20) + + HStack { + Text("Scan QR Code") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(Color("Accent")) + .padding(.leading, 20) + Spacer() + } + + Button(action: { + isScanning = true + }) { + VStack { + if isScanning { + QRScannerView(scannedCode: $scannedCode, isScanning: $isScanning) + .frame(width: screenWidth * 0.8, height: screenHeight * 0.25) + } else { + Image(systemName: "qrcode.viewfinder") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .foregroundColor(Color.white) + + Text("Tap to scan QR code") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + } + .frame(width: screenWidth * 0.8, height: screenHeight * 0.25) .background(Color.black.opacity(0.3)) - .cornerRadius(8) - .foregroundColor(.white) + .cornerRadius(12) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 12) .stroke(Color.gray.opacity(0.5), lineWidth: 1) ) + } + .disabled(isJoining) + + Spacer() + + HStack { + Spacer() + Button(action: { + joinCircle() + }) { + HStack { + if isJoining { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(.white) + } + Text(isJoining ? "JOINING..." : "JOIN") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(isJoining ? .white : Color("Accent")) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isJoining ? Color.gray.opacity(0.5) : Color.clear) + ) + } + .disabled(isJoining || localGroupCode.isEmpty) + .padding(.trailing, 20) + } + .padding(.leading, 20) + .padding(.bottom, 20) } - .padding(.horizontal, 20) - + .presentationDetents([.height(screenHeight * 0.65)]) + .background(Color("Secondary")) + .onChange(of: scannedCode) { oldValue, newValue in + if !newValue.isEmpty { + handleScannedCode(newValue) + } + } + + if showToast { + VStack { + Spacer() + ToastView(message: toastMessage, isShowing: $showToast) + .padding(.bottom, 50) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .alert("Join Circle", isPresented: $showingAlert) { + Button("OK") { + if alertMessage.contains("successfully") || alertMessage.contains("requested") { + dismiss() + } + } + } message: { + Text(alertMessage) + } + .onOpenURL { url in + handleDeepLink(url) + } + .onAppear { + localGroupCode = groupCode + } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + + // MARK: - Handle Deep Link + private func handleDeepLink(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value else { + return + } + + let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value ?? "Unknown Circle" + + showJoinAlert(circleId: circleId, circleName: circleName) + } + + // MARK: - Show Join Alert + private func showJoinAlert(circleId: String, circleName: String) { + let alert = UIAlertController( + title: "Join Circle", + message: "Do you want to join '\(circleName)'?", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Join", style: .default) { _ in + self.localGroupCode = circleId + self.groupCode = circleId + self.circleName = circleName + self.joinCircle() + }) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + } + + private func handleScannedCode(_ code: String) { + if code.contains("vitty.app/invite") || code.contains("circleId=") { + if let components = URLComponents(string: code), + let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value { + localGroupCode = circleId + groupCode = circleId + if let name = components.queryItems?.first(where: { $0.name == "circleName" })?.value { + circleName = name + } + joinCircle() + } + } else { + localGroupCode = code + groupCode = code + joinCircle() + } + isScanning = false + } + + // MARK: - Join Circle + private func joinCircle() { + guard !localGroupCode.isEmpty, + let username = authViewModel.loggedInBackendUser?.username, + let token = authViewModel.loggedInBackendUser?.token else { + showToast(message: "Error: Unable to get user information", isError: true) + return + } + + if localGroupCode.count < 3 { + showToast(message: "Error: Circle code must be at least 3 characters", isError: true) + return + } + + isJoining = true + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + let urlString = "\(APIConstants.base_url)circles/sendRequest/\(localGroupCode)/\(username)" + guard let url = URL(string: urlString) else { + showToast(message: "Error: Invalid URL", isError: true) + isJoining = false + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isJoining = false + + if let error = error { + showToast(message: "Network error: \(error.localizedDescription)", isError: true) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + showToast(message: "Error: Invalid response", isError: true) + return + } + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + showToast(message: "Circle join request sent successfully! 🎉", isError: false) + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + + localGroupCode = "" + groupCode = "" + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + dismiss() + } + } else { + if let data = data, + let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = errorResponse["message"] as? String { + showToast(message: "Error: \(message)", isError: true) + } else { + switch httpResponse.statusCode { + case 400: + showToast(message: "Error: Invalid circle code", isError: true) + case 404: + showToast(message: "Error: Circle not found", isError: true) + case 409: + showToast(message: "Error: Already a member or request pending", isError: true) + default: + showToast(message: "Error: Failed to join circle (Code: \(httpResponse.statusCode))", isError: true) + } + } + } + } + }.resume() + } + + // MARK: - Show Toast + private func showToast(message: String, isError: Bool) { + toastMessage = message + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showToast = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showToast = false + } + } + } +} + + +// MARK: - Toast View +struct ToastView: View { + let message: String + @Binding var isShowing: Bool + + var body: some View { + if isShowing { HStack { - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) - Text("OR") - .font(.system(size: 16, weight: .bold)) + Text(message) + .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) - .padding(.horizontal, 10) - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) + .multilineTextAlignment(.center) } .padding(.horizontal, 20) - - HStack{ - Text("Scan Qr Code").font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")).padding(.leading,20) - Spacer() - } - - VStack { - Image(systemName: "qrcode.viewfinder") - .resizable() - .scaledToFit() - .frame(width: 100, height: 100) - .foregroundColor(Color.white) - } - .frame(width: screenWidth*0.8, height: screenHeight*0.25) - .background(Color.black.opacity(0.3)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray.opacity(0.5), lineWidth: 1) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(Color.black.opacity(0.8)) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) ) - - Spacer() - - HStack { - Spacer() - Text("JOIN") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")).padding(.trailing,20) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onTapGesture { + withAnimation { + isShowing = false + } } - .padding(.leading, 20) - .padding(.bottom, 20) + } + } +} + + +struct QRScannerView: UIViewControllerRepresentable { + @Binding var scannedCode: String + @Binding var isScanning: Bool + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, QRScannerDelegate { + let parent: QRScannerView + + init(_ parent: QRScannerView) { + self.parent = parent + } + + func didScanCode(_ code: String) { + parent.scannedCode = code + parent.isScanning = false } - .presentationDetents([.height(screenHeight * 0.65)]) - .background(Color("Secondary")) + func didFailWithError(_ error: Error) { + parent.isScanning = false + } } } -#Preview { - JoinGroup(groupCode: .constant("")) + +protocol QRScannerDelegate: AnyObject { + func didScanCode(_ code: String) + func didFailWithError(_ error: Error) +} + +class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + weak var delegate: QRScannerDelegate? + + private var captureSession: AVCaptureSession! + private var previewLayer: AVCaptureVideoPreviewLayer! + + override func viewDidLoad() { + super.viewDidLoad() + setupCamera() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if captureSession?.isRunning == false { + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if captureSession?.isRunning == true { + captureSession.stopRunning() + } + } + + private func setupCamera() { + captureSession = AVCaptureSession() + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -1, userInfo: [NSLocalizedDescriptionKey: "Camera not available"])) + return + } + + let videoInput: AVCaptureDeviceInput + + do { + videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) + } catch { + delegate?.didFailWithError(error) + return + } + + if captureSession.canAddInput(videoInput) { + captureSession.addInput(videoInput) + } else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -2, userInfo: [NSLocalizedDescriptionKey: "Could not add video input"])) + return + } + + let metadataOutput = AVCaptureMetadataOutput() + + if captureSession.canAddOutput(metadataOutput) { + captureSession.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr] + } else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -3, userInfo: [NSLocalizedDescriptionKey: "Could not add metadata output"])) + return + } + + previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.frame = view.layer.bounds + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + captureSession.stopRunning() + + if let metadataObject = metadataObjects.first { + guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } + guard let stringValue = readableObject.stringValue else { return } + + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + delegate?.didScanCode(stringValue) + } + } + + override var prefersStatusBarHidden: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift new file mode 100644 index 0000000..7f8c6ac --- /dev/null +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -0,0 +1,141 @@ +// +// QrCode.swift +// VITTY +// +// Created by Rujin Devkota on 6/24/25. +// + +import CoreImage.CIFilterBuiltins +import SwiftUI + + +struct QRCodeModalView: View { + let groupCode: String + let circleName: String + let onDismiss: () -> Void + + @State private var showingShareSheet = false + + var body: some View { + VStack { + Spacer() + VStack(spacing: 20) { + + HStack { + Text("Circle QR Code") + .font(.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(.white) + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark") + .foregroundColor(.white) + .font(.system(size: 18)) + } + } + + + VStack(spacing: 8) { + Text(circleName) + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Circle ID: \(groupCode)") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.gray) + } + + + if let qrImage = generateQRCode(from: createInvitationLink()) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .background(Color.white) + .cornerRadius(12) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 200, height: 200) + .cornerRadius(12) + .overlay( + Text("QR Code\nGeneration Failed") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + ) + } + + + Text("Share this code for others to join your circle") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal) + + + Button(action: { + showingShareSheet = true + }) { + HStack { + Image(systemName: "square.and.arrow.up") + Text("Share Invitation") + } + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color("Accent")) + .cornerRadius(8) + } + } + .frame(maxWidth: 300) + .padding(24) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $showingShareSheet) { + ShareSheet(items: [createInvitationLink(), "Join my circle '\(circleName)' on VITTY!"]) + } + } + + private func createInvitationLink() -> String { + + let baseURL = "https://vitty.app/invite" + let encodedCircleName = circleName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? circleName + return "\(baseURL)/circles/sendRequest/\(groupCode)&circleName=\(encodedCircleName)" + } + + private func generateQRCode(from string: String) -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + + filter.message = Data(string.utf8) + + if let outputImage = filter.outputImage { + let scaleX = 200 / outputImage.extent.size.width + let scaleY = 200 / outputImage.extent.size.height + let transformedImage = outputImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) + + if let cgImage = context.createCGImage(transformedImage, from: transformedImage.extent) { + return UIImage(cgImage: cgImage) + } + } + return nil + } +} + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift new file mode 100644 index 0000000..90ee5b7 --- /dev/null +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift @@ -0,0 +1,269 @@ +// +// CircleRequests.swift +// VITTY +// +// Created by Rujin Devkota on 6/24/25. +// + + + +import SwiftUI + +struct CircleRequestRow: View { + let request: CircleRequest + let onAccept: () -> Void + let onDecline: () -> Void + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + + var body: some View { + HStack { + UserImage(url: "https://picsum.photos/200/300", height: 48, width: 48) + + Spacer().frame(width: 16) + + VStack(alignment: .leading, spacing: 4) { + Text("@\(request.from_username)") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text("wants to join \(request.circle_name)") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + .lineLimit(2) + } + + Spacer() + + if communityPageViewModel.loadingRequestAction { + ProgressView() + .scaleEffect(0.8) + .padding(.trailing) + } else { + HStack(spacing: 8) { + Button(action: onDecline) { + Image(systemName: "xmark") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color.red.opacity(0.8)) + .cornerRadius(18) + } + + Button(action: onAccept) { + Image(systemName: "checkmark") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color.green.opacity(0.8)) + .cornerRadius(18) + } + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + ) + .animation(.easeInOut(duration: 0.2), value: communityPageViewModel.loadingRequestAction) + } +} + +struct CircleRequestsView: View { + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.presentationMode) var presentationMode + + @State private var showSuccessAlert = false + @State private var alertMessage = "" + @State private var searchText = "" + + private var filteredRequests: [CircleRequest] { + if searchText.isEmpty { + return communityPageViewModel.circleRequests + } else { + return communityPageViewModel.circleRequests.filter { request in + request.from_username.localizedCaseInsensitiveContains(searchText) || + request.circle_name.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.system(size: 18)) + } + + Spacer() + + Text("Group Requests") + .font(.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(.white) + + Spacer() + + + Button(action: { + refreshRequests() + }) { + Image(systemName: "arrow.clockwise") + .foregroundColor(.white) + .font(.system(size: 16)) + } + } + .padding() + + + SearchBar(searchText: $searchText) + .padding(.horizontal) + + Spacer().frame(height: 16) + + + if communityPageViewModel.loadingCircleRequests { + Spacer() + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.2) + Text("Loading requests...") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + Spacer() + + } else if communityPageViewModel.errorCircleRequests { + Spacer() + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(.red) + + Text("Failed to load requests") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text("Please try again") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + + Button(action: refreshRequests) { + Text("Retry") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color("Accent")) + .cornerRadius(20) + } + .padding(.top, 8) + } + Spacer() + + } else if filteredRequests.isEmpty { + Spacer() + VStack(spacing: 12) { + Image(systemName: searchText.isEmpty ? "person.2" : "magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(Color("Accent")) + + Text(searchText.isEmpty ? "No pending requests" : "No matching requests") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text(searchText.isEmpty ? + "You're all caught up! No one is waiting to join your circles." : + "Try adjusting your search terms") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + Spacer() + + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(filteredRequests, id: \.id) { request in + CircleRequestRow( + request: request, + onAccept: { + acceptRequest(request) + }, + onDecline: { + declineRequest(request) + } + ) + } + } + .padding(.horizontal) + .padding(.bottom, 100) + } + } + + Spacer() + } + .background(Color("Background").edgesIgnoringSafeArea(.all)) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .onAppear { + refreshRequests() + } + .refreshable { + refreshRequests() + } + .alert("Request Processed", isPresented: $showSuccessAlert) { + Button("OK") { } + } message: { + Text(alertMessage) + } + } + + private func refreshRequests() { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + communityPageViewModel.fetchCircleRequests(token: token) + } + + private func acceptRequest(_ request: CircleRequest) { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + + communityPageViewModel.acceptCircleRequest(circleId: request.circle_id, token: token) { success in + if success { + alertMessage = "@\(request.from_username) has been added to \(request.circle_name)" + showSuccessAlert = true + + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + } + } else { + alertMessage = "Failed to accept the request. Please try again." + showSuccessAlert = true + } + } + } + + private func declineRequest(_ request: CircleRequest) { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + + communityPageViewModel.declineCircleRequest(circleId: request.circle_id, token: token) { success in + if success { + alertMessage = "Request from @\(request.from_username) has been declined" + showSuccessAlert = true + } else { + alertMessage = "Failed to decline the request. Please try again." + showSuccessAlert = true + } + } + } +} diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index a99e29d..e7159be 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -4,7 +4,6 @@ // // Created by Rujin Devkota on 3/26/25. - import SwiftUI struct LeaveCircleAlert: View { @@ -59,14 +58,97 @@ struct LeaveCircleAlert: View { } } +struct CircleMenuView: View { + let circleName: String + let onLeaveGroup: () -> Void + let onGroupRequests: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 0) { + Button(action: { + onCancel() + onLeaveGroup() + }) { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + .foregroundColor(.red) + Text("Leave Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.red) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + onGroupRequests() + }) { + HStack { + Image(systemName: "person.badge.plus") + .foregroundColor(.white) + Text("Group Requests") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Background")) + } + } + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + struct InsideCircle: View { var circleName : String var groupCode: String @State var searchText: String = "" @State var showLeaveAlert: Bool = false + @State var showCircleMenu: Bool = false + @State var showGroupRequests : Bool = false @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(AuthViewModel.self) private var authViewModel @Environment(\.presentationMode) var presentationMode + @State var showQRCode: Bool = false + + + private var busyCount: Int { + communityPageViewModel.circleMembers.filter { + $0.status != nil && $0.status != "available" && $0.status != "free" + }.count + } + + private var availableCount: Int { + communityPageViewModel.circleMembers.filter { + $0.status == nil || $0.status == "available" || $0.status == "free" + }.count + } var body: some View { VStack(spacing: 0) { @@ -83,10 +165,11 @@ struct InsideCircle: View { .foregroundColor(.white) Spacer() Button(action: { - showLeaveAlert = true + showCircleMenu = true }) { - Image(systemName: "rectangle.portrait.and.arrow.right") + Image(systemName: "ellipsis") .foregroundColor(.white) + .font(.system(size: 18)) } } .padding() @@ -101,31 +184,52 @@ struct InsideCircle: View { .font(.custom("Poppins-SemiBold", size: 20)) .foregroundColor(.white) Spacer() - Text(groupCode) - .font(.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) + } Spacer().frame(height: 5) HStack { - HStack { - Image("inclass").resizable().frame(width: 18, height: 18) - Text("3 busy") - .foregroundStyle(Color("Accent")) + // Dynamic busy count + if busyCount > 0 { + HStack { + Image("inclass").resizable().frame(width: 18, height: 18) + Text("\(busyCount) busy") + .foregroundStyle(Color("Accent")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color("Secondary")) + .cornerRadius(12) + + Spacer().frame(width: 10) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color("Secondary")) - .cornerRadius(12) - Spacer().frame(width: 10) - HStack { - Image("available").resizable().frame(width: 18, height: 18) - Text("2 available") - .foregroundStyle(Color("Accent")) + + // Dynamic available count + if availableCount > 0 { + HStack { + Image("available").resizable().frame(width: 18, height: 18) + Text("\(availableCount) available") + .foregroundStyle(Color("Accent")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color("Secondary")) + .cornerRadius(12) + } + + Spacer() + + + Button(action: { + showQRCode = true + print("QR Code tapped") + }) { + Image(systemName: "qrcode") + .foregroundColor(Color("Accent")) + .font(.system(size: 20)) + .padding(8) + .background(Color("Secondary")) + .cornerRadius(8) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color("Secondary")) - .cornerRadius(12) } } .padding() @@ -139,12 +243,12 @@ struct InsideCircle: View { } else { ScrollView { VStack(spacing: 10) { - ForEach(communityPageViewModel.circleMembers, id: \ .username) { member in + ForEach(communityPageViewModel.circleMembers, id: \.username) { member in InsideCircleRow( picture: member.picture, name: member.name, - status: "free", - venue: "318" + status: member.status ?? "free", + venue: member.venue ?? "available" ) .padding(.horizontal) } @@ -154,7 +258,9 @@ struct InsideCircle: View { } Spacer() } - .background(Color("Background").edgesIgnoringSafeArea(.all)) + .background(Color("Background").edgesIgnoringSafeArea(.all)).sheet(isPresented: $showGroupRequests, content: { + CircleRequestsView() + }) .onAppear { communityPageViewModel.fetchCircleMemberData( from: "\(APIConstants.base_url)circles/\(groupCode)", @@ -173,16 +279,39 @@ struct InsideCircle: View { communityPageViewModel.leaveCircle(from: url, token: token) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showLeaveAlert = false presentationMode.wrappedValue.dismiss() } }) } + + if showCircleMenu { + CircleMenuView( + circleName: circleName, + onLeaveGroup: { + showLeaveAlert = true + }, + onGroupRequests: { + showGroupRequests = true + print("Navigate to Circle Requests") + }, + onCancel: { + showCircleMenu = false + } + ) + } + if showQRCode { + QRCodeModalView( + groupCode: groupCode, + circleName: circleName, + onDismiss: { + showQRCode = false + } + ) + } } ) - .navigationBarHidden(true) .navigationBarBackButtonHidden(true) } diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 3ec7bc7..48605ce 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -6,7 +6,7 @@ import SwiftUI -// Enum to manage different sheet types + enum SheetType: Identifiable { case addCircleOptions case createGroup @@ -92,7 +92,7 @@ struct ConnectPage: View { case .addCircleOptions: AddCircleOptionsView(activeSheet: $activeSheet) case .createGroup: - CreateGroup(groupCode: .constant("")) + CreateGroup(groupCode: .constant(""), token:authViewModel.loggedInBackendUser?.token ?? "" ) case .joinGroup: JoinGroup(groupCode: .constant("")) } @@ -117,7 +117,7 @@ struct ConnectPage: View { } } -// Separate view for the add circle options sheet + struct AddCircleOptionsView: View { @Binding var activeSheet: SheetType? @Environment(\.dismiss) private var dismiss diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 24fbeb9..af4ef8b 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -10,19 +10,27 @@ import Alamofire import OSLog - @Observable class CommunityPageViewModel { var friends = [Friend]() var circles = [CircleModel]() + var circleRequests = [CircleRequest]() + var loadingFreinds = false var loadingCircle = false var loadingCircleMembers = false + var loadingCircleRequests = false + var loadingRequestAction = false var errorFreinds = false var errorCircle = false var errorCircleMembers = false + var errorCircleRequests = false + var circleMembers = [CircleUserTemp]() + + var circleMembersDict: [String: [CircleUserTemp]] = [:] + var loadingCircleMembersDict: [String: Bool] = [:] private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, @@ -72,9 +80,94 @@ class CommunityPageViewModel { } } } - //MARK : Circle Members NetwrokCall - func fetchCircleMemberData(from url: String, token: String, loading: Bool) { - self.loadingCircleMembers = loading + + // MARK: - Circle Requests + + func fetchCircleRequests(token: String) { + self.loadingCircleRequests = true + self.errorCircleRequests = false + + let url = "\(APIConstants.base_url)circles/requests/received" + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: CircleRequestResponse.self) { response in + DispatchQueue.main.async { + self.loadingCircleRequests = false + + switch response.result { + case .success(let data): + self.circleRequests = data.data + self.logger.info("Successfully fetched circle requests: \(data.data.count) requests") + + case .failure(let error): + self.logger.error("Error fetching circle requests: \(error)") + self.errorCircleRequests = true + } + } + } + } + + func acceptCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { + self.loadingRequestAction = true + + let url = "\(APIConstants.base_url)circles/acceptRequest/\(circleId)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + self.loadingRequestAction = false + + switch response.result { + case .success: + self.logger.info("Successfully accepted circle request for circle: \(circleId)") + + self.circleRequests.removeAll { $0.circle_id == circleId } + completion(true) + + case .failure(let error): + self.logger.error("Error accepting circle request: \(error)") + completion(false) + } + } + } + } + + func declineCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { + self.loadingRequestAction = true + + let url = "\(APIConstants.base_url)circles/declineRequest/\(circleId)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + self.loadingRequestAction = false + + switch response.result { + case .success: + self.logger.info("Successfully declined circle request for circle: \(circleId)") + + self.circleRequests.removeAll { $0.circle_id == circleId } + completion(true) + + case .failure(let error): + self.logger.error("Error declining circle request: \(error)") + completion(false) + } + } + } + } + + func fetchCircleMemberData(from url: String, token: String, loading: Bool, circleID: String? = nil) { + if let circleID = circleID { + + self.loadingCircleMembersDict[circleID] = loading + } else { + + self.loadingCircleMembers = loading + } AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() @@ -84,18 +177,33 @@ class CommunityPageViewModel { switch response.result { case .success(let data): - self.circleMembers = data.data - self.loadingCircleMembers = false - print(data.data) + if let circleID = circleID { + + self.circleMembersDict[circleID] = data.data + self.loadingCircleMembersDict[circleID] = false + } else { + + self.circleMembers = data.data + self.loadingCircleMembers = false + } + + print(data.data) print("Successfully fetched circles members :") print(data.data) + case .failure(let error): self.logger.error("Error fetching circles members: \(error)") - self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + + if let circleID = circleID { + self.loadingCircleMembersDict[circleID] = false + } else { + self.loadingCircleMembers = false + self.errorCircleMembers.toggle() + } } } } + //MARK : Circle Leave func fetchCircleLeave(from url: String, token: String, loading: Bool) { self.loadingCircleMembers = loading @@ -143,6 +251,93 @@ class CommunityPageViewModel { } } } - + // MARK: Helper methods for circle members + + func circleMembers(for circleID: String) -> [CircleUserTemp] { + return circleMembersDict[circleID] ?? [] + } + + func isLoadingCircleMembers(for circleID: String) -> Bool { + return loadingCircleMembersDict[circleID] ?? false + } + + func clearCircleMembers(for circleID: String) { + circleMembersDict.removeValue(forKey: circleID) + loadingCircleMembersDict.removeValue(forKey: circleID) + } + // MARK: - Group Creation + + func createCircle(name: String, token: String, completion: @escaping (Result) -> Void) { + let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name + let url = "\(APIConstants.base_url)circles/create/\(encodedName)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseJSON { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + if let json = data as? [String: Any], + let circleId = json["circle_id"] as? String { + self.logger.info("Successfully created circle: \(circleId)") + completion(.success(circleId)) + } else { + // Try to extract circle_id from different response format + if let json = data as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let circleId = dataDict["id"] as? String { + self.logger.info("Successfully created circle: \(circleId)") + completion(.success(circleId)) + } else { + let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + completion(.failure(error)) + } + } + + case .failure(let error): + self.logger.error("Error creating circle: \(error)") + completion(.failure(error)) + } + } + } + } + + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + switch response.result { + case .success: + self.logger.info("Successfully sent invitation to \(username) for circle \(circleId)") + completion(true) + + case .failure(let error): + self.logger.error("Error sending invitation to \(username): \(error)") + completion(false) + } + } + } + } + + func sendMultipleInvitations(circleId: String, usernames: [String], token: String, completion: @escaping ([String: Bool]) -> Void) { + let dispatchGroup = DispatchGroup() + var results: [String: Bool] = [:] + + for username in usernames { + dispatchGroup.enter() + + sendCircleInvitation(circleId: circleId, username: username, token: token) { success in + results[username] = success + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + completion(results) + } + } } diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 8b1202d..8236c5a 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -9,4 +9,10 @@ import Foundation struct APIConstants { static let base_url = "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + static let createCircle = "circles/create/" + static let sendRequest = "circles/sendRequest/" + static let acceptRequest = "circles/acceptRequest/" + static let declineRequest = "circles/declineRequest/" + static let circleRequests = "circles/requests/received" + static let friends = "friends" } From acbbda03c6331adcc772aade0a2776d333fcf84c Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Wed, 25 Jun 2025 11:13:59 +0545 Subject: [PATCH 06/16] feat: improved navigation of notes --- VITTY/VITTY/Academics/View/CourseRefs.swift | 304 ++++++++++++++++-- VITTY/VITTY/Academics/View/Notes.swift | 17 +- .../AddFriends/View/AddFriendsView.swift | 4 +- .../Connect/Search/Views/SearchView.swift | 2 +- .../View/Circles/View/CircleRequests.swift | 2 +- .../View/Circles/View/InsideCircle.swift | 2 +- 6 files changed, 289 insertions(+), 42 deletions(-) diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index 2492d9d..9832fe1 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -1,3 +1,9 @@ +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + import SwiftUI import SwiftData @@ -11,15 +17,20 @@ struct OCourseRefs: View { @State private var showReminderSheet = false @State private var showNotes = false @State private var navigateToNotesEditor = false - @State var showCourseNotes : Bool = false + @State var showCourseNotes: Bool = false @State private var selectedNote: CreateNoteModel? @State private var preloadedAttributedString: NSAttributedString? + @State private var searchText = "" + @State private var showDeleteAlert = false + @State private var noteToDelete: CreateNoteModel? + @State private var isLoadingNote = false + @State private var loadingNoteId: Date? @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext private let maxVisible = 4 - @Query private var filteredRemainders: [Remainder] @Query private var courseNotes: [CreateNoteModel] @@ -44,6 +55,16 @@ struct OCourseRefs: View { ) } + private var filteredNotes: [CreateNoteModel] { + if searchText.isEmpty { + return courseNotes + } else { + return courseNotes.filter { note in + note.noteName.localizedCaseInsensitiveContains(searchText) + } + } + } + var body: some View { NavigationStack { ZStack(alignment: .bottom) { @@ -66,19 +87,18 @@ struct OCourseRefs: View { .foregroundColor(.white) Spacer() - - } .padding() HStack { Spacer() - TextField("Search", text: .constant("")) + TextField("Search notes...", text: $searchText) .padding(10) .frame(width: UIScreen.main.bounds.width * 0.85) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) + .foregroundColor(.white) Spacer() } Spacer().frame(height: 20) @@ -107,22 +127,39 @@ struct OCourseRefs: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 15) { - if courseNotes.isEmpty { - Text("No notes found for this course") - .foregroundColor(.gray) - .padding() + if filteredNotes.isEmpty { + VStack(spacing: 16) { + Image(systemName: searchText.isEmpty ? "doc.text" : "magnifyingglass") + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text(searchText.isEmpty ? "No notes found for this course" : "No notes match your search") + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + .multilineTextAlignment(.center) + + if !searchText.isEmpty { + Text("Try searching with different keywords") + .foregroundColor(.gray.opacity(0.8)) + .font(.system(size: 14)) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 60) } else { - ForEach(courseNotes, id: \.createdAt) { note in + ForEach(filteredNotes, id: \.createdAt) { note in CourseCardNotes( title: note.noteName, - description: note.cachedPlainText + description: note.cachedPlainText, + isLoading: loadingNoteId == note.createdAt, + onDelete: { + noteToDelete = note + showDeleteAlert = true + } ) .onTapGesture { - selectedNote = note - - Task { - preloadedAttributedString = note.cachedAttributedString - } + openNote(note) } } } @@ -149,6 +186,21 @@ struct OCourseRefs: View { .padding(.bottom, 30) } } + + + if showDeleteAlert { + DeleteNoteAlert( + noteName: noteToDelete?.noteName ?? "", + onCancel: { + showDeleteAlert = false + noteToDelete = nil + }, + onDelete: { + deleteNote() + } + ) + .zIndex(1) + } } .onAppear { print("this is course code") @@ -184,20 +236,159 @@ struct OCourseRefs: View { } .navigationDestination(isPresented: $navigateToNotesEditor) { - NoteEditorView(courseCode: courseCode, courseName: courseName) + NoteEditorView(courseCode: courseCode, courseName: courseName, courseIns: courseInstitution, courseSlot: slot) } - .navigationDestination(item: $selectedNote) { note in - NoteEditorView( - existingNote: note, + .sheet(isPresented: $showNotes, content: { + NoteEditorView( + existingNote: selectedNote, preloadedAttributedString: preloadedAttributedString, courseCode: courseCode, - courseName: courseName + courseName: courseName, + courseIns: courseInstitution, + courseSlot: slot ) + }) + } + } + + // MARK: - Note Loading Function + private func openNote(_ note: CreateNoteModel) { + guard !isLoadingNote else { return } + + + isLoadingNote = true + loadingNoteId = note.createdAt + + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + + Task { @MainActor in + do { + let attributedString = try await loadNoteContent(note) + + + selectedNote = note + preloadedAttributedString = attributedString + + + try await Task.sleep(nanoseconds: 300_000_000) + + + isLoadingNote = false + loadingNoteId = nil + showNotes = true + + } catch { + print("Error loading note: \(error)") + isLoadingNote = false + loadingNoteId = nil + + + let errorFeedback = UINotificationFeedbackGenerator() + errorFeedback.notificationOccurred(.error) } } } + + @MainActor + private func loadNoteContent(_ note: CreateNoteModel) async throws -> NSAttributedString { + + if let cachedAttributedString = note.cachedAttributedString { + return cachedAttributedString + } + + + guard let data = Data(base64Encoded: note.noteContent) else { + throw NoteLoadingError.invalidData + } + + + if let attributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { + return attributedString + } else { + throw NoteLoadingError.unarchiveFailed + } + } + + private func deleteNote() { + guard let note = noteToDelete else { return } + + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + + modelContext.delete(note) + + + do { + try modelContext.save() + } catch { + print("Failed to delete note: \(error)") + } + + showDeleteAlert = false + noteToDelete = nil + } } +// MARK: - Error Handling +enum NoteLoadingError: Error { + case invalidData + case unarchiveFailed +} + +struct DeleteNoteAlert: View { + let noteName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete note?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete '\(noteName)'?") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} struct BottomSheetButton: View { var icon: String @@ -254,6 +445,7 @@ struct TagView: View { } } } + struct MoreTagView: View { var count: Int @@ -272,23 +464,69 @@ struct MoreTagView: View { struct CourseCardNotes: View { var title: String var description: String - + var isLoading: Bool = false + var onDelete: () -> Void + + @State private var showComingSoonAlert = false + var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.headline) - .foregroundColor(.white) - .padding(.bottom, 5) - - Text(description) - .font(.subheadline) - .foregroundColor(.gray) - .lineLimit(2) + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(description) + .font(.subheadline) + .foregroundColor(.gray) + .lineLimit(2) + } + + Spacer() + + if isLoading { + + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + .padding(.trailing, 8) + } else { + Menu { + Button(role: .destructive) { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + showComingSoonAlert = true + } label: { + Label("Export Markdown", systemImage: "square.and.arrow.down") + } + + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundColor(.white) + .font(.system(size: 20, weight: .medium)) + .padding(8) + .clipShape(Circle()) + } + } } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.2)) .cornerRadius(15) + .opacity(isLoading ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isLoading) + .alert("Feature coming soon", isPresented: $showComingSoonAlert) { + Button("OK", role: .cancel) { } + } } } @@ -307,5 +545,3 @@ struct RoundedCorner: Shape { return Path(path.cgPath) } } - - diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index 25adeb3..bd09353 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -1,3 +1,9 @@ +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + import SwiftUI import UIKit @@ -134,16 +140,21 @@ struct NoteEditorView: View { @State private var isEmpty = true @State private var hasUnsavedChanges = false @State private var isInitialized = false + @State private var goback = false @Environment(\.modelContext) private var modelContext let courseCode: String let courseName: String + let courseIns : String + let courseSlot : String - init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String) { + init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String,courseIns: String , courseSlot: String) { self.existingNote = existingNote self.preloadedAttributedString = preloadedAttributedString self.courseCode = existingNote?.courseId ?? courseCode self.courseName = existingNote?.courseName ?? courseName + self.courseIns = courseIns + self.courseSlot = courseSlot } private func handleBackNavigation() { @@ -319,7 +330,7 @@ struct NoteEditorView: View { HStack { Button(action: { handleBackNavigation() }) { Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) + .foregroundColor(Color("Accent")).font(.title2) } Spacer() Text("Note") @@ -495,7 +506,7 @@ struct NoteEditorView: View { } } - // MARK: - Text Formatting Functions (Rest of the formatting functions remain the same) + func addBulletPoints() { guard selectedRange.length > 0 else { return } diff --git a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift index 6a68880..4da5572 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift @@ -24,7 +24,7 @@ struct AddFriendsView: View { VStack(alignment: .leading) { Button(action: {dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) + .foregroundColor(Color("Accent")).font(.title2) } if !suggestedFriendsViewModel.suggestedFriends.isEmpty || !friendRequestViewModel.requests.isEmpty @@ -73,7 +73,7 @@ struct AddFriendsView: View { HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) + .foregroundColor(Color("Accent")).font(.title2) } Spacer() Text("Note") diff --git a/VITTY/VITTY/Connect/Search/Views/SearchView.swift b/VITTY/VITTY/Connect/Search/Views/SearchView.swift index 0a2166a..ade68f8 100644 --- a/VITTY/VITTY/Connect/Search/Views/SearchView.swift +++ b/VITTY/VITTY/Connect/Search/Views/SearchView.swift @@ -85,7 +85,7 @@ struct SearchView: View { HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) + .foregroundColor(Color("Accent")).font(.title2) } Spacer() Text("Search") diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift index 90ee5b7..2fbcf4a 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift @@ -98,7 +98,7 @@ struct CircleRequestsView: View { }) { Image(systemName: "chevron.left") .foregroundColor(.white) - .font(.system(size: 18)) + .font(.title2) } Spacer() diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index e7159be..a9170df 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -157,7 +157,7 @@ struct InsideCircle: View { presentationMode.wrappedValue.dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(.white) + .foregroundColor(.white).font(.title2) } Spacer() Text("Circle") From 0fe9bd1e3141d33e864fad69bd12bce921b68d0a Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Wed, 25 Jun 2025 11:24:14 +0545 Subject: [PATCH 07/16] feat: improved loading states in circle and friends page --- .../Icons/Frame 10.imageset/Contents.json | 21 ++ .../Icons/Frame 10.imageset/Frame 10.svg | 23 ++ VITTY/VITTY/Connect/View/ConnectPage.swift | 51 ++- .../ViewModel/CommunityPageViewModel.swift | 323 ++++++++++-------- 4 files changed, 263 insertions(+), 155 deletions(-) create mode 100644 VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json create mode 100644 VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg diff --git a/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json new file mode 100644 index 0000000..ad8b1af --- /dev/null +++ b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Frame 10.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg new file mode 100644 index 0000000..964e950 --- /dev/null +++ b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 48605ce..e3845f0 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -4,9 +4,10 @@ // // Created by Rujin Devkota on 2/27/25. -import SwiftUI +import SwiftUI + enum SheetType: Identifiable { case addCircleOptions case createGroup @@ -34,6 +35,7 @@ struct ConnectPage: View { @State private var isAddFriendsViewPresented = false @State private var selectedTab = 0 + @State private var hasLoadedInitialData = false var body: some View { ZStack { @@ -86,7 +88,7 @@ struct ConnectPage: View { .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } } - // Single sheet modifier handling all sheet presentations + .sheet(item: $activeSheet) { sheetType in switch sheetType { case .addCircleOptions: @@ -98,26 +100,39 @@ struct ConnectPage: View { } } .onAppear { - communityPageViewModel.fetchFriendsData( - from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)requests/")!, - authToken: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) + + let shouldShowLoading = !hasLoadedInitialData + + + if communityPageViewModel.friends.isEmpty || !hasLoadedInitialData { + communityPageViewModel.fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + if communityPageViewModel.circles.isEmpty || !hasLoadedInitialData { + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + if communityPageViewModel.circleRequests.isEmpty || !hasLoadedInitialData { + friendRequestViewModel.fetchFriendRequests( + from: URL(string: "\(APIConstants.base_url)requests/")!, + authToken: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + hasLoadedInitialData = true } } } - struct AddCircleOptionsView: View { @Binding var activeSheet: SheetType? @Environment(\.dismiss) private var dismiss diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index af4ef8b..9139610 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -4,12 +4,12 @@ // // Created by Chandram Dutta on 04/01/24. // +// import Foundation import Alamofire import OSLog - @Observable class CommunityPageViewModel { var friends = [Friend]() @@ -37,54 +37,78 @@ class CommunityPageViewModel { category: String(describing: CommunityPageViewModel.self) ) - func fetchFriendsData(from url: String, token: String, loading: Bool) { - self.loadingFreinds = loading + func fetchFriendsData(from url: String, token: String, loading: Bool = false) { + + if loading || friends.isEmpty { + self.loadingFreinds = true + } + + + self.errorFreinds = false + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: FriendRaw.self) { response in - - switch response.result { + DispatchQueue.main.async { + self.loadingFreinds = false + + switch response.result { case .success(let data): self.friends = data.data - self.loadingFreinds = false - - + self.errorFreinds = false + case .failure(let error): - self.logger.error("Error fetching data: \(error)") - self.loadingFreinds = false - self.errorFreinds.toggle() + self.logger.error("Error fetching friends: \(error)") + + if self.friends.isEmpty { + self.errorFreinds = true + } + } } } } //MARK: Circle DATA - func fetchCircleData(from url: String, token: String, loading: Bool) { - self.loadingCircle = loading + func fetchCircleData(from url: String, token: String, loading: Bool = false) { + + if loading || circles.isEmpty { + self.loadingCircle = true + } + + + self.errorCircle = false + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleResponse.self) { response in - print("***********") - print(response) - switch response.result { + DispatchQueue.main.async { + self.loadingCircle = false + + switch response.result { case .success(let data): self.circles = data.data - self.loadingCircle = false - print(data.data) - print("Successfully fetched circles:") - print(data.data) + self.errorCircle = false + print("Successfully fetched circles: \(data.data)") + case .failure(let error): self.logger.error("Error fetching circles: \(error)") - self.loadingCircle = false - self.errorCircle.toggle() + + if self.circles.isEmpty { + self.errorCircle = true + } + } } } } // MARK: - Circle Requests - func fetchCircleRequests(token: String) { - self.loadingCircleRequests = true + func fetchCircleRequests(token: String, loading: Bool = false) { + if loading || circleRequests.isEmpty { + self.loadingCircleRequests = true + } + self.errorCircleRequests = false let url = "\(APIConstants.base_url)circles/requests/received" @@ -98,11 +122,14 @@ class CommunityPageViewModel { switch response.result { case .success(let data): self.circleRequests = data.data + self.errorCircleRequests = false self.logger.info("Successfully fetched circle requests: \(data.data.count) requests") case .failure(let error): self.logger.error("Error fetching circle requests: \(error)") - self.errorCircleRequests = true + if self.circleRequests.isEmpty { + self.errorCircleRequests = true + } } } } @@ -122,7 +149,6 @@ class CommunityPageViewModel { switch response.result { case .success: self.logger.info("Successfully accepted circle request for circle: \(circleId)") - self.circleRequests.removeAll { $0.circle_id == circleId } completion(true) @@ -148,7 +174,6 @@ class CommunityPageViewModel { switch response.result { case .success: self.logger.info("Successfully declined circle request for circle: \(circleId)") - self.circleRequests.removeAll { $0.circle_id == circleId } completion(true) @@ -160,71 +185,71 @@ class CommunityPageViewModel { } } - func fetchCircleMemberData(from url: String, token: String, loading: Bool, circleID: String? = nil) { + func fetchCircleMemberData(from url: String, token: String, loading: Bool = false, circleID: String? = nil) { if let circleID = circleID { - - self.loadingCircleMembersDict[circleID] = loading + if loading || circleMembersDict[circleID]?.isEmpty != false { + self.loadingCircleMembersDict[circleID] = true + } } else { - - self.loadingCircleMembers = loading + if loading || circleMembers.isEmpty { + self.loadingCircleMembers = true + } } AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in - print("***********") - - switch response.result { - + DispatchQueue.main.async { + switch response.result { case .success(let data): if let circleID = circleID { - self.circleMembersDict[circleID] = data.data self.loadingCircleMembersDict[circleID] = false } else { - self.circleMembers = data.data self.loadingCircleMembers = false } - - print(data.data) - print("Successfully fetched circles members :") - print(data.data) + print("Successfully fetched circle members: \(data.data)") case .failure(let error): - self.logger.error("Error fetching circles members: \(error)") + self.logger.error("Error fetching circle members: \(error)") if let circleID = circleID { self.loadingCircleMembersDict[circleID] = false } else { self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } } + } } } } //MARK : Circle Leave - func fetchCircleLeave(from url: String, token: String, loading: Bool) { - self.loadingCircleMembers = loading + func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { + if loading { + self.loadingCircleMembers = true + } AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in - print("***********") - - switch response.result { + DispatchQueue.main.async { + self.loadingCircleMembers = false + switch response.result { case .success(let data): - self.circleMembers = data.data - self.loadingCircleMembers = false - print(data.data) - print("Successfully fetched circles members :") - print(data.data) + self.circleMembers = data.data + print("Successfully fetched circle members after leave: \(data.data)") + case .failure(let error): - self.logger.error("Error fetching circles members: \(error)") - self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + self.logger.error("Error fetching circle members: \(error)") + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } + } } } } @@ -237,17 +262,19 @@ class CommunityPageViewModel { AF.request(url, method: .delete, headers: ["Authorization": "Token \(token)"]) .validate() .response { response in - switch response.result { - case .success(let value): - if let json = value as? [String: Any], let detail = json["detail"] as? String { - self.logger.info("Success: \(detail)") - } - self.loadingCircleMembers = false - - case .failure(let error): - self.logger.error("Error leaving circle: \(error)") + DispatchQueue.main.async { self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + + switch response.result { + case .success(let value): + if let json = value as? [String: Any], let detail = json["detail"] as? String { + self.logger.info("Success: \(detail)") + } + + case .failure(let error): + self.logger.error("Error leaving circle: \(error)") + self.errorCircleMembers = true + } } } } @@ -266,78 +293,100 @@ class CommunityPageViewModel { circleMembersDict.removeValue(forKey: circleID) loadingCircleMembersDict.removeValue(forKey: circleID) } + // MARK: - Group Creation - func createCircle(name: String, token: String, completion: @escaping (Result) -> Void) { - let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name - let url = "\(APIConstants.base_url)circles/create/\(encodedName)" - - AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) - .validate() - .responseJSON { response in - DispatchQueue.main.async { - switch response.result { - case .success(let data): - if let json = data as? [String: Any], - let circleId = json["circle_id"] as? String { - self.logger.info("Successfully created circle: \(circleId)") - completion(.success(circleId)) - } else { - // Try to extract circle_id from different response format - if let json = data as? [String: Any], - let dataDict = json["data"] as? [String: Any], - let circleId = dataDict["id"] as? String { - self.logger.info("Successfully created circle: \(circleId)") - completion(.success(circleId)) - } else { - let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) - completion(.failure(error)) - } - } - - case .failure(let error): - self.logger.error("Error creating circle: \(error)") - completion(.failure(error)) - } - } - } - } + func createCircle(name: String, token: String, completion: @escaping (Result) -> Void) { + let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name + let url = "\(APIConstants.base_url)circles/create/\(encodedName)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseJSON { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + if let json = data as? [String: Any], + let circleId = json["circle_id"] as? String { + self.logger.info("Successfully created circle: \(circleId)") + completion(.success(circleId)) + } else { + + if let json = data as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let circleId = dataDict["id"] as? String { + self.logger.info("Successfully created circle: \(circleId)") + completion(.success(circleId)) + } else { + let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + completion(.failure(error)) + } + } + + case .failure(let error): + self.logger.error("Error creating circle: \(error)") + completion(.failure(error)) + } + } + } + } + + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + switch response.result { + case .success: + self.logger.info("Successfully sent invitation to \(username) for circle \(circleId)") + completion(true) + + case .failure(let error): + self.logger.error("Error sending invitation to \(username): \(error)") + completion(false) + } + } + } + } + + func sendMultipleInvitations(circleId: String, usernames: [String], token: String, completion: @escaping ([String: Bool]) -> Void) { + let dispatchGroup = DispatchGroup() + var results: [String: Bool] = [:] + + for username in usernames { + dispatchGroup.enter() + + sendCircleInvitation(circleId: circleId, username: username, token: token) { success in + results[username] = success + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + completion(results) + } + } + + // MARK: - Refresh Methods + + func refreshAllData(token: String, username: String) { - func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { - let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" - - AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) - .validate() - .response { response in - DispatchQueue.main.async { - switch response.result { - case .success: - self.logger.info("Successfully sent invitation to \(username) for circle \(circleId)") - completion(true) - - case .failure(let error): - self.logger.error("Error sending invitation to \(username): \(error)") - completion(false) - } - } - } - } + fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(username)/", + token: token, + loading: false + ) + + + fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + - func sendMultipleInvitations(circleId: String, usernames: [String], token: String, completion: @escaping ([String: Bool]) -> Void) { - let dispatchGroup = DispatchGroup() - var results: [String: Bool] = [:] - - for username in usernames { - dispatchGroup.enter() - - sendCircleInvitation(circleId: circleId, username: username, token: token) { success in - results[username] = success - dispatchGroup.leave() - } - } - - dispatchGroup.notify(queue: .main) { - completion(results) - } - } + fetchCircleRequests(token: token, loading: false) + } } From d4ec63a2b2d8e975741eed90bb88e5f719da2a3a Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Wed, 25 Jun 2025 12:44:14 +0545 Subject: [PATCH 08/16] fix: username nav icon --- .../VITTY/Academics/View/RemindersData.swift | 10 ++++-- VITTY/VITTY/Username/Views/UsernameView.swift | 33 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/VITTY/VITTY/Academics/View/RemindersData.swift b/VITTY/VITTY/Academics/View/RemindersData.swift index 75ef787..7be6760 100644 --- a/VITTY/VITTY/Academics/View/RemindersData.swift +++ b/VITTY/VITTY/Academics/View/RemindersData.swift @@ -15,7 +15,7 @@ struct RemindersView: View { @State private var searchText = "" @State private var selectedTab = 0 - // Filtered reminders based on search text + private var filteredReminders: [Remainder] { if searchText.isEmpty { return allReminders @@ -28,7 +28,7 @@ struct RemindersView: View { } } - // Group reminders by date + private var groupedReminders: [ReminderGroup] { let grouped = Dictionary(grouping: filteredReminders) { reminder in Calendar.current.startOfDay(for: reminder.date) @@ -88,6 +88,12 @@ struct RemindersView: View { StatusTabView(isSelected: selectedTab == 1, title: "Completed") .onTapGesture { selectedTab = 1 } Spacer() + Button { + //TODO: to implement + } label: { + Image(systemName: "plus") + } + } .padding(.horizontal) .padding(.top, 16) diff --git a/VITTY/VITTY/Username/Views/UsernameView.swift b/VITTY/VITTY/Username/Views/UsernameView.swift index 090130a..5aa9f5c 100644 --- a/VITTY/VITTY/Username/Views/UsernameView.swift +++ b/VITTY/VITTY/Username/Views/UsernameView.swift @@ -18,12 +18,15 @@ struct UsernameView: View { @State private var isLoading = false @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ZStack { BackgroundView() VStack(alignment: .leading) { + headerView + Text("Enter username and your registration number below.") .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) @@ -114,11 +117,37 @@ struct UsernameView: View { .padding(.horizontal) } - .navigationTitle("Let's Sign You In") + .navigationBarBackButtonHidden(true) } .accentColor(.white) } - + private var headerView: some View { + VStack{ + HStack { + Button(action: { + dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.title2) + } + Spacer() + + + } + + .padding(.top) + + HStack{ + Text("Let's Sign you in ") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + Spacer() + }.padding([.top,.bottom]) + } + } + func checkUserExists(completion: @escaping (Result) -> Void) { guard let url = URL(string: "\(Constants.url)auth/check-username") else { completion(.failure(AuthAPIServiceError.invalidUrl)) From 5a6a477e8055c2704e281f9f9fffd86818b4703b Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Wed, 25 Jun 2025 17:02:48 +0545 Subject: [PATCH 09/16] feat:file upload impl --- VITTY/VITTY.xcodeproj/project.pbxproj | 34 +- VITTY/VITTY/Academics/Components/Alerts.swift | 112 ++ .../Components/FileUploadHelper.swift | 146 ++ VITTY/VITTY/Academics/Model/CourseFile.swift | 43 + VITTY/VITTY/Academics/View/CourseRefs.swift | 526 +++++-- VITTY/VITTY/Academics/View/FileUpload.swift | 1243 +++++++++++++++++ .../View/Circles/Components/QrCode.swift | 8 +- VITTY/VITTY/Username/Views/UsernameView.swift | 4 - VITTY/VITTYApp.swift | 2 +- 9 files changed, 2008 insertions(+), 110 deletions(-) create mode 100644 VITTY/VITTY/Academics/Components/Alerts.swift create mode 100644 VITTY/VITTY/Academics/Components/FileUploadHelper.swift create mode 100644 VITTY/VITTY/Academics/Model/CourseFile.swift create mode 100644 VITTY/VITTY/Academics/View/FileUpload.swift diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 9b13248..38ff062 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -36,6 +36,11 @@ 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; + 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; + 4B74D8742E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; + 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8762E0BF77400B390E9 /* Alerts.swift */; }; + 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */; }; + 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */; }; 4B7DA5DC2D708BD3007354A3 /* LectureItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5DB2D708BCD007354A3 /* LectureItemView.swift */; }; 4B7DA5DF2D7094E8007354A3 /* Academics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5DE2D7094E3007354A3 /* Academics.swift */; }; 4B7DA5E12D70A728007354A3 /* FriendRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5E02D70A71C007354A3 /* FriendRow.swift */; }; @@ -188,6 +193,10 @@ 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = ""; }; 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = ""; }; + 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseFile.swift; sourceTree = ""; }; + 4B74D8762E0BF77400B390E9 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; + 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUpload.swift; sourceTree = ""; }; + 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadHelper.swift; sourceTree = ""; }; 4B7DA5DB2D708BCD007354A3 /* LectureItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureItemView.swift; sourceTree = ""; }; 4B7DA5DE2D7094E3007354A3 /* Academics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Academics.swift; sourceTree = ""; }; 4B7DA5E02D70A71C007354A3 /* FriendRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendRow.swift; sourceTree = ""; }; @@ -455,9 +464,19 @@ path = ViewModel; sourceTree = ""; }; + 4B74D8752E0BF76B00B390E9 /* Components */ = { + isa = PBXGroup; + children = ( + 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */, + 4B74D8762E0BF77400B390E9 /* Alerts.swift */, + ); + path = Components; + sourceTree = ""; + }; 4B7DA5DD2D7094CA007354A3 /* Academics */ = { isa = PBXGroup; children = ( + 4B74D8752E0BF76B00B390E9 /* Components */, 4BBB002F2D95510B003B8FE2 /* Model */, 4BBB002E2D955104003B8FE2 /* VIewModel */, 4BBB002D2D9550F8003B8FE2 /* View */, @@ -534,6 +553,7 @@ 4BBB002D2D9550F8003B8FE2 /* View */ = { isa = PBXGroup; children = ( + 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, 4B183EE72D7C78B300C9D801 /* Courses.swift */, 4BF03C982D7819E00098C803 /* Notes.swift */, 4BF03C9A2D7838C50098C803 /* NotesHelper.swift */, @@ -558,6 +578,7 @@ 4BBB002F2D95510B003B8FE2 /* Model */ = { isa = PBXGroup; children = ( + 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, 4B5977462DF97D5A009CC224 /* RemainderModel.swift */, 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */, ); @@ -598,13 +619,6 @@ path = View; sourceTree = ""; }; - 4BF03C972D7819D30098C803 /* Academics */ = { - isa = PBXGroup; - children = ( - ); - path = Academics; - sourceTree = ""; - }; 520BA63B2B47FF5700124850 /* SuggestedFriends */ = { isa = PBXGroup; children = ( @@ -748,7 +762,6 @@ 524B84312B46EF28006D18BD /* View */ = { isa = PBXGroup; children = ( - 4BF03C972D7819D30098C803 /* Academics */, 4B7DA5E92D71E0D7007354A3 /* Freinds */, 4B7DA5E82D71E0CE007354A3 /* Circles */, 524B84342B46F0FE006D18BD /* Components */, @@ -1126,6 +1139,7 @@ 522B8BB02B4732CC00EE686E /* Friend.swift in Sources */, 52D5AB8C2B6FE4D600B2E66D /* UserDefaultKeys.swift in Sources */, 5D7F04F72AAB9E9900ECED15 /* APIConstants.swift in Sources */, + 4B74D8742E0BDF2100B390E9 /* CourseFile.swift in Sources */, 4BF03C9B2D7838C80098C803 /* NotesHelper.swift in Sources */, 3109639F27824F6F0009A29C /* AppStorageConstants.swift in Sources */, 4BD63D742D70547E00EEF5D7 /* EmptyClass.swift in Sources */, @@ -1133,6 +1147,7 @@ 524B84332B46EF3A006D18BD /* ConnectPage.swift in Sources */, 521E1E8B2C21DF0D00E8C7D2 /* AddFriendCardSearch.swift in Sources */, 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */, + 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.swift in Sources */, 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */, 5238C7F12B4AAE8700413946 /* FriendRequestView.swift in Sources */, 528CF1782B769E64007298A0 /* TimeTableAPIService.swift in Sources */, @@ -1163,6 +1178,7 @@ 52D5AB862B6FE2ED00B2E66D /* AuthViewModel.swift in Sources */, 4BF0C79F2D94694900016202 /* InsideCircleCards.swift in Sources */, 524B843C2B46F6FD006D18BD /* AddFriendsHeader.swift in Sources */, + 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */, 4BD63D7A2D70636400EEF5D7 /* EmptyClassRoomViewModel.swift in Sources */, 521562AC2B70B0FD0054F051 /* InstructionView.swift in Sources */, 522B8BAD2B47297A00EE686E /* CommunityPageViewModel.swift in Sources */, @@ -1172,6 +1188,7 @@ 52D5AB8F2B6FE82E00B2E66D /* AuthAPIService.swift in Sources */, 4BD63D772D70610B00EEF5D7 /* EmptyClassAPIService.swift in Sources */, 528CF1732B769B18007298A0 /* TimeTable.swift in Sources */, + 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */, 521562AE2B710E730054F051 /* UsernameView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1182,6 +1199,7 @@ files = ( 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */, 4BC853C42DF6DA7A0092B2E2 /* TimeTable.swift in Sources */, + 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/VITTY/VITTY/Academics/Components/Alerts.swift b/VITTY/VITTY/Academics/Components/Alerts.swift new file mode 100644 index 0000000..7a2d2bc --- /dev/null +++ b/VITTY/VITTY/Academics/Components/Alerts.swift @@ -0,0 +1,112 @@ +// +// Alerts.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + +import SwiftUI + +struct DeleteNoteAlert: View { + let noteName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete note?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete '\(noteName)'?") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + +struct DeleteFileAlert: View { + let noteName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete File?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete '\(noteName)'?") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} diff --git a/VITTY/VITTY/Academics/Components/FileUploadHelper.swift b/VITTY/VITTY/Academics/Components/FileUploadHelper.swift new file mode 100644 index 0000000..8500c36 --- /dev/null +++ b/VITTY/VITTY/Academics/Components/FileUploadHelper.swift @@ -0,0 +1,146 @@ +// +// FileUploadHelper.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Manager Helper +class FileManagerHelper { + static let shared = FileManagerHelper() + + private init() {} + + var documentsDirectory: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + + func createCourseDirectory(courseCode: String) -> URL { + let courseDir = documentsDirectory.appendingPathComponent("Courses/\(courseCode)") + try? FileManager.default.createDirectory(at: courseDir, withIntermediateDirectories: true) + return courseDir + } + + func saveFile(data: Data, fileName: String, courseCode: String) -> String? { + let courseDir = createCourseDirectory(courseCode: courseCode) + let fileURL = courseDir.appendingPathComponent(fileName) + + do { + try data.write(to: fileURL) + return fileURL.path + } catch { + print("Error saving file: \(error)") + return nil + } + } + + func loadFile(from path: String) -> Data? { + return FileManager.default.contents(atPath: path) + } + + + func fileExists(at path: String) -> Bool { + return FileManager.default.fileExists(atPath: path) + } + + func generateThumbnail(for imageData: Data, courseCode: String, fileName: String) -> String? { + guard let image = UIImage(data: imageData) else { return nil } + + let thumbnailSize = CGSize(width: 150, height: 150) + let renderer = UIGraphicsImageRenderer(size: thumbnailSize) + + let thumbnailImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: thumbnailSize)) + } + + guard let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.7) else { return nil } + + let thumbnailFileName = "thumb_\(fileName)" + return saveFile(data: thumbnailData, fileName: thumbnailFileName, courseCode: courseCode) + } + + func deleteFile(at path: String) { + try? FileManager.default.removeItem(atPath: path) + } + + func formatFileSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + + +extension FileManagerHelper { + + + func getValidFileURL(from storedPath: String, courseCode: String) -> URL? { + + if FileManager.default.fileExists(atPath: storedPath) { + return URL(fileURLWithPath: storedPath) + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: courseCode) + let reconstructedURL = courseDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: reconstructedURL.path) { + return reconstructedURL + } + + return nil + } + + + func updateFilePathsIfNeeded(files: [UploadedFile], modelContext: ModelContext) { + var hasChanges = false + + for file in files { + + if !fileExists(at: file.localPath) { + + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + + file.localPath = newPath + hasChanges = true + } + } + + + if let thumbnailPath = file.thumbnailPath, !fileExists(at: thumbnailPath) { + let thumbnailFileName = URL(fileURLWithPath: thumbnailPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newThumbnailPath = courseDir.appendingPathComponent(thumbnailFileName).path + + if fileExists(at: newThumbnailPath) { + file.thumbnailPath = newThumbnailPath + hasChanges = true + } + } + } + + + if hasChanges { + do { + try modelContext.save() + print("Updated file paths for \(files.count) files") + } catch { + print("Error updating file paths: \(error)") + } + } + } + + +} diff --git a/VITTY/VITTY/Academics/Model/CourseFile.swift b/VITTY/VITTY/Academics/Model/CourseFile.swift new file mode 100644 index 0000000..f6baf3c --- /dev/null +++ b/VITTY/VITTY/Academics/Model/CourseFile.swift @@ -0,0 +1,43 @@ +// +// CourseFile.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + + +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Model +@Model +class UploadedFile { + var id: UUID + var fileName: String + var fileType: String + var fileSize: Int64 + var courseName: String + var courseCode: String + var uploadDate: Date + var localPath: String + var thumbnailPath: String? + var isImage: Bool + + init(fileName: String, fileType: String, fileSize: Int64, courseName: String, courseCode: String, localPath: String, thumbnailPath: String? = nil, isImage: Bool = false) { + self.id = UUID() + self.fileName = fileName + self.fileType = fileType + self.fileSize = fileSize + self.courseName = courseName + self.courseCode = courseCode + self.uploadDate = Date() + self.localPath = localPath + self.thumbnailPath = thumbnailPath + self.isImage = isImage + } +} + diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index 9832fe1..dc50d30 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -4,6 +4,7 @@ // // Created by Rujin Devkota on 2/27/25. + import SwiftUI import SwiftData @@ -23,8 +24,14 @@ struct OCourseRefs: View { @State private var searchText = "" @State private var showDeleteAlert = false @State private var noteToDelete: CreateNoteModel? + @State private var fileToDelete: UploadedFile? @State private var isLoadingNote = false @State private var loadingNoteId: Date? + @State private var showimgDeleteAlert = false + // File upload related states + @State private var showFileUpload = false + @State private var showFileGallery = false + @State private var selectedContentType: ContentType = .notes @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -33,6 +40,19 @@ struct OCourseRefs: View { @Query private var filteredRemainders: [Remainder] @Query private var courseNotes: [CreateNoteModel] + @Query private var courseFiles: [UploadedFile] + + enum ContentType: String, CaseIterable { + case notes = "Notes" + case files = "Files" + + var icon: String { + switch self { + case .notes: return "doc.text" + case .files: return "folder" + } + } + } init(courseName: String, courseInstitution: String, slot: String, courseCode: String) { self.courseName = courseName @@ -53,6 +73,14 @@ struct OCourseRefs: View { _courseNotes = Query( FetchDescriptor(predicate: notesPredicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) ) + + + let filesPredicate = #Predicate { + $0.courseCode == courseCode + } + _courseFiles = Query( + FetchDescriptor(predicate: filesPredicate, sortBy: [SortDescriptor(\.uploadDate, order: .reverse)]) + ) } private var filteredNotes: [CreateNoteModel] { @@ -64,6 +92,16 @@ struct OCourseRefs: View { } } } + + private var filteredFiles: [UploadedFile] { + if searchText.isEmpty { + return courseFiles + } else { + return courseFiles.filter { file in + file.fileName.localizedCaseInsensitiveContains(searchText) + } + } + } var body: some View { NavigationStack { @@ -87,12 +125,21 @@ struct OCourseRefs: View { .foregroundColor(.white) Spacer() + + + if selectedContentType == .files && !courseFiles.isEmpty { + Button("View All") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) + } } .padding() HStack { Spacer() - TextField("Search notes...", text: $searchText) + TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) .padding(10) .frame(width: UIScreen.main.bounds.width * 0.85) .background(Color.white.opacity(0.1)) @@ -101,13 +148,35 @@ struct OCourseRefs: View { .foregroundColor(.white) Spacer() } - Spacer().frame(height: 20) + + + + + Spacer().frame(height: 15) Text("\(courseName) - \(courseInstitution)") .font(.title2) .bold() .foregroundColor(.white) .padding(.horizontal) + + HStack(spacing: 12) { + ForEach(ContentType.allCases, id: \.self) { contentType in + ContentTypeTab( + contentType: contentType, + isSelected: selectedContentType == contentType, + count: contentType == .notes ? filteredNotes.count : filteredFiles.count + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedContentType = contentType + searchText = "" + } + } + } + Spacer() + } + .padding(.horizontal) + .padding(.top, 10) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -127,39 +196,61 @@ struct OCourseRefs: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 15) { - if filteredNotes.isEmpty { - VStack(spacing: 16) { - Image(systemName: searchText.isEmpty ? "doc.text" : "magnifyingglass") - .font(.system(size: 48)) - .foregroundColor(.gray.opacity(0.6)) - - Text(searchText.isEmpty ? "No notes found for this course" : "No notes match your search") - .foregroundColor(.gray) - .font(.system(size: 16, weight: .medium)) - .multilineTextAlignment(.center) - - if !searchText.isEmpty { - Text("Try searching with different keywords") - .foregroundColor(.gray.opacity(0.8)) - .font(.system(size: 14)) - .multilineTextAlignment(.center) + if selectedContentType == .notes { + + if filteredNotes.isEmpty { + EmptyStateView( + icon: searchText.isEmpty ? "doc.text" : "magnifyingglass", + title: searchText.isEmpty ? "No notes found for this course" : "No notes match your search", + subtitle: searchText.isEmpty ? nil : "Try searching with different keywords" + ) + } else { + ForEach(filteredNotes, id: \.createdAt) { note in + CourseCardNotes( + title: note.noteName, + description: note.cachedPlainText, + isLoading: loadingNoteId == note.createdAt, + onDelete: { + noteToDelete = note + showDeleteAlert = true + } + ) + .onTapGesture { + openNote(note) + } } } - .frame(maxWidth: .infinity) - .padding(.top, 60) } else { - ForEach(filteredNotes, id: \.createdAt) { note in - CourseCardNotes( - title: note.noteName, - description: note.cachedPlainText, - isLoading: loadingNoteId == note.createdAt, - onDelete: { - noteToDelete = note - showDeleteAlert = true - } + + if filteredFiles.isEmpty { + EmptyStateView( + icon: searchText.isEmpty ? "folder" : "magnifyingglass", + title: searchText.isEmpty ? "No files found for this course" : "No files match your search", + subtitle: searchText.isEmpty ? "Upload some files to get started" : "Try searching with different keywords" ) - .onTapGesture { - openNote(note) + } else { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 12) { + ForEach(Array(filteredFiles.prefix(6)), id: \.id) { file in + CompactFileCard(file: file) { + + + showimgDeleteAlert = true + fileToDelete = file + } + } + } + + if filteredFiles.count > 6 { + Button("View All \(filteredFiles.count) Files") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) + .padding(.top, 8) + .frame(maxWidth: .infinity) } } } @@ -187,7 +278,6 @@ struct OCourseRefs: View { } } - if showDeleteAlert { DeleteNoteAlert( noteName: noteToDelete?.noteName ?? "", @@ -201,6 +291,21 @@ struct OCourseRefs: View { ) .zIndex(1) } + if showimgDeleteAlert { + DeleteFileAlert( + noteName: noteToDelete?.noteName ?? "", + onCancel: { + showimgDeleteAlert = false + fileToDelete = nil + }, + onDelete: { + deleteFile() + } + ) + .zIndex(1) + } + + } .onAppear { print("this is course code") @@ -218,7 +323,11 @@ struct OCourseRefs: View { navigateToNotesEditor = true } - BottomSheetButton(icon: "edit_document", title: "Upload File") + BottomSheetButton(icon: "edit_document", title: "Upload File") { + showBottomSheet = false + showFileUpload = true + } + BottomSheetButton(icon: "alarm", title: "Set Reminder") { showBottomSheet = false showReminderSheet = true @@ -234,7 +343,12 @@ struct OCourseRefs: View { ReminderView(courseName: courseName, slot: slot, courseCode: courseCode) .presentationDetents([.fraction(0.8)]) } - + .sheet(isPresented: $showFileUpload) { + FileUploadView(courseName: courseName, courseCode: courseCode) + } + .sheet(isPresented: $showFileGallery) { + FileGalleryView(courseCode: courseCode) + } .navigationDestination(isPresented: $navigateToNotesEditor) { NoteEditorView(courseCode: courseCode, courseName: courseName, courseIns: courseInstitution, courseSlot: slot) } @@ -255,7 +369,6 @@ struct OCourseRefs: View { private func openNote(_ note: CreateNoteModel) { guard !isLoadingNote else { return } - isLoadingNote = true loadingNoteId = note.createdAt @@ -266,14 +379,11 @@ struct OCourseRefs: View { do { let attributedString = try await loadNoteContent(note) - selectedNote = note preloadedAttributedString = attributedString - try await Task.sleep(nanoseconds: 300_000_000) - isLoadingNote = false loadingNoteId = nil showNotes = true @@ -283,7 +393,6 @@ struct OCourseRefs: View { isLoadingNote = false loadingNoteId = nil - let errorFeedback = UINotificationFeedbackGenerator() errorFeedback.notificationOccurred(.error) } @@ -292,17 +401,14 @@ struct OCourseRefs: View { @MainActor private func loadNoteContent(_ note: CreateNoteModel) async throws -> NSAttributedString { - if let cachedAttributedString = note.cachedAttributedString { return cachedAttributedString } - guard let data = Data(base64Encoded: note.noteContent) else { throw NoteLoadingError.invalidData } - if let attributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { return attributedString } else { @@ -313,83 +419,319 @@ struct OCourseRefs: View { private func deleteNote() { guard let note = noteToDelete else { return } - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() - modelContext.delete(note) - do { try modelContext.save() } catch { print("Failed to delete note: \(error)") } - + showDeleteAlert = false noteToDelete = nil } + + private func deleteFile(){ + guard let file = fileToDelete else{return} + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + modelContext.delete(file) + do { + try modelContext.save() + } catch { + print("Failed to delete file: \(error)") + } + showimgDeleteAlert = false + + } + } -// MARK: - Error Handling -enum NoteLoadingError: Error { - case invalidData - case unarchiveFailed + + +struct ContentTypeTab: View { + let contentType: OCourseRefs.ContentType + let isSelected: Bool + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: contentType.icon) + .font(.system(size: 14)) + + Text(contentType.rawValue) + .font(.system(size: 14, weight: .medium)) + + if count > 0 { + Text("(\(count))") + .font(.system(size: 12)) + .opacity(0.8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color("Accent") : Color("Secondary")) + .foregroundColor(isSelected ? .black : .white) + .cornerRadius(20) + } + } } -struct DeleteNoteAlert: View { - let noteName: String - let onCancel: () -> Void - let onDelete: () -> Void +struct EmptyStateView: View { + let icon: String + let title: String + let subtitle: String? var body: some View { - VStack { - Spacer() - VStack(spacing: 12) { - Text("Delete note?") - .font(.custom("Poppins-SemiBold", size: 18)) + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text(title) + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + .multilineTextAlignment(.center) + + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.gray.opacity(0.8)) + .font(.system(size: 14)) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } +} +struct CompactFileCard: View { + let file: UploadedFile + let onDelete: (() -> Void)? + + @State private var showFileViewer = false + @State private var showActionSheet = false + @State private var fileImage: UIImage? + @State private var imageLoadError = false + @State private var isLoading = true + + init(file: UploadedFile, onDelete: (() -> Void)? = nil) { + self.file = file + self.onDelete = onDelete + } + + var body: some View { + VStack(spacing: 8) { + + if file.isImage && !imageLoadError { + Group { + if let image = fileImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else if isLoading { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + ) + } else { + Rectangle() + .fill(Color.red.opacity(0.3)) + .overlay( + VStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + .font(.system(size: 16)) + Text("Not found") + .font(.caption2) + .foregroundColor(.red) + } + ) + } + } + .frame(height: 80) + .clipped() + .cornerRadius(8) + } else { + Rectangle() + .fill(getFileTypeColor(file.fileType).opacity(0.2)) + .frame(height: 80) + .overlay( + VStack(spacing: 4) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 24)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileType.uppercased()) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(getFileTypeColor(file.fileType)) + } + ) + .cornerRadius(8) + } + + + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName) + .font(.caption) + .fontWeight(.medium) .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) - Text("Are you sure you want to delete '\(noteName)'?") - .font(.custom("Poppins-Regular", size: 14)) - .foregroundColor(.white) - .multilineTextAlignment(.center) + Text(FileManagerHelper.shared.formatFileSize(file.fileSize)) + .font(.caption2) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onTapGesture { + showFileViewer = true + } + .onLongPressGesture(minimumDuration: 0.5) { + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + showActionSheet = true + } + .onAppear { + if file.isImage { + loadImageFile() + } + } + .sheet(isPresented: $showFileViewer) { + EnhancedFileViewerSheet(file: file) + } + .confirmationDialog("File Options", isPresented: $showActionSheet, titleVisibility: .visible) { + Button("Share") { + shareFile() + } + + if let onDelete = onDelete { + Button("Delete", role: .destructive) { + onDelete() + } + } + + Button("Cancel", role: .cancel) {} + } message: { + Text("Choose an action for \(file.fileName)") + } + } + + // MARK: - File Loading Methods + + private func loadImageFile() { + isLoading = true + imageLoadError = false + + Task { + let imagePaths = [file.thumbnailPath, file.localPath].compactMap { $0 } + var loadedImage: UIImage? + + for path in imagePaths { + if let data = FileManagerHelper.shared.loadFileWithFallback(from: path, courseCode: file.courseCode), + let image = UIImage(data: data) { + loadedImage = image + break + } + } + + await MainActor.run { + if let image = loadedImage { + self.fileImage = image + self.imageLoadError = false + } else { + self.imageLoadError = true + } + self.isLoading = false + } + } + } + + private func shareFile() { + guard let data = FileManagerHelper.shared.loadFileWithFallback(from: file.localPath, courseCode: file.courseCode) else { + print("Cannot share file: File not found") + return + } + + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(file.fileName) + + do { + if FileManager.default.fileExists(atPath: tempURL.path) { + try FileManager.default.removeItem(at: tempURL) + } + try data.write(to: tempURL) + + let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootViewController = window.rootViewController { - HStack(spacing: 10) { - Button(action: onCancel) { - Text("Cancel") - .font(.custom("Poppins-Regular", size: 14)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.3)) - .foregroundColor(.white) - .cornerRadius(8) - } - - Button(action: onDelete) { - Text("Delete") - .font(.custom("Poppins-Regular", size: 14)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.red) - .foregroundColor(.white) - .cornerRadius(8) - } + if let popover = activityVC.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) + popover.permittedArrowDirections = [] } + + rootViewController.present(activityVC, animated: true) } - .frame(height: 150) - .padding(20) - .background(Color("Background")) - .cornerRadius(16) - .padding(.horizontal, 30) - .transition(.scale.combined(with: .opacity)) - Spacer() + } catch { + print("Error sharing file: \(error)") + } + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray } - .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) } } +// MARK: - Error Handling +enum NoteLoadingError: Error { + case invalidData + case unarchiveFailed +} + + struct BottomSheetButton: View { var icon: String var title: String @@ -460,7 +802,6 @@ struct MoreTagView: View { } } - struct CourseCardNotes: View { var title: String var description: String @@ -485,7 +826,6 @@ struct CourseCardNotes: View { Spacer() if isLoading { - ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) .scaleEffect(0.8) diff --git a/VITTY/VITTY/Academics/View/FileUpload.swift b/VITTY/VITTY/Academics/View/FileUpload.swift new file mode 100644 index 0000000..b720a52 --- /dev/null +++ b/VITTY/VITTY/Academics/View/FileUpload.swift @@ -0,0 +1,1243 @@ +// +// FileUpload.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + +import SwiftUI +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Upload View +struct FileUploadView: View { + let courseName: String + let courseCode: String + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var selectedImages: [PhotosPickerItem] = [] + @State private var showDocumentPicker = false + @State private var isUploading = false + @State private var uploadProgress: Double = 0 + @State private var showSuccessAlert = false + @State private var uploadedCount = 0 + @State private var showErrorAlert = false + @State private var errorMessage = "" + @State private var capturedImage: UIImage? + @State private var showCamera = false + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 30) { + // Header + VStack(spacing: 8) { + Image(systemName: "icloud.and.arrow.up") + .font(.system(size: 48)) + .foregroundColor(Color("Secondary")) + + Text("Upload Files") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + + Text("Add images and documents to \(courseName)") + .font(.subheadline) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + } + .padding(.top, 20) + + + VStack(spacing: 16) { + Button(action: { + showCamera = true + }) { + UploadOptionCard( + icon: "camera.fill", + title: "Take Photo", + description: "Capture new photos with camera", + color: .orange + ) + } + .disabled(isUploading) + + PhotosPicker( + selection: $selectedImages, + maxSelectionCount: 10, + matching: .images + ) { + UploadOptionCard( + icon: "photo.on.rectangle.angled", + title: "Upload Images", + description: "Take photos or select from gallery", + color: .blue + ) + } + .disabled(isUploading) + + + Button(action: { + showDocumentPicker = true + }) { + UploadOptionCard( + icon: "doc.fill", + title: "Upload Documents", + description: "Select PDF, Word, or other files", + color: .green + ) + } + .disabled(isUploading) + } + + + if isUploading { + VStack(spacing: 12) { + ProgressView(value: uploadProgress) + .progressViewStyle(LinearProgressViewStyle(tint: Color("Secondary"))) + + Text("Uploading files... (\(Int(uploadProgress * 100))%)") + .font(.subheadline) + .foregroundColor(.gray) + } + .padding(.horizontal) + } + + Spacer() + } + .padding(.horizontal) + } + .navigationTitle("Upload Files") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) .sheet(isPresented: $showCamera) { + CameraView(capturedImage: $capturedImage) + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundColor(.white) + .disabled(isUploading) + } + } + } + .onChange(of: selectedImages) { _, newItems in + if !newItems.isEmpty { + uploadImages(newItems) + } + } + .fileImporter( + isPresented: $showDocumentPicker, + allowedContentTypes: [.pdf, .plainText, .rtf, .rtfd, .data, .item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + uploadDocuments(urls) + case .failure(let error): + errorMessage = "Error selecting documents: \(error.localizedDescription)" + showErrorAlert = true + } + } + .alert("Upload Complete", isPresented: $showSuccessAlert) { + Button("OK") { + dismiss() + } + } message: { + Text("Successfully uploaded \(uploadedCount) file(s)") + } + .alert("Upload Error", isPresented: $showErrorAlert) { + Button("OK") { } + } message: { + Text(errorMessage) + } + } + + private func uploadImages(_ items: [PhotosPickerItem]) { + guard !items.isEmpty else { return } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + for (index, item) in items.enumerated() { + do { + if let data = try await item.loadTransferable(type: Data.self) { + let timestamp = Int(Date().timeIntervalSince1970) + let fileName = "image_\(timestamp)_\(index).jpg" + + if let savedPath = FileManagerHelper.shared.saveFile( + data: data, + fileName: fileName, + courseCode: courseCode + ) { + let thumbnailPath = FileManagerHelper.shared.generateThumbnail( + for: data, + courseCode: courseCode, + fileName: fileName + ) + + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: "jpg", + fileSize: Int64(data.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + thumbnailPath: thumbnailPath, + isImage: true + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount += 1 + } + } + } + } catch { + await MainActor.run { + errorMessage = "Failed to process image: \(error.localizedDescription)" + showErrorAlert = true + } + } + + await MainActor.run { + uploadProgress = Double(index + 1) / Double(items.count) + } + } + + await MainActor.run { + isUploading = false + selectedImages = [] + + if uploadedCount > 0 { + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving files: \(error.localizedDescription)" + showErrorAlert = true + } + } + } + } + } + + private func uploadDocuments(_ urls: [URL]) { + guard !urls.isEmpty else { return } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + for (index, url) in urls.enumerated() { + var canAccess = false + + if url.startAccessingSecurityScopedResource() { + canAccess = true + } + + defer { + if canAccess { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let data = try Data(contentsOf: url) + let fileName = url.lastPathComponent + let fileType = url.pathExtension + + if let savedPath = FileManagerHelper.shared.saveFile( + data: data, + fileName: fileName, + courseCode: courseCode + ) { + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: fileType, + fileSize: Int64(data.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + isImage: false + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount += 1 + } + } + } catch { + await MainActor.run { + errorMessage = "Failed to process document \(url.lastPathComponent): \(error.localizedDescription)" + showErrorAlert = true + } + } + + await MainActor.run { + uploadProgress = Double(index + 1) / Double(urls.count) + } + } + + await MainActor.run { + isUploading = false + + if uploadedCount > 0 { + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving files: \(error.localizedDescription)" + showErrorAlert = true + } + } + } + } + } + private func uploadCapturedImage(_ image: UIImage) { + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + errorMessage = "Failed to process captured image" + showErrorAlert = true + return + } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + let timestamp = Int(Date().timeIntervalSince1970) + let fileName = "camera_\(timestamp).jpg" + + if let savedPath = FileManagerHelper.shared.saveFile( + data: imageData, + fileName: fileName, + courseCode: courseCode + ) { + let thumbnailPath = FileManagerHelper.shared.generateThumbnail( + for: imageData, + courseCode: courseCode, + fileName: fileName + ) + + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: "jpg", + fileSize: Int64(imageData.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + thumbnailPath: thumbnailPath, + isImage: true + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount = 1 + uploadProgress = 1.0 + isUploading = false + capturedImage = nil + + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving captured image: \(error.localizedDescription)" + showErrorAlert = true + } + } + } else { + await MainActor.run { + isUploading = false + capturedImage = nil + errorMessage = "Failed to save captured image" + showErrorAlert = true + } + } + } + } + + +} +// MARK: - Camera View +struct CameraView: UIViewControllerRepresentable { + @Binding var capturedImage: UIImage? + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + picker.allowsEditing = true + picker.cameraCaptureMode = .photo + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let editedImage = info[.editedImage] as? UIImage { + parent.capturedImage = editedImage + } else if let originalImage = info[.originalImage] as? UIImage { + parent.capturedImage = originalImage + } + + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + + +// MARK: - Upload Option Card +struct UploadOptionCard: View { + let icon: String + let title: String + let description: String + let color: Color + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(color) + .frame(width: 40, height: 40) + .background(color.opacity(0.1)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(description) + .font(.subheadline) + .foregroundColor(.gray) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + .padding(16) + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + } +} + +// MARK: - File Gallery View +struct FileGalleryView: View { + let courseCode: String + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query private var files: [UploadedFile] + @State private var selectedFilter: FileFilter = .all + @State private var showDeleteAlert = false + @State private var fileToDelete: UploadedFile? + @State private var selectedFile: UploadedFile? + @State private var showFileViewer = false + + enum FileFilter: String, CaseIterable { + case all = "All" + case images = "Images" + case documents = "Documents" + + var icon: String { + switch self { + case .all: return "folder" + case .images: return "photo" + case .documents: return "doc" + } + } + } + + init(courseCode: String) { + self.courseCode = courseCode + let predicate = #Predicate { file in + file.courseCode == courseCode + } + _files = Query( + FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.uploadDate, order: .reverse)] + ) + ) + } + + private var filteredFiles: [UploadedFile] { + switch selectedFilter { + case .all: + return files + case .images: + return files.filter { $0.isImage } + case .documents: + return files.filter { !$0.isImage } + } + } + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(FileFilter.allCases, id: \.self) { filter in + FilterTab( + filter: filter, + isSelected: selectedFilter == filter + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedFilter = filter + } + } + } + } + .padding(.horizontal) + } + .padding(.bottom, 16) + + + if filteredFiles.isEmpty { + VStack(spacing: 16) { + Image(systemName: selectedFilter.icon) + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text("No \(selectedFilter.rawValue.lowercased()) found") + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + + Text("Upload some files to get started") + .foregroundColor(.gray.opacity(0.7)) + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + ForEach(filteredFiles, id: \.id) { file in + FileCard(file: file) { + selectedFile = file + showFileViewer = true + } onDelete: { + fileToDelete = file + showDeleteAlert = true + } + } + } + .padding(.horizontal) + .padding(.bottom, 20) + } + } + } + } + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Back") { + dismiss() + } + .foregroundColor(.white) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Text("\(filteredFiles.count) files") + .font(.caption) + .foregroundColor(.gray) + } + } + } + .alert("Delete File", isPresented: $showDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + deleteFile() + } + } message: { + Text("Are you sure you want to delete '\(fileToDelete?.fileName ?? "")'?") + } + .sheet(isPresented: $showFileViewer) { + if let file = selectedFile { + EnhancedFileViewerSheet(file: file) + } + } + } + + private func deleteFile() { + guard let file = fileToDelete else { return } + + + FileManagerHelper.shared.deleteFile(at: file.localPath) + if let thumbnailPath = file.thumbnailPath { + FileManagerHelper.shared.deleteFile(at: thumbnailPath) + } + + + modelContext.delete(file) + + do { + try modelContext.save() + } catch { + print("Error deleting file: \(error)") + } + + fileToDelete = nil + } +} + +// MARK: - Filter Tab +struct FilterTab: View { + let filter: FileGalleryView.FileFilter + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: filter.icon) + .font(.system(size: 14)) + + Text(filter.rawValue) + .font(.system(size: 14, weight: .medium)) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color("Secondary") : Color.white.opacity(0.1)) + .foregroundColor(isSelected ? .black : .white) + .cornerRadius(20) + } + } +} +// MARK: - File Card +struct FileCard: View { + let file: UploadedFile + let onTap: () -> Void + let onDelete: () -> Void + + @State private var imageLoadError = false + @State private var showActionSheet = false + + var body: some View { + VStack(spacing: 0) { + + if file.isImage && !imageLoadError { + Group { + if let thumbnailPath = file.thumbnailPath, + let thumbnailURL = getValidFileURL(from: thumbnailPath) { + AsyncImage(url: thumbnailURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } else if let fileURL = getValidFileURL(from: file.localPath) { + AsyncImage(url: fileURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } else { + Rectangle() + .fill(Color.red.opacity(0.3)) + .overlay( + VStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("File not found") + .font(.caption2) + .foregroundColor(.red) + } + ) + } + } + .frame(height: 120) + .clipped() + .onAppear { + + if getValidFileURL(from: file.localPath) == nil { + imageLoadError = true + } + } + } else { + Rectangle() + .fill(getFileTypeColor(file.fileType).opacity(0.1)) + .frame(height: 120) + .overlay( + VStack(spacing: 8) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 32)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileType.uppercased()) + .font(.caption) + .fontWeight(.bold) + .foregroundColor(getFileTypeColor(file.fileType)) + } + ) + } + + + VStack(alignment: .leading, spacing: 4) { + Text(file.fileName) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(FileManagerHelper.shared.formatFileSize(file.fileSize)) + .font(.caption2) + .foregroundColor(.gray) + + Spacer() + + Text(file.uploadDate, style: .date) + .font(.caption2) + .foregroundColor(.gray) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .onTapGesture { + onTap() + } + .onLongPressGesture(minimumDuration: 0.5) { + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + + showActionSheet = true + } + .confirmationDialog("File Options", isPresented: $showActionSheet, titleVisibility: .visible) { + Button("Share") { + shareFile() + } + + Button("Delete", role: .destructive) { + onDelete() + } + + Button("Cancel", role: .cancel) { + + } + } message: { + Text("Choose an action for \(file.fileName)") + } + } + + // MARK: - Helper Methods + + + private func getValidFileURL(from storedPath: String) -> URL? { + + if FileManager.default.fileExists(atPath: storedPath) { + return URL(fileURLWithPath: storedPath) + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let currentDocumentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let courseDir = currentDocumentsDir.appendingPathComponent("Courses/\(file.courseCode)") + let reconstructedURL = courseDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: reconstructedURL.path) { + return reconstructedURL + } + + return nil + } + + + private func shareFile() { + guard let fileURL = getValidFileURL(from: file.localPath) else { + print("Cannot share file: File not found") + return + } + + let activityVC = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil) + + + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray + } + } +} + +// MARK: - Updated FileManagerHelper +extension FileManagerHelper { + + func updateFilePathsIfNeeded(for file: UploadedFile) -> Bool { + + if fileExists(at: file.localPath) { + return true + } + + + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + + return true + } + + return false + } +} + +struct EnhancedFileViewerSheet: View { + let file: UploadedFile + + @Environment(\.dismiss) private var dismiss + @State private var fileData: Data? + @State private var isLoading = true + @State private var showShareSheet = false + @State private var loadError: String? + @State private var showQuickLook = false + @State private var temporaryFileURL: URL? + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + if isLoading { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + + Text("Loading file...") + .foregroundColor(.gray) + } + } else if let error = loadError { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Unable to load file") + .foregroundColor(.white) + .font(.headline) + + Text(error) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding() + } else if let data = fileData { + contentView(for: data) + } + } + .navigationTitle(file.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done") { + dismiss() + } + .foregroundColor(.white) + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack { + if file.fileType.lowercased() == "pdf" { + Button("Open") { + showQuickLook = true + } + .foregroundColor(.white) + } + + Button("Share") { + showShareSheet = true + } + .foregroundColor(.white) + } + } + } + } + .onAppear { + loadFileData() + } + .sheet(isPresented: $showShareSheet) { + if let url = temporaryFileURL { + ShareSheet(items: [url]) + } + } + .sheet(isPresented: $showQuickLook) { + if let url = temporaryFileURL { + QuickLookView(url: url) + } + } + } + + @ViewBuilder + private func contentView(for data: Data) -> some View { + if file.isImage, let image = UIImage(data: data) { + + ScrollView([.horizontal, .vertical]) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + } + } else if file.fileType.lowercased() == "pdf" { + + PDFViewerWrapper(data: data) + } else if file.fileType.lowercased() == "txt" { + + if let text = String(data: data, encoding: .utf8) { + ScrollView { + Text(text) + .foregroundColor(.white) + .font(.system(size: 14)) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + fileInfoView(data: data) + } + } else { + + fileInfoView(data: data) + } + } + + @ViewBuilder + private func fileInfoView(data: Data) -> some View { + VStack(spacing: 20) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 64)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileName) + .font(.headline) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + VStack(spacing: 8) { + Text("File size: \(FileManagerHelper.shared.formatFileSize(file.fileSize))") + .font(.subheadline) + .foregroundColor(.gray) + + Text("Type: \(file.fileType.uppercased())") + .font(.subheadline) + .foregroundColor(.gray) + + Text("Uploaded: \(file.uploadDate, style: .date)") + .font(.subheadline) + .foregroundColor(.gray) + } + + Button("Open with External App") { + showQuickLook = true + } + .padding() + .background(Color("Secondary")) + .foregroundColor(.black) + .cornerRadius(10) + } + .padding() + } + + private func loadFileData() { + Task { + + let data = FileManagerHelper.shared.loadFileWithFallback(from: file.localPath, courseCode: file.courseCode) + + await MainActor.run { + if let data = data { + fileData = data + createTemporaryFile(data: data) + } else { + loadError = "File not found or corrupted" + } + isLoading = false + } + } + } + + private func createTemporaryFile(data: Data) { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(file.fileName) + + do { + if FileManager.default.fileExists(atPath: tempURL.path) { + try FileManager.default.removeItem(at: tempURL) + } + try data.write(to: tempURL) + temporaryFileURL = tempURL + } catch { + print("Error creating temporary file: \(error)") + } + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray + } + } +} +struct PDFViewerWrapper: UIViewRepresentable { + let data: Data + + func makeUIView(context: Context) -> PDFView { + let pdfView = PDFView() + pdfView.backgroundColor = UIColor.clear + pdfView.autoScales = true + pdfView.displayMode = .singlePageContinuous + pdfView.displayDirection = .vertical + + if let document = PDFDocument(data: data) { + pdfView.document = document + } + + return pdfView + } + + func updateUIView(_ uiView: PDFView, context: Context) { + + } +} + +// MARK: - QuickLook View +struct QuickLookView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, QLPreviewControllerDataSource { + let parent: QuickLookView + + init(_ parent: QuickLookView) { + self.parent = parent + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + return 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + return parent.url as NSURL + } + } +} + +// MARK: - Enhanced FileManagerHelper Extension +extension FileManagerHelper { + + + func loadFileWithFallback(from storedPath: String, courseCode: String) -> Data? { + + if fileExists(at: storedPath), let data = loadFile(from: storedPath) { + return data + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: courseCode) + let reconstructedPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: reconstructedPath), let data = loadFile(from: reconstructedPath) { + return data + } + + + return findAndLoadFile(fileName: fileName, in: courseDir) + } + + + private func findAndLoadFile(fileName: String, in directory: URL) -> Data? { + do { + let contents = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + + for fileURL in contents { + if fileURL.lastPathComponent == fileName { + return try? Data(contentsOf: fileURL) + } + } + } catch { + print("Error searching directory: \(error)") + } + + return nil + } + + + func updateStoredFilePaths(files: [UploadedFile], modelContext: ModelContext) { + var hasChanges = false + + for file in files { + if !fileExists(at: file.localPath) { + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + file.localPath = newPath + hasChanges = true + } + } + + + if let thumbnailPath = file.thumbnailPath, !fileExists(at: thumbnailPath) { + let thumbnailFileName = URL(fileURLWithPath: thumbnailPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newThumbnailPath = courseDir.appendingPathComponent(thumbnailFileName).path + + if fileExists(at: newThumbnailPath) { + file.thumbnailPath = newThumbnailPath + hasChanges = true + } + } + } + + if hasChanges { + do { + try modelContext.save() + print("Updated \(files.count) file paths") + } catch { + print("Error updating file paths: \(error)") + } + } + } +} + + +// MARK: - Share Sheet +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + + } +} diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift index 7f8c6ac..8e513b7 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -99,7 +99,7 @@ struct QRCodeModalView: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .sheet(isPresented: $showingShareSheet) { - ShareSheet(items: [createInvitationLink(), "Join my circle '\(circleName)' on VITTY!"]) + ShareSheetQr(items: [createInvitationLink(), "Join my circle '\(circleName)' on VITTY!"]) } } @@ -129,13 +129,13 @@ struct QRCodeModalView: View { } } -struct ShareSheet: UIViewControllerRepresentable { +struct ShareSheetQr: UIViewControllerRepresentable { let items: [Any] - + func makeUIViewController(context: Context) -> UIActivityViewController { let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) return controller } - + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } diff --git a/VITTY/VITTY/Username/Views/UsernameView.swift b/VITTY/VITTY/Username/Views/UsernameView.swift index 5aa9f5c..0559677 100644 --- a/VITTY/VITTY/Username/Views/UsernameView.swift +++ b/VITTY/VITTY/Username/Views/UsernameView.swift @@ -189,7 +189,3 @@ struct UsernameView: View { } } -#Preview { - UsernameView() - .preferredColorScheme(.dark) -} diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index ec5785d..4c7e153 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -60,7 +60,7 @@ struct VITTYApp: App { }.modelContainer(sharedModelContainer) } var sharedModelContainer: ModelContainer { - let schema = Schema([TimeTable.self,Remainder.self,CreateNoteModel.self]) + let schema = Schema([TimeTable.self,Remainder.self,CreateNoteModel.self,UploadedFile.self]) let config = ModelConfiguration( "group.com.gdscvit.vittyioswidget" From 9558baac67f3d5ff7904f59bfdcd0778a112ea8b Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Wed, 25 Jun 2025 17:11:20 +0545 Subject: [PATCH 10/16] fix:back navigation friends timetableicon --- .../Connect/View/Freinds/View/Freinds.swift | 2 +- VITTY/VITTY/Home/View/HomeView.swift | 2 +- .../VITTY/TimeTable/Views/TimeTableView.swift | 185 ++++++++++-------- 3 files changed, 102 insertions(+), 87 deletions(-) diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index 029b05e..75b76d9 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -99,7 +99,7 @@ struct FriendsView: View { ScrollView { VStack(spacing: 10) { ForEach(filteredFriends, id: \.username) { friend in - NavigationLink(destination: TimeTableView(friend: friend)) { + NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { FriendRow(friend: friend) } } diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 84494c6..2b15a06 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -52,7 +52,7 @@ struct HomeView: View { ZStack { switch selectedPage { case 1: - TimeTableView(friend: nil) + TimeTableView(friend: nil,isFriendsTimeTable: false) case 2: ConnectPage(isCreatingGroup: $isCreatingGroup) case 3: diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index b32a10f..d60b6f3 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -8,120 +8,135 @@ struct TimeTableView: View { @Environment(\.modelContext) private var context private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - + @State private var viewModel = TimeTableViewModel() @State private var selectedLecture: Lecture? = nil @Query private var timetableItem : [TimeTable] + @Environment(\.dismiss) private var dismiss let friend: Friend? - + + var isFriendsTimeTable : Bool + private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String( describing: TimeTableView.self ) ) - + var body: some View { NavigationStack{ ZStack { BackgroundView() - switch viewModel.stage { - case .loading: - VStack { - Spacer() - ProgressView() - Spacer() - } - case .error: - VStack { - Spacer() - Text("It's an error!\(String(describing: authViewModel.loggedInBackendUser?.username))") - .font(Font.custom("Poppins-Bold", size: 24)) - Text("Sorry if you are late for your class!") + VStack{ + if isFriendsTimeTable { + HStack{ + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + } Spacer() - } - case .data: - VStack(spacing: 0) { - // Day selector - ScrollView(.horizontal) { - HStack { - ForEach(daysOfWeek, id: \.self) { day in - Text(day) - .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) - .frame(width: 60, height: 54) - .background( - daysOfWeek[viewModel.dayNo] == day - ? Color("Accent") : Color.clear - ) - .onTapGesture { - withAnimation { - viewModel.dayNo = daysOfWeek.firstIndex( - of: day - )! - viewModel.changeDay() - } - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } + }.padding(8) + } + switch viewModel.stage { + case .loading: + VStack { + Spacer() + ProgressView() + Spacer() } - .scrollIndicators(.hidden) - .background(Color("Secondary")) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - - - if viewModel.lectures.isEmpty { + case .error: + VStack { Spacer() - Text("No classes today!") + Text("It's an error!\(String(describing: authViewModel.loggedInBackendUser?.username))") .font(Font.custom("Poppins-Bold", size: 24)) - Text(StringConstants.noClassQuotesOffline.randomElement()!) + Text("Sorry if you are late for your class!") Spacer() - } else { - ScrollView { - VStack(spacing: 12) { - ForEach(viewModel.lectures.sorted()) { lecture in - LectureItemView(lecture: lecture) { - selectedLecture = lecture + } + case .data: + VStack(spacing: 0) { + // Day selector + ScrollView(.horizontal) { + HStack { + ForEach(daysOfWeek, id: \.self) { day in + Text(day) + .foregroundStyle(daysOfWeek[viewModel.dayNo] == day + ? Color("Background") : Color("Accent")) + .frame(width: 60, height: 54) + .background( + daysOfWeek[viewModel.dayNo] == day + ? Color("Accent") : Color.clear + ) + .onTapGesture { + withAnimation { + viewModel.dayNo = daysOfWeek.firstIndex( + of: day + )! + viewModel.changeDay() + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + .scrollIndicators(.hidden) + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + + + if viewModel.lectures.isEmpty { + Spacer() + Text("No classes today!") + .font(Font.custom("Poppins-Bold", size: 24)) + Text(StringConstants.noClassQuotesOffline.randomElement()!) + Spacer() + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(viewModel.lectures.sorted()) { lecture in + LectureItemView(lecture: lecture) { + selectedLecture = lecture + } } } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 100) } } } + } } - } - .sheet(item: $selectedLecture) { lecture in - LectureDetailView(lecture: lecture) - } - .onAppear { - logger.debug("onAppear triggered") - - - if let existing = timetableItem.first { - logger.debug("existing timetable found") - viewModel.timeTable = existing - viewModel.changeDay() - viewModel.stage = .data - } else { - logger.debug("no local timetable, fetching from API") - Task { - await viewModel.fetchTimeTable( - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), - authToken: authViewModel.loggedInBackendUser?.token ?? "" - ) - if let fetched = viewModel.timeTable { - context.insert(fetched) + .sheet(item: $selectedLecture) { lecture in + LectureDetailView(lecture: lecture) + }.navigationBarBackButtonHidden(true) + .onAppear { + logger.debug("onAppear triggered") + + + if let existing = timetableItem.first { + logger.debug("existing timetable found") + viewModel.timeTable = existing + viewModel.changeDay() + viewModel.stage = .data + } else { + logger.debug("no local timetable, fetching from API") + Task { + await viewModel.fetchTimeTable( + username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), + authToken: authViewModel.loggedInBackendUser?.token ?? "" + ) + if let fetched = viewModel.timeTable { + context.insert(fetched) + } } } } + + } - - } } -} + From 1a2d4bdf9c279c85f786abea76ab38fd53d8ceb6 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sat, 28 Jun 2025 22:31:11 +0545 Subject: [PATCH 11/16] feat : small bug fixes and ghost mode impl --- VITTY/VITTY/Academics/View/Courses.swift | 12 +- .../VITTY/Academics/View/RemindersData.swift | 246 +++++++++++++++- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 18 +- VITTY/VITTY/Auth/Views/LoginView.swift | 2 +- .../View/Circles/Components/QrCode.swift | 6 +- VITTY/VITTY/Connect/View/ConnectPage.swift | 121 +++++++- .../ViewModel/CommunityPageViewModel.swift | 21 +- VITTY/VITTY/Settings/View/SettingsView.swift | 149 ++++++++-- .../ViewModel/SettingsViewModel.swift | 130 +++++++-- VITTY/VITTY/TimeTable/Models/TimeTable.swift | 79 +++--- .../ViewModel/TimeTableViewModel.swift | 265 ++++++++++++++---- .../TimeTable/Views/LectureDetailView.swift | 28 +- .../TimeTable/Views/LectureItemView.swift | 29 +- .../VITTY/TimeTable/Views/TimeTableView.swift | 184 ++++++------ VITTY/VITTY/UserProfileSideBar/SideBar.swift | 120 ++++++-- VITTY/VITTY/Username/Views/UsernameView.swift | 6 +- 16 files changed, 1080 insertions(+), 336 deletions(-) diff --git a/VITTY/VITTY/Academics/View/Courses.swift b/VITTY/VITTY/Academics/View/Courses.swift index 66cdf43..c5f291a 100644 --- a/VITTY/VITTY/Academics/View/Courses.swift +++ b/VITTY/VITTY/Academics/View/Courses.swift @@ -15,17 +15,7 @@ struct CoursesView: View { VStack(spacing: 0) { SearchBar(searchText: $searchText) - HStack(spacing: 16) { - SemesterFilterButton(isSelected: isCurrentSemester, title: "Current Semester") - .onTapGesture { isCurrentSemester = true } - - SemesterFilterButton(isSelected: !isCurrentSemester, title: "All Semesters") - .onTapGesture { isCurrentSemester = false } - - Spacer() - } - .padding(.horizontal) - .padding(.top, 16) + VStack(spacing: 16) { ForEach(filtered) { course in diff --git a/VITTY/VITTY/Academics/View/RemindersData.swift b/VITTY/VITTY/Academics/View/RemindersData.swift index 7be6760..7ff7ae7 100644 --- a/VITTY/VITTY/Academics/View/RemindersData.swift +++ b/VITTY/VITTY/Academics/View/RemindersData.swift @@ -8,14 +8,19 @@ import SwiftUI import SwiftData + struct RemindersView: View { @Environment(\.modelContext) private var modelContext @Query private var allReminders: [Remainder] + @Query private var timeTables: [TimeTable] @State private var searchText = "" @State private var selectedTab = 0 + @State private var showingSubjectSelection = false + @State private var showingReminderCreation = false + @State private var selectedCourse: Course? - + // Your existing computed properties remain the same private var filteredReminders: [Remainder] { if searchText.isEmpty { return allReminders @@ -28,7 +33,6 @@ struct RemindersView: View { } } - private var groupedReminders: [ReminderGroup] { let grouped = Dictionary(grouping: filteredReminders) { reminder in Calendar.current.startOfDay(for: reminder.date) @@ -57,10 +61,16 @@ struct RemindersView: View { }.sorted { $0.daysToGo < $1.daysToGo } } + // Extract courses from timetable + private var availableCourses: [Course] { + let courses = timeTables.first.map { extractCourses(from: $0) } ?? [] + return courses + } + var body: some View { ScrollView { VStack(spacing: 0) { - // Search Bar + HStack { Image(systemName: "magnifyingglass") .foregroundColor(.gray) @@ -81,24 +91,30 @@ struct RemindersView: View { .padding(.horizontal) .padding(.top, 16) - // Status Tabs + HStack(spacing: 16) { StatusTabView(isSelected: selectedTab == 0, title: "Pending") .onTapGesture { selectedTab = 0 } StatusTabView(isSelected: selectedTab == 1, title: "Completed") .onTapGesture { selectedTab = 1 } Spacer() + + Button { - //TODO: to implement + showingSubjectSelection = true } label: { Image(systemName: "plus") + .foregroundColor(.blue) + .font(.system(size: 16, weight: .medium)) + .frame(width: 32, height: 32) + + } - } .padding(.horizontal) .padding(.top, 16) - // Reminder Groups + VStack(spacing: 24) { ForEach(groupedReminders, id: \.id) { group in if selectedTab == 0 && !group.items.filter({ !$0.isCompleted }).isEmpty { @@ -152,6 +168,25 @@ struct RemindersView: View { } .scrollIndicators(.hidden) .background(Color("Background").edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $showingSubjectSelection) { + SubjectSelectionView( + courses: availableCourses, + onCourseSelected: { course in + selectedCourse = course + showingSubjectSelection = false + showingReminderCreation = true + } + ) + } + .sheet(isPresented: $showingReminderCreation) { + if let course = selectedCourse { + ReminderView( + courseName: course.title, + slot: course.slot, + courseCode: course.code + ) + } + } } private func completeReminderItem(itemId: PersistentIdentifier) { @@ -162,8 +197,203 @@ struct RemindersView: View { } } } + + + private func extractCourses(from timetable: TimeTable) -> [Course] { + let allLectures = timetable.monday + timetable.tuesday + timetable.wednesday + + timetable.thursday + timetable.friday + timetable.saturday + + timetable.sunday + + let currentSemester = determineSemester(for: Date()) + let groupedLectures = Dictionary(grouping: allLectures, by: { $0.name }) + var result: [Course] = [] + + for title in groupedLectures.keys.sorted() { + if let lectures = groupedLectures[title] { + let uniqueSlot = Set(lectures.map { $0.slot }).sorted().joined(separator: " + ") + let uniqueCode = Set(lectures.map { $0.code }).sorted().joined(separator: " / ") + + result.append( + Course( + title: title, + slot: uniqueSlot, + code: uniqueCode, + semester: currentSemester, + isFavorite: false + ) + ) + } + } + + return result.sorted { $0.title < $1.title } + } + + private func determineSemester(for date: Date) -> String { + let month = Calendar.current.component(.month, from: date) + + switch month { + case 12, 1, 2: + return "Winter \(academicYear(for: date))" + case 3...6: + return "Summer \(academicYear(for: date))" + case 7...11: + return "Fall \(academicYear(for: date))" + default: + return "Unknown" + } + } + + private func academicYear(for date: Date) -> String { + let year = Calendar.current.component(.year, from: date) + let month = Calendar.current.component(.month, from: date) + if month < 3 { + return "\(year - 1)-\(String(format: "%02d", year % 100))" + } else { + return "\(year)-\(String(format: "%02d", (year + 1) % 100))" + } + } } +// MARK: - Subject Selection View +struct SubjectSelectionView: View { + let courses: [Course] + let onCourseSelected: (Course) -> Void + + @Environment(\.presentationMode) var presentationMode + @State private var searchText = "" + + private var filteredCourses: [Course] { + if searchText.isEmpty { + return courses + } else { + return courses.filter { course in + course.title.localizedCaseInsensitiveContains(searchText) || + course.code.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("Search subjects", text: $searchText) + .foregroundColor(.white) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark") + .foregroundColor(.gray) + } + } + } + .padding(10) + .background(Color("Secondary")) + .cornerRadius(8) + .padding(.horizontal) + .padding(.top, 16) + + ScrollView { + LazyVStack(spacing: 12) { + ForEach(filteredCourses) { course in + SubjectSelectionCard(course: course) { + onCourseSelected(course) + } + } + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 24) + } + + + if filteredCourses.isEmpty { + VStack(spacing: 16) { + Image(systemName: "book.closed") + .font(.system(size: 48)) + .foregroundColor(.gray) + + Text(searchText.isEmpty ? "No subjects available" : "No subjects found") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.gray) + + if !searchText.isEmpty { + Text("Try adjusting your search terms") + .font(.system(size: 14)) + .foregroundColor(.gray.opacity(0.7)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .navigationTitle("Select Subject") + .navigationBarTitleDisplayMode(.large) + .navigationBarItems( + leading: Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .foregroundColor(.red) + ) + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Subject Selection Card +struct SubjectSelectionCard: View { + let course: Course + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(course.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.system(size: 14)) + } + .padding(.top, 16) + .padding(.horizontal, 16) + + HStack { + Text(course.code) + .font(.system(size: 14)) + .foregroundColor(Color("Accent")) + + Spacer() + + Text("Slot: \(course.slot)") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color("Secondary").opacity(0.5)) + .cornerRadius(8) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 16).fill(Color("Secondary"))) + } + .buttonStyle(PlainButtonStyle()) + } +} struct StatusTabView: View { let isSelected: Bool let title: String @@ -348,7 +578,7 @@ struct ReminderItemView: View { } } -// Updated models to work with SwiftData + struct ReminderGroup: Identifiable { let id = UUID() let date: String diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 9684adb..e4fd2b2 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -67,15 +67,15 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") do { -// self.loggedInBackendUser = try await AuthAPIService.shared -// .signInUser( -// with: AuthRequestBody( -// uuid: loggedInFirebaseUser?.uid ?? "", -// reg_no: regNo, -// username: username -// ) -// ) - self.loggedInBackendUser = AppUser(name: "Rudrank Basant", picture: "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", role: "normal", token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJ1ZHJhbmsxMjNAZ21haWwuY29tIiwicm9sZSI6Im5vcm1hbCIsInVzZXJuYW1lIjoicnVkcmFuayJ9.m7YQwp7hLCBO1YXPNvwpaHCOXh5BZVa6BK7sTYVzUT4", username: "rudrank") + self.loggedInBackendUser = try await AuthAPIService.shared + .signInUser( + with: AuthRequestBody( + uuid: loggedInFirebaseUser?.uid ?? "", + reg_no: regNo, + username: username + ) + ) +// self.loggedInBackendUser = AppUser(name: "Rudrank Basant", picture: "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", role: "normal", token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJ1ZHJhbmsxMjNAZ21haWwuY29tIiwicm9sZSI6Im5vcm1hbCIsInVzZXJuYW1lIjoicnVkcmFuayJ9.m7YQwp7hLCBO1YXPNvwpaHCOXh5BZVa6BK7sTYVzUT4", username: "rudrank") } catch { diff --git a/VITTY/VITTY/Auth/Views/LoginView.swift b/VITTY/VITTY/Auth/Views/LoginView.swift index 38cd03f..2e5891a 100644 --- a/VITTY/VITTY/Auth/Views/LoginView.swift +++ b/VITTY/VITTY/Auth/Views/LoginView.swift @@ -154,7 +154,7 @@ struct CarouselItemView: View { .foregroundColor(Color.white) Text(item.subtitle) .font(.footnote) - .foregroundColor(Color("tfBlueLight")) + .foregroundColor(Color("Accent")) .multilineTextAlignment(.center) .frame(width: 400) .padding(.top, 1) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift index 8e513b7..05bcd18 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -39,9 +39,7 @@ struct QRCodeModalView: View { .font(.custom("Poppins-SemiBold", size: 18)) .foregroundColor(.white) - Text("Circle ID: \(groupCode)") - .font(.custom("Poppins-Regular", size: 12)) - .foregroundColor(.gray) + } @@ -82,7 +80,7 @@ struct QRCodeModalView: View { Text("Share Invitation") } .font(.custom("Poppins-SemiBold", size: 16)) - .foregroundColor(.white) + .foregroundColor(Color("Background")) .padding(.horizontal, 20) .padding(.vertical, 12) .background(Color("Accent")) diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index e3845f0..3655743 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -4,20 +4,20 @@ // // Created by Rujin Devkota on 2/27/25. - - import SwiftUI enum SheetType: Identifiable { case addCircleOptions case createGroup case joinGroup + case groupRequests var id: Int { switch self { case .addCircleOptions: return 0 case .createGroup: return 1 case .joinGroup: return 2 + case .groupRequests: return 3 } } } @@ -29,6 +29,7 @@ struct ConnectPage: View { @State private var isShowingRequestView = false @State var isCircleView = false @State private var activeSheet: SheetType? + @State private var showCircleMenu = false @Environment(\.dismiss) private var dismiss @Binding var isCreatingGroup : Bool @@ -80,15 +81,35 @@ struct ConnectPage: View { .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } else { Button(action: { - activeSheet = .addCircleOptions + showCircleMenu = true }) { - Image(systemName: "person.fill.badge.plus") + Image(systemName: "ellipsis") .foregroundColor(.white) + .font(.system(size: 18)) } .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } } - + .overlay( + Group { + if showCircleMenu { + ConnectCircleMenuView( + onCreateGroup: { + activeSheet = .createGroup + }, + onJoinGroup: { + activeSheet = .joinGroup + }, + onGroupRequests: { + activeSheet = .groupRequests + }, + onCancel: { + showCircleMenu = false + } + ) + } + } + ) .sheet(item: $activeSheet) { sheetType in switch sheetType { case .addCircleOptions: @@ -97,6 +118,8 @@ struct ConnectPage: View { CreateGroup(groupCode: .constant(""), token:authViewModel.loggedInBackendUser?.token ?? "" ) case .joinGroup: JoinGroup(groupCode: .constant("")) + case .groupRequests: + CircleRequestsView() } } .onAppear { @@ -133,6 +156,94 @@ struct ConnectPage: View { } } +struct ConnectCircleMenuView: View { + let onCreateGroup: () -> Void + let onJoinGroup: () -> Void + let onGroupRequests: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 0) { + Button(action: { + onCancel() + onCreateGroup() + }) { + HStack { + Image("creategroup") + .resizable() + .frame(width: 24, height: 24) + Text("Create Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + onJoinGroup() + }) { + HStack { + Image("joingroup") + .resizable() + .frame(width: 24, height: 24) + Text("Join Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + onGroupRequests() + }) { + HStack { + Image(systemName: "person.badge.plus") + .foregroundColor(.white) + Text("Group Requests") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Background")) + } + } + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + struct AddCircleOptionsView: View { @Binding var activeSheet: SheetType? @Environment(\.dismiss) private var dismiss diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 9139610..e09862e 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -115,15 +115,28 @@ class CommunityPageViewModel { AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() - .responseDecodable(of: CircleRequestResponse.self) { response in + .responseData { response in DispatchQueue.main.async { self.loadingCircleRequests = false switch response.result { case .success(let data): - self.circleRequests = data.data - self.errorCircleRequests = false - self.logger.info("Successfully fetched circle requests: \(data.data.count) requests") + do { + let decodedResponse = try JSONDecoder().decode(CircleRequestResponse.self, from: data) + self.circleRequests = decodedResponse.data + self.errorCircleRequests = false + self.logger.info("Successfully fetched circle requests: \(decodedResponse.data.count) requests") + } catch { + self.logger.error("Error decoding circle requests: \(error)") + + if let jsonString = String(data: data, encoding: .utf8) { + self.logger.info("Raw response: \(jsonString)") + } + + response + self.circleRequests = [] + self.errorCircleRequests = false + } case .failure(let error): self.logger.error("Error fetching circle requests: \(error)") diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 08c821a..68ccf38 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -1,6 +1,9 @@ import SwiftUI import SwiftData + + + struct SettingsView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.dismiss) private var dismiss @@ -12,6 +15,7 @@ struct SettingsView: View { @State private var showDaySelection = false @State private var selectedDay: String? = nil + @State private var showResetAlert = false private let selectedDayKey = "SelectedSaturdayDay" @@ -45,11 +49,9 @@ struct SettingsView: View { } SettingsSectionView(title: "Class Settings") { - VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation(.easeInOut(duration: 0.5)) { - showDaySelection.toggle() - } + VStack(alignment: .leading, spacing: 12) { + Button { + showDaySelection.toggle() } label: { SettingsRowView( icon: "calendar.badge.plus", @@ -57,6 +59,7 @@ struct SettingsView: View { subtitle: selectedDay == nil ? "Select a day to copy classes to Saturday" : "Copy \(selectedDay!) classes to Saturday" ) } + .buttonStyle(PlainButtonStyle()) if showDaySelection { VStack(alignment: .leading, spacing: 8) { @@ -77,11 +80,7 @@ struct SettingsView: View { selectedDay = day UserDefaults.standard.set(day, forKey: selectedDayKey) copyLecturesToSaturday(from: day) - - - withAnimation(.easeInOut(duration: 0.3)) { - showDaySelection = false - } + showDaySelection = false } } } @@ -92,17 +91,29 @@ struct SettingsView: View { )) } - SettingsRowView( - icon: "pencil.and.ellipsis.rectangle", - title: "Update Timetable", - subtitle: "Keep your timetable up-to-date. Don't miss a class." - ).onTapGesture { + + Button { + showResetAlert = true + } label: { + SettingsRowView( + icon: "trash.circle.fill", + title: "Reset Saturday Classes", + subtitle: "Remove all classes from Saturday" + ) + } + .buttonStyle(PlainButtonStyle()) + Button { if let url = URL(string: "https://vitty.dscvit.com") { UIApplication.shared.open(url) } + } label: { + SettingsRowView( + icon: "pencil.and.ellipsis.rectangle", + title: "Update Timetable", + subtitle: "Keep your timetable up-to-date. Don't miss a class." + ) } - - + .buttonStyle(PlainButtonStyle()) } } @@ -126,6 +137,20 @@ struct SettingsView: View { } .scrollContentBackground(.hidden) } + + + if showResetAlert { + ResetSaturdayAlert( + onCancel: { + showResetAlert = false + }, + onReset: { + resetSaturdayClasses() + showResetAlert = false + } + ) + .zIndex(1) + } } .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) @@ -150,6 +175,38 @@ struct SettingsView: View { selectedDay = UserDefaults.standard.string(forKey: selectedDayKey) } + private func resetSaturdayClasses() { + guard let timeTable = timeTables.first else { return } + + + let newTimeTable = TimeTable( + monday: timeTable.monday, + tuesday: timeTable.tuesday, + wednesday: timeTable.wednesday, + thursday: timeTable.thursday, + friday: timeTable.friday, + saturday: [], // Empty Saturday + sunday: timeTable.sunday + ) + + + modelContext.delete(timeTable) + modelContext.insert(newTimeTable) + + + do { + try modelContext.save() + print("Successfully reset Saturday classes") + + + UserDefaults.standard.removeObject(forKey: selectedDayKey) + selectedDay = nil + + } catch { + print("Error saving context: \(error)") + } + } + private func copyLecturesToSaturday(from day: String) { guard let timeTable = timeTables.first else { return } @@ -257,8 +314,11 @@ struct SettingsView: View { .font(.system(size: 12)) .foregroundColor(.gray.opacity(0.8)) } + + Spacer() } .padding(.vertical, 6) + .contentShape(Rectangle()) } } @@ -287,3 +347,58 @@ struct SettingsView: View { } } } + +// Custom Reset Alert Component +struct ResetSaturdayAlert: View { + let onCancel: () -> Void + let onReset: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Reset Saturday Classes?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to remove all classes from Saturday? This action cannot be undone.") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onReset) { + Text("Reset") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .onTapGesture { + + } + } +} diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift index 8c9d67b..64bf1f7 100644 --- a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -2,13 +2,11 @@ import Foundation import SwiftUI import UserNotifications - class SettingsViewModel : ObservableObject{ - var notificationsEnabled: Bool = false { + @Published var notificationsEnabled: Bool = false { didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") if notificationsEnabled { - if let timetable = self.timetable { self.scheduleAllNotifications(from: timetable) } @@ -19,12 +17,12 @@ class SettingsViewModel : ObservableObject{ } } - var timetable: TimeTable? - var showNotificationDisabledAlert = false + @Published var timetable: TimeTable? + @Published var showNotificationDisabledAlert = false init(timetable: TimeTable? = nil) { self.timetable = timetable - // Load the stored value + self.notificationsEnabled = UserDefaults.standard.bool(forKey: "notificationsEnabled") checkNotificationAuthorization() } @@ -40,8 +38,6 @@ class SettingsViewModel : ObservableObject{ } func requestNotificationPermission() { - // Since you handle permission elsewhere, this method can be simplified or removed - // Just schedule notifications if permission is already granted UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { if settings.authorizationStatus == .authorized { @@ -56,24 +52,35 @@ class SettingsViewModel : ObservableObject{ } func scheduleAllNotifications(from timetable: TimeTable) { + // Clear existing notifications first + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + let weekdays: [(Int, [Lecture])] = [ - (2, timetable.monday), - (3, timetable.tuesday), - (4, timetable.wednesday), - (5, timetable.thursday), - (6, timetable.friday), - (7, timetable.saturday), - (1, timetable.sunday) + (2, timetable.monday), // Monday = 2 + (3, timetable.tuesday), // Tuesday = 3 + (4, timetable.wednesday), // Wednesday = 4 + (5, timetable.thursday), // Thursday = 5 + (6, timetable.friday), // Friday = 6 + (7, timetable.saturday), // Saturday = 7 + (1, timetable.sunday) // Sunday = 1 ] for (weekday, lectures) in weekdays { for lecture in lectures { - guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { continue } + guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { + print("Failed to parse time for lecture: \(lecture.name) with time: \(lecture.startTime)") + continue + } + scheduleNotification(for: lecture.name, at: startDate, title: "Class Starting", minutesBefore: 0) + + scheduleNotification(for: lecture.name, at: startDate, title: "Upcoming Class", minutesBefore: 10) } } + + print("Scheduled notifications for all lectures") } private func scheduleNotification(for lectureName: String, at date: Date, title: String, minutesBefore: Int) { @@ -87,28 +94,89 @@ class SettingsViewModel : ObservableObject{ let triggerComponents = Calendar.current.dateComponents([.weekday, .hour, .minute], from: triggerDate) let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: true) + let identifier = "\(lectureName)-\(title)-\(minutesBefore)min-weekday\(triggerComponents.weekday ?? 0)" let request = UNNotificationRequest( - identifier: "\(lectureName)-\(title)-\(triggerDate)", + identifier: identifier, content: content, trigger: trigger ) - UNUserNotificationCenter.current().add(request) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error scheduling notification: \(error)") + } else { + print("Successfully scheduled notification: \(identifier)") + } + } } + private func parseLectureTime(_ timeString: String, weekday: Int) -> Date? { - var cleaned = timeString.components(separatedBy: "T").last ?? "" - cleaned = cleaned.components(separatedBy: "Z").first ?? "" - - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - guard let time = formatter.date(from: cleaned) else { return nil } - - var components = Calendar.current.dateComponents([.year, .month, .weekOfYear], from: Date()) - components.weekday = weekday - components.hour = Calendar.current.component(.hour, from: time) - components.minute = Calendar.current.component(.minute, from: time) - - return Calendar.current.nextDate(after: Date(), matching: components, matchingPolicy: .nextTime) + + let formattedTimeString = formatTime(time: timeString) + + + if formattedTimeString == "Failed to parse the time string." { + return nil + } + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mm a" + timeFormatter.locale = Locale(identifier: "en_US_POSIX") + + guard let timeDate = timeFormatter.date(from: formattedTimeString) else { + print("Failed to parse formatted time: \(formattedTimeString)") + return nil + } + + + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute], from: timeDate) + + + let today = Date() + let currentWeekday = calendar.component(.weekday, from: today) + + + let daysFromToday = weekday - currentWeekday + let targetDate = calendar.date(byAdding: .day, value: daysFromToday, to: today) ?? today + + + var finalDateComponents = calendar.dateComponents([.year, .month, .day], from: targetDate) + finalDateComponents.hour = timeComponents.hour + finalDateComponents.minute = timeComponents.minute + finalDateComponents.second = 0 + + guard let lectureDate = calendar.date(from: finalDateComponents) else { + print("Failed to create lecture date") + return nil + } + + + if weekday == currentWeekday && lectureDate < today { + return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) + } + + + if lectureDate < today { + return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) + } + + return lectureDate + } + + // Your existing formatTime function + private func formatTime(time: String) -> String { + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return formattedTime + } else { + return "Failed to parse the time string." + } } } diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index 61cfecd..4c933d3 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -208,21 +208,26 @@ class Lecture: Codable, Identifiable, Comparable { try container.encode(endTime, forKey: .endTime) } } + extension TimeTable { var isEmpty: Bool { monday.isEmpty && tuesday.isEmpty && wednesday.isEmpty && thursday.isEmpty && friday.isEmpty && saturday.isEmpty && sunday.isEmpty } - private func extractStartDate(from timeString: String) -> Date? { - let components = timeString.components(separatedBy: " - ") - guard let startTimeString = components.first else { return nil } + + private func extractStartTime(from lecture: Lecture) -> Date? { + let formattedTime = formatTime(time: lecture.startTime) + + + guard formattedTime != "Failed to parse the time string." else { return nil } - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.locale = Locale(identifier: "en_US_POSIX") + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + + return formatter.date(from: formattedTime) + } - return formatter.date(from: startTimeString) - } func classesFor(date: Date) -> [Classes] { let calendar = Calendar.current @@ -248,37 +253,45 @@ extension TimeTable { ) } - return mapped.sorted { - guard let d1 = extractStartDate(from: $0.time), - let d2 = extractStartDate(from: $1.time) else { + // Sort using the original lecture objects instead of formatted strings + return lectures.sorted { lecture1, lecture2 in + guard let time1 = extractStartTime(from: lecture1), + let time2 = extractStartTime(from: lecture2) else { return false } - return d1 < d2 + return time1 < time2 + }.map { + Classes( + title: $0.name, + time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", + slot: $0.slot + ) } } - - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return formattedTime - } else { - return "Failed to parse the time string." + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } } - } + func isDifferentFrom(_ other: TimeTable) -> Bool { - return monday != other.monday || - tuesday != other.tuesday || - wednesday != other.wednesday || - thursday != other.thursday || - friday != other.friday || - sunday != other.sunday - } + return monday != other.monday || + tuesday != other.tuesday || + wednesday != other.wednesday || + thursday != other.thursday || + friday != other.friday || + saturday != other.saturday || + sunday != other.sunday + } } diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index 97b6bf9..b66bc69 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -10,71 +10,214 @@ import OSLog import SwiftData public enum Stage { - case loading - case error - case data + case loading + case error + case data } -extension TimeTableView { - - @Observable - class TimeTableViewModel { - - var timeTable: TimeTable? - var stage: Stage = .loading - var lectures = [Lecture]() - var dayNo = Date.convertToMondayWeek() - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: TimeTableViewModel.self - ) - ) - - func changeDay() { - switch dayNo { - case 0: - self.lectures = timeTable?.monday ?? [] - case 1: - self.lectures = timeTable?.tuesday ?? [] - case 2: - self.lectures = timeTable?.wednesday ?? [] - case 3: - self.lectures = timeTable?.thursday ?? [] - case 4: - self.lectures = timeTable?.friday ?? [] - case 5: - self.lectures = timeTable?.saturday ?? [] - case 6: - self.lectures = timeTable?.sunday ?? [] - default: - self.lectures = [] - } - } +extension TimeTableView { + @Observable + class TimeTableViewModel { - - func fetchTimeTable(username: String, authToken: String) async { - logger.info("Fetching TimeTable Started") - do { - stage = .loading - let data = try await TimeTableAPIService.shared.getTimeTable( - with: username, - authToken: authToken - ) - logger.info("TimeTable Fetched from API") - timeTable = data - changeDay() - stage = .data - } - catch { - logger.error("\(error)") - stage = .error - } - logger.info("Fetching TimeTable Ended") - } + var timeTable: TimeTable? + var stage: Stage = .loading + var lectures = [Lecture]() + var dayNo = Date.convertToMondayWeek() + + private var hasSyncedThisSession = false + private var isSyncing = false + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String( + describing: TimeTableViewModel.self + ) + ) + + func changeDay() { + switch dayNo { + case 0: + self.lectures = timeTable?.monday ?? [] + case 1: + self.lectures = timeTable?.tuesday ?? [] + case 2: + self.lectures = timeTable?.wednesday ?? [] + case 3: + self.lectures = timeTable?.thursday ?? [] + case 4: + self.lectures = timeTable?.friday ?? [] + case 5: + self.lectures = timeTable?.saturday ?? [] + case 6: + self.lectures = timeTable?.sunday ?? [] + default: + self.lectures = [] + } + } + + @MainActor + func loadTimeTable( + existingTimeTable: TimeTable?, + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Starting timetable loading process") + + if let existing = existingTimeTable { + logger.debug("Using existing local timetable") + timeTable = existing + changeDay() + stage = .data + print("\(existing)") + + if !hasSyncedThisSession && !isSyncing { + Task { + await backgroundSync( + localTimeTable: existing, + username: username, + authToken: authToken + ) + } + + } + } else { + logger.debug("No local timetable, fetching from API") + await fetchTimeTableFromAPI( + username: username, + authToken: authToken, + context: context + ) + } + } - + private func backgroundSync( + localTimeTable: TimeTable, + username: String, + authToken: String + ) async { + guard !isSyncing else { return } + + isSyncing = true + hasSyncedThisSession = true + + logger.info("Starting background sync") + + do { + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + logger.info("Background sync: Fetched remote timetable") + + if shouldUpdateLocalTimeTable(local: localTimeTable, remote: remoteTimeTable) { + logger.info("Background sync: Timetables differ, updating local data") + await updateLocalTimeTable(newTimeTable: remoteTimeTable) + } else { + logger.info("Background sync: Timetables are identical, no update needed") + } + + } catch { + logger.error("Background sync failed: \(error)") + } + + isSyncing = false + } - } + private func shouldUpdateLocalTimeTable(local: TimeTable, remote: TimeTable) -> Bool { + let daysToCompare = [ + (local.monday, remote.monday), + (local.tuesday, remote.tuesday), + (local.wednesday, remote.wednesday), + (local.thursday, remote.thursday), + (local.friday, remote.friday), + (local.sunday, remote.sunday) + ] + + for (localDay, remoteDay) in daysToCompare { + if !areLectureArraysEqual(localDay, remoteDay) { + return true + } + } + + return false + } + + private func areLectureArraysEqual(_ local: [Lecture], _ remote: [Lecture]) -> Bool { + guard local.count == remote.count else { return false } + + let sortedLocal = local.sorted { $0.startTime < $1.startTime } + let sortedRemote = remote.sorted { $0.startTime < $1.startTime } + + for (localLecture, remoteLecture) in zip(sortedLocal, sortedRemote) { + if !areLecturesEqual(localLecture, remoteLecture) { + return false + } + } + + return true + } + + private func areLecturesEqual(_ local: Lecture, _ remote: Lecture) -> Bool { + return local.name == remote.name && + local.code == remote.code && + local.venue == remote.venue && + local.slot == remote.slot && + local.type == remote.type && + local.startTime == remote.startTime && + local.endTime == remote.endTime + } + + + + @MainActor + private func updateLocalTimeTable(newTimeTable: TimeTable) async { + timeTable = newTimeTable + changeDay() + logger.info("Timetable updated in memory, view will handle persistence") + } + + @MainActor + private func fetchTimeTableFromAPI( + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Fetching TimeTable from API") + + do { + stage = .loading + let data = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + logger.info("TimeTable fetched from API") + + timeTable = data + changeDay() + stage = .data + + context.insert(data) + hasSyncedThisSession = true + + } catch { + logger.error("API fetch failed: \(error)") + stage = .error + } + } + + var updatedTimeTable: TimeTable? { + timeTable + } + + func resetSyncStatus() { + hasSyncedThisSession = false + logger.debug("Sync status reset") + } + } } + + diff --git a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift index 8aa7a11..8f16f2a 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift @@ -89,19 +89,19 @@ struct LectureDetailView: View { } } - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + private func formatTime(time: String) -> String { + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return (formattedTime) - } - else { - return ("Failed to parse the time string.") - } - } + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } + } } diff --git a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift index b557ed3..206f806 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift @@ -59,26 +59,21 @@ struct LectureItemView: View { } private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return formattedTime - } else { - return "Failed to parse the time string." + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } } - } } -#Preview { - LectureItemView( - lecture: Lecture(name: "hello", code: "qww", venue: "123", slot: "asd", type: "asad", startTime: "time1", endTime: "time") - , onTap: {} - ) -} diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index d60b6f3..7fa9a78 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -1,21 +1,22 @@ + import OSLog import SwiftData import SwiftUI - struct TimeTableView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] @State private var viewModel = TimeTableViewModel() @State private var selectedLecture: Lecture? = nil - @Query private var timetableItem : [TimeTable] + @Query private var timetableItem: [TimeTable] @Environment(\.dismiss) private var dismiss let friend: Friend? - var isFriendsTimeTable : Bool + var isFriendsTimeTable: Bool private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, @@ -25,12 +26,12 @@ struct TimeTableView: View { ) var body: some View { - NavigationStack{ + NavigationStack { ZStack { BackgroundView() - VStack{ + VStack { if isFriendsTimeTable { - HStack{ + HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") .foregroundColor(Color("Accent")).font(.title2) @@ -38,105 +39,102 @@ struct TimeTableView: View { Spacer() }.padding(8) } - switch viewModel.stage { - case .loading: - VStack { - Spacer() - ProgressView() - Spacer() + + switch viewModel.stage { + case .loading: + VStack { + Spacer() + ProgressView() + Spacer() + } + case .error: + VStack { + Spacer() + Text("It's an error!\(String(describing: authViewModel.loggedInBackendUser?.username))") + .font(Font.custom("Poppins-Bold", size: 24)) + Text("Sorry if you are late for your class!") + Spacer() + } + case .data: + VStack(spacing: 0) { + // Day selector + ScrollView(.horizontal) { + HStack { + ForEach(daysOfWeek, id: \.self) { day in + Text(day) + .foregroundStyle(daysOfWeek[viewModel.dayNo] == day + ? Color("Background") : Color("Accent")) + .frame(width: 60, height: 54) + .background( + daysOfWeek[viewModel.dayNo] == day + ? Color("Accent") : Color.clear + ) + .onTapGesture { + withAnimation { + viewModel.dayNo = daysOfWeek.firstIndex( + of: day + )! + viewModel.changeDay() + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } } - case .error: - VStack { + .scrollIndicators(.hidden) + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + + if viewModel.lectures.isEmpty { Spacer() - Text("It's an error!\(String(describing: authViewModel.loggedInBackendUser?.username))") + Text("No classes today!") .font(Font.custom("Poppins-Bold", size: 24)) - Text("Sorry if you are late for your class!") + Text(StringConstants.noClassQuotesOffline.randomElement()!) Spacer() - } - case .data: - VStack(spacing: 0) { - // Day selector - ScrollView(.horizontal) { - HStack { - ForEach(daysOfWeek, id: \.self) { day in - Text(day) - .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) - .frame(width: 60, height: 54) - .background( - daysOfWeek[viewModel.dayNo] == day - ? Color("Accent") : Color.clear - ) - .onTapGesture { - withAnimation { - viewModel.dayNo = daysOfWeek.firstIndex( - of: day - )! - viewModel.changeDay() - } - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } - } - .scrollIndicators(.hidden) - .background(Color("Secondary")) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - - - if viewModel.lectures.isEmpty { - Spacer() - Text("No classes today!") - .font(Font.custom("Poppins-Bold", size: 24)) - Text(StringConstants.noClassQuotesOffline.randomElement()!) - Spacer() - } else { - ScrollView { - VStack(spacing: 12) { - ForEach(viewModel.lectures.sorted()) { lecture in - LectureItemView(lecture: lecture) { - selectedLecture = lecture - } + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(viewModel.lectures.sorted()) { lecture in + LectureItemView(lecture: lecture) { + selectedLecture = lecture } } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 100) } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) } } } } } - .sheet(item: $selectedLecture) { lecture in - LectureDetailView(lecture: lecture) - }.navigationBarBackButtonHidden(true) - .onAppear { - logger.debug("onAppear triggered") - - - if let existing = timetableItem.first { - logger.debug("existing timetable found") - viewModel.timeTable = existing - viewModel.changeDay() - viewModel.stage = .data - } else { - logger.debug("no local timetable, fetching from API") - Task { - await viewModel.fetchTimeTable( - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), - authToken: authViewModel.loggedInBackendUser?.token ?? "" - ) - if let fetched = viewModel.timeTable { - context.insert(fetched) - } - } - } - } - - + } + } + .sheet(item: $selectedLecture) { lecture in + LectureDetailView(lecture: lecture) + } + .navigationBarBackButtonHidden(true) + .onAppear { + logger.debug("onAppear triggered") + loadTimetable() + } + .onChange(of: scenePhase) { _, newPhase in + + if newPhase == .active { + viewModel.resetSyncStatus() } } } - + + private func loadTimetable() { + Task { + await viewModel.loadTimeTable( + existingTimeTable: timetableItem.first, + username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), + authToken: authViewModel.loggedInBackendUser?.token ?? "", + context: context + ) + } + } +} diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index d6340ce..fc70d55 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -1,22 +1,24 @@ import SwiftUI + + + struct UserProfileSidebar: View { @Environment(AuthViewModel.self) private var authViewModel @Binding var isPresented: Bool @State private var ghostMode: Bool = false - + @State private var isUpdatingGhostMode: Bool = false + var body: some View { ZStack(alignment: .topTrailing) { Button { - withAnimation(.easeInOut(duration: 0.8)) { - isPresented = false - } + isPresented = false } label: { Image(systemName: "xmark") .foregroundColor(.white) .padding() } - + VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 8) { UserImage( @@ -24,56 +26,65 @@ struct UserProfileSidebar: View { height: 60, width: 60 ) - Text(authViewModel.loggedInBackendUser?.name ?? "User") .font(Font.custom("Poppins-Bold", size: 18)) .foregroundColor(.white) - Text("@\(authViewModel.loggedInBackendUser?.username ?? "")") .font(Font.custom("Poppins-Regular", size: 14)) .foregroundColor(.white.opacity(0.8)) } .padding(.top, 40) - + Divider().background(Color.clear) - + NavigationLink { EmptyClassRoom() } label: { MenuOption(icon: "emptyclassroom", title: "Find Empty Classroom") } - + NavigationLink { SettingsView() } label: { MenuOption(icon: "settings", title: "Settings") } - + Divider().background(Color.clear) - + MenuOption(icon: "share", title: "Share") MenuOption(icon: "support", title: "Support") MenuOption(icon: "about", title: "About") - + Divider().background(Color.clear) - + VStack(alignment: .leading, spacing: 4) { Text("Ghost Mode") .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) - Text("(your timetable will be visible only to you)") .font(Font.custom("Poppins-Regular", size: 12)) .foregroundColor(.white.opacity(0.7)) - - Toggle("", isOn: $ghostMode) - .labelsHidden() - .toggleStyle(SwitchToggleStyle(tint: Color("Accent"))) - .padding(.top, 4) + + HStack { + Toggle("", isOn: $ghostMode) + .labelsHidden() + .toggleStyle(SwitchToggleStyle(tint: Color("Accent"))) + .disabled(isUpdatingGhostMode) + .padding(.top, 4) + .onChange(of: ghostMode) { oldValue, newValue in + updateGhostMode(enabled: newValue) + } + + if isUpdatingGhostMode { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(.white) + } + } } - + Spacer() - + Button { authViewModel.signOut() } label: { @@ -91,24 +102,83 @@ struct UserProfileSidebar: View { .frame(width: UIScreen.main.bounds.width * 0.75, alignment: .leading) .frame(maxHeight: .infinity) .background(Color("Background")) + .transition(.move(edge: .trailing)) + } + .animation(.easeInOut(duration: 0.3), value: isPresented) + .onAppear { + loadGhostModeState() + } + } + + // MARK: - Ghost Mode Functions + + private func loadGhostModeState() { + + let username = authViewModel.loggedInBackendUser?.username ?? "" + ghostMode = UserDefaults.standard.bool(forKey: "ghostMode_\(username)") + } + + private func updateGhostMode(enabled: Bool) { + guard let username = authViewModel.loggedInBackendUser?.username, + let token = authViewModel.loggedInBackendUser?.token else { + return } + + isUpdatingGhostMode = true + + + let endpoint = enabled ? "ghost" : "alive" + let urlString = "\(APIConstants.base_url)friends/\(endpoint)/\(username)" + + guard let url = URL(string: urlString) else { + isUpdatingGhostMode = false + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isUpdatingGhostMode = false + + if let error = error { + print("Ghost mode update failed: \(error.localizedDescription)") + + ghostMode = !enabled + return + } + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + + UserDefaults.standard.set(enabled, forKey: "ghostMode_\(username)") + print("Ghost mode \(enabled ? "enabled" : "disabled") successfully") + } else { + print("Ghost mode update failed with status code: \(httpResponse.statusCode)") + + ghostMode = !enabled + } + } + } + }.resume() } } struct MenuOption: View { let icon: String let title: String - + var body: some View { HStack(spacing: 16) { Image(icon) .foregroundColor(.white) .frame(width: 24) - Text(title) .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) } } } - diff --git a/VITTY/VITTY/Username/Views/UsernameView.swift b/VITTY/VITTY/Username/Views/UsernameView.swift index 0559677..4aa7ef9 100644 --- a/VITTY/VITTY/Username/Views/UsernameView.swift +++ b/VITTY/VITTY/Username/Views/UsernameView.swift @@ -66,7 +66,7 @@ struct UsernameView: View { TextField("Username", text: $username) .padding() } - .background(Color("tfBlue")) + .cornerRadius(18) .padding(.top) Text(userNameErrorString) @@ -76,7 +76,7 @@ struct UsernameView: View { TextField("Registration No.", text: $regNo) .padding() } - .background(Color("tfBlue")) + .cornerRadius(18) .padding(.top) Text(regNoErrorString) @@ -111,7 +111,7 @@ struct UsernameView: View { } } - .background(Color("brightBlue")) + .cornerRadius(18) } .padding(.horizontal) From e4d96e0a56dfaa0b01ba46502bb6e4b60acbf69e Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sat, 28 Jun 2025 22:58:32 +0545 Subject: [PATCH 12/16] fix : support link and small fixes --- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 4 ++-- VITTY/VITTY/Connect/Models/Friend.swift | 14 -------------- .../View/Circles/View/InsideCircle.swift | 19 ++----------------- VITTY/VITTY/Settings/View/SettingsView.swift | 2 +- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 10 +++++++--- VITTY/VITTYApp.swift | 6 ++++++ 6 files changed, 18 insertions(+), 37 deletions(-) diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index e4fd2b2..addd15e 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -23,7 +23,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { var loggedInFirebaseUser: User? var loggedInBackendUser: AppUser? -// = AppUser(name: "Rudrank Basant", picture: "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", role: "normal", token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJ1ZHJhbmsxMjNAZ21haWwuY29tIiwicm9sZSI6Im5vcm1hbCIsInVzZXJuYW1lIjoicnVkcmFuayJ9.m7YQwp7hLCBO1YXPNvwpaHCOXh5BZVa6BK7sTYVzUT4", username: "rudrank") + var isLoading: Bool = false var isLoadingApple: Bool = false @@ -75,7 +75,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { username: username ) ) -// self.loggedInBackendUser = AppUser(name: "Rudrank Basant", picture: "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", role: "normal", token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJ1ZHJhbmsxMjNAZ21haWwuY29tIiwicm9sZSI6Im5vcm1hbCIsInVzZXJuYW1lIjoicnVkcmFuayJ9.m7YQwp7hLCBO1YXPNvwpaHCOXh5BZVa6BK7sTYVzUT4", username: "rudrank") + } catch { diff --git a/VITTY/VITTY/Connect/Models/Friend.swift b/VITTY/VITTY/Connect/Models/Friend.swift index 191760c..e1a6008 100644 --- a/VITTY/VITTY/Connect/Models/Friend.swift +++ b/VITTY/VITTY/Connect/Models/Friend.swift @@ -51,18 +51,4 @@ struct Friend: Decodable { } } -extension Friend { - static var sampleFriend: Friend { - return Friend( - currentStatus: CurrentStatus(status: "free"), - friendStatus: "friends", - friendsCount: 2, - mutualFriendsCount: 2, - name: "Rudrank Basant", - picture: - "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", - username: "rudrank" - ) - } -} diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index a9170df..16c4279 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -87,24 +87,9 @@ struct CircleMenuView: View { Divider() .background(Color.gray.opacity(0.3)) - Button(action: { - onCancel() - onGroupRequests() - }) { - HStack { - Image(systemName: "person.badge.plus") - .foregroundColor(.white) - Text("Group Requests") - .font(.custom("Poppins-Regular", size: 16)) - .foregroundColor(.white) - Spacer() - } - .padding() - .background(Color("Background")) - } + + - Divider() - .background(Color.gray.opacity(0.3)) Button(action: onCancel) { Text("Cancel") diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 68ccf38..19fbd36 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -49,7 +49,7 @@ struct SettingsView: View { } SettingsSectionView(title: "Class Settings") { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { Button { showDaySelection.toggle() } label: { diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index fc70d55..a362897 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -51,9 +51,12 @@ struct UserProfileSidebar: View { Divider().background(Color.clear) - MenuOption(icon: "share", title: "Share") - MenuOption(icon: "support", title: "Support") - MenuOption(icon: "about", title: "About") +// MenuOption(icon: "share", title: "Share") + MenuOption(icon: "support", title: "Support").onTapGesture { + let supportUrl = URL(string: "https://github.com/GDGVIT/vitty-ios/issues/new?template=bug_report.md") + UIApplication.shared.open(supportUrl!) + } +// MenuOption(icon: "about", title: "About") Divider().background(Color.clear) @@ -171,6 +174,7 @@ struct MenuOption: View { let icon: String let title: String + var body: some View { HStack(spacing: 16) { Image(icon) diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index 4c7e153..af10f0d 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -38,6 +38,12 @@ import SwiftData - use // MARK: when u create a function, it helps to navigate. */ + + + +/// Empty classrooms testing +/// empty sheet in reaminder view +/// @main struct VITTYApp: App { From a2ca598226886ec7b83efd9a0ed03ca8fc229cdf Mon Sep 17 00:00:00 2001 From: rujinDev <71916379+rujin2003@users.noreply.github.com> Date: Sun, 29 Jun 2025 00:10:19 +0545 Subject: [PATCH 13/16] feat: upgraded vitty connect to v3 with fixes in acads --- .../Icons/Frame 10.imageset/Contents.json | 21 + .../Icons/Frame 10.imageset/Frame 10.svg | 23 + VITTY/VITTY.xcodeproj/project.pbxproj | 42 +- VITTY/VITTY/Academics/Components/Alerts.swift | 112 ++ .../Components/FileUploadHelper.swift | 146 ++ VITTY/VITTY/Academics/Model/CourseFile.swift | 43 + VITTY/VITTY/Academics/Model/NotesModel.swift | 53 + VITTY/VITTY/Academics/View/CourseRefs.swift | 681 ++++++++- VITTY/VITTY/Academics/View/Courses.swift | 14 +- VITTY/VITTY/Academics/View/FileUpload.swift | 1243 +++++++++++++++++ VITTY/VITTY/Academics/View/Notes.swift | 265 +++- VITTY/VITTY/Academics/View/NotesHelper.swift | 27 +- .../VITTY/Academics/View/RemindersData.swift | 248 +++- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 11 +- VITTY/VITTY/Auth/Views/LoginView.swift | 2 +- .../AddFriends/View/AddFriendsView.swift | 51 +- VITTY/VITTY/Connect/Models/CircleModel.swift | 37 +- VITTY/VITTY/Connect/Models/Friend.swift | 15 +- .../Connect/Search/Views/SearchView.swift | 22 +- .../View/Circles/Components/CirclesRow.swift | 100 +- .../View/Circles/Components/CreateGroup.swift | 407 +++++- .../View/Circles/Components/JoinGroup.swift | 538 ++++++- .../View/Circles/Components/QrCode.swift | 139 ++ .../View/Circles/View/CircleRequests.swift | 269 ++++ .../View/Circles/View/InsideCircle.swift | 174 ++- VITTY/VITTY/Connect/View/ConnectPage.swift | 292 +++- .../Connect/View/Freinds/View/Freinds.swift | 50 +- .../ViewModel/CommunityPageViewModel.swift | 381 ++++- VITTY/VITTY/Home/View/HomeView.swift | 2 +- VITTY/VITTY/Settings/View/SettingsView.swift | 147 +- .../ViewModel/SettingsViewModel.swift | 130 +- VITTY/VITTY/Shared/Constants.swift | 6 +- VITTY/VITTY/TimeTable/Models/TimeTable.swift | 71 +- .../ViewModel/TimeTableViewModel.swift | 265 +++- .../TimeTable/Views/LectureDetailView.swift | 28 +- .../TimeTable/Views/LectureItemView.swift | 29 +- .../VITTY/TimeTable/Views/TimeTableView.swift | 85 +- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 130 +- VITTY/VITTY/Username/Views/UsernameView.swift | 43 +- .../Utilities/Constants/APIConstants.swift | 6 + VITTY/VITTYApp.swift | 8 +- 41 files changed, 5594 insertions(+), 762 deletions(-) create mode 100644 VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json create mode 100644 VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg create mode 100644 VITTY/VITTY/Academics/Components/Alerts.swift create mode 100644 VITTY/VITTY/Academics/Components/FileUploadHelper.swift create mode 100644 VITTY/VITTY/Academics/Model/CourseFile.swift create mode 100644 VITTY/VITTY/Academics/View/FileUpload.swift create mode 100644 VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift create mode 100644 VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift diff --git a/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json new file mode 100644 index 0000000..ad8b1af --- /dev/null +++ b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Frame 10.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg new file mode 100644 index 0000000..964e950 --- /dev/null +++ b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg @@ -0,0 +1,23 @@ +<svg width="50" height="51" viewBox="0 0 50 51" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_811_9482)"> +<rect x="2.5" y="3" width="45" height="45" rx="22.5" fill="#F4F4FC"/> +<mask id="mask0_811_9482" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="11" y="11" width="29" height="29"> +<rect x="11.3999" y="11.3999" width="28" height="28" fill="#041727"/> +</mask> +<g mask="url(#mask0_811_9482)"> +<path d="M13 33.9998V30.8498C13 30.2123 13.1641 29.6263 13.4922 29.0919C13.8203 28.5576 14.2562 28.1498 14.8 27.8685C15.9625 27.2873 17.1437 26.8513 18.3437 26.5607C19.5437 26.2701 20.7625 26.1248 22 26.1248C23.2375 26.1248 24.4562 26.2701 25.6562 26.5607C26.8562 26.8513 28.0375 27.2873 29.2 27.8685C29.7437 28.1498 30.1797 28.5576 30.5078 29.0919C30.8359 29.6263 31 30.2123 31 30.8498V33.9998H13ZM33.25 33.9998V30.6248C33.25 29.7998 33.0203 29.0076 32.5609 28.2482C32.1016 27.4888 31.45 26.8373 30.6062 26.2935C31.5625 26.406 32.4625 26.5982 33.3062 26.8701C34.15 27.1419 34.9375 27.4748 35.6687 27.8685C36.3437 28.2435 36.8594 28.6607 37.2156 29.1201C37.5719 29.5794 37.75 30.081 37.75 30.6248V33.9998H33.25ZM22 24.9998C20.7625 24.9998 19.7031 24.5591 18.8219 23.6779C17.9406 22.7966 17.5 21.7373 17.5 20.4998C17.5 19.2623 17.9406 18.2029 18.8219 17.3216C19.7031 16.4404 20.7625 15.9998 22 15.9998C23.2375 15.9998 24.2969 16.4404 25.1781 17.3216C26.0594 18.2029 26.5 19.2623 26.5 20.4998C26.5 21.7373 26.0594 22.7966 25.1781 23.6779C24.2969 24.5591 23.2375 24.9998 22 24.9998ZM33.25 20.4998C33.25 21.7373 32.8094 22.7966 31.9281 23.6779C31.0469 24.5591 29.9875 24.9998 28.75 24.9998C28.5437 24.9998 28.2812 24.9763 27.9625 24.9294C27.6437 24.8826 27.3812 24.831 27.175 24.7748C27.6812 24.1748 28.0703 23.5091 28.3422 22.7779C28.6141 22.0466 28.75 21.2873 28.75 20.4998C28.75 19.7123 28.6141 18.9529 28.3422 18.2216C28.0703 17.4904 27.6812 16.8248 27.175 16.2248C27.4375 16.131 27.7 16.0701 27.9625 16.0419C28.225 16.0138 28.4875 15.9998 28.75 15.9998C29.9875 15.9998 31.0469 16.4404 31.9281 17.3216C32.8094 18.2029 33.25 19.2623 33.25 20.4998ZM15.25 31.7498H28.75V30.8498C28.75 30.6435 28.6984 30.456 28.5953 30.2873C28.4922 30.1185 28.3562 29.9873 28.1875 29.8935C27.175 29.3873 26.1531 29.0076 25.1219 28.7544C24.0906 28.5013 23.05 28.3748 22 28.3748C20.95 28.3748 19.9094 28.5013 18.8781 28.7544C17.8469 29.0076 16.825 29.3873 15.8125 29.8935C15.6437 29.9873 15.5078 30.1185 15.4047 30.2873C15.3016 30.456 15.25 30.6435 15.25 30.8498V31.7498ZM22 22.7498C22.6187 22.7498 23.1484 22.5294 23.5891 22.0888C24.0297 21.6482 24.25 21.1185 24.25 20.4998C24.25 19.881 24.0297 19.3513 23.5891 18.9107C23.1484 18.4701 22.6187 18.2498 22 18.2498C21.3812 18.2498 20.8516 18.4701 20.4109 18.9107C19.9703 19.3513 19.75 19.881 19.75 20.4998C19.75 21.1185 19.9703 21.6482 20.4109 22.0888C20.8516 22.5294 21.3812 22.7498 22 22.7498Z" fill="#041727"/> +</g> +</g> +<defs> +<filter id="filter0_d_811_9482" x="0.0999999" y="0.6" width="49.8" height="49.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1.2"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.0117647 0 0 0 0 0.0431373 0 0 0 0 0.0705882 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_811_9482"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_811_9482" result="shape"/> +</filter> +</defs> +</svg> diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 7603db9..38ff062 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -27,13 +27,20 @@ 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE72D7C78B300C9D801 /* Courses.swift */; }; 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; + 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */; }; 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */; }; 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */; }; 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; + 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; + 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; + 4B74D8742E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; + 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8762E0BF77400B390E9 /* Alerts.swift */; }; + 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */; }; + 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */; }; 4B7DA5DC2D708BD3007354A3 /* LectureItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5DB2D708BCD007354A3 /* LectureItemView.swift */; }; 4B7DA5DF2D7094E8007354A3 /* Academics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5DE2D7094E3007354A3 /* Academics.swift */; }; 4B7DA5E12D70A728007354A3 /* FriendRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5E02D70A71C007354A3 /* FriendRow.swift */; }; @@ -178,12 +185,18 @@ 4B183EE72D7C78B300C9D801 /* Courses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Courses.swift; sourceTree = "<group>"; }; 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = "<group>"; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = "<group>"; }; + 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = "<group>"; }; 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderNotifcationManager.swift; sourceTree = "<group>"; }; 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingHotelView.swift; sourceTree = "<group>"; }; 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; }; + 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCode.swift; sourceTree = "<group>"; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = "<group>"; }; 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = "<group>"; }; + 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseFile.swift; sourceTree = "<group>"; }; + 4B74D8762E0BF77400B390E9 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; }; + 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUpload.swift; sourceTree = "<group>"; }; + 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadHelper.swift; sourceTree = "<group>"; }; 4B7DA5DB2D708BCD007354A3 /* LectureItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureItemView.swift; sourceTree = "<group>"; }; 4B7DA5DE2D7094E3007354A3 /* Academics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Academics.swift; sourceTree = "<group>"; }; 4B7DA5E02D70A71C007354A3 /* FriendRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendRow.swift; sourceTree = "<group>"; }; @@ -451,9 +464,19 @@ path = ViewModel; sourceTree = "<group>"; }; + 4B74D8752E0BF76B00B390E9 /* Components */ = { + isa = PBXGroup; + children = ( + 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */, + 4B74D8762E0BF77400B390E9 /* Alerts.swift */, + ); + path = Components; + sourceTree = "<group>"; + }; 4B7DA5DD2D7094CA007354A3 /* Academics */ = { isa = PBXGroup; children = ( + 4B74D8752E0BF76B00B390E9 /* Components */, 4BBB002F2D95510B003B8FE2 /* Model */, 4BBB002E2D955104003B8FE2 /* VIewModel */, 4BBB002D2D9550F8003B8FE2 /* View */, @@ -489,6 +512,7 @@ 4B7DA5EB2D71E0F4007354A3 /* Components */ = { isa = PBXGroup; children = ( + 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */, 4BF0C79E2D94694000016202 /* InsideCircleCards.swift */, 4B7DA5E62D71AC51007354A3 /* CirclesRow.swift */, 4B7DA5F12D7228E5007354A3 /* JoinGroup.swift */, @@ -501,6 +525,7 @@ 4B7DA5EC2D71E0FB007354A3 /* View */ = { isa = PBXGroup; children = ( + 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B7DA5E42D70B2C8007354A3 /* Circles.swift */, 4BF0C79C2D94680A00016202 /* InsideCircle.swift */, ); @@ -528,6 +553,7 @@ 4BBB002D2D9550F8003B8FE2 /* View */ = { isa = PBXGroup; children = ( + 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, 4B183EE72D7C78B300C9D801 /* Courses.swift */, 4BF03C982D7819E00098C803 /* Notes.swift */, 4BF03C9A2D7838C50098C803 /* NotesHelper.swift */, @@ -552,6 +578,7 @@ 4BBB002F2D95510B003B8FE2 /* Model */ = { isa = PBXGroup; children = ( + 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, 4B5977462DF97D5A009CC224 /* RemainderModel.swift */, 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */, ); @@ -592,13 +619,6 @@ path = View; sourceTree = "<group>"; }; - 4BF03C972D7819D30098C803 /* Academics */ = { - isa = PBXGroup; - children = ( - ); - path = Academics; - sourceTree = "<group>"; - }; 520BA63B2B47FF5700124850 /* SuggestedFriends */ = { isa = PBXGroup; children = ( @@ -742,7 +762,6 @@ 524B84312B46EF28006D18BD /* View */ = { isa = PBXGroup; children = ( - 4BF03C972D7819D30098C803 /* Academics */, 4B7DA5E92D71E0D7007354A3 /* Freinds */, 4B7DA5E82D71E0CE007354A3 /* Circles */, 524B84342B46F0FE006D18BD /* Components */, @@ -1120,17 +1139,21 @@ 522B8BB02B4732CC00EE686E /* Friend.swift in Sources */, 52D5AB8C2B6FE4D600B2E66D /* UserDefaultKeys.swift in Sources */, 5D7F04F72AAB9E9900ECED15 /* APIConstants.swift in Sources */, + 4B74D8742E0BDF2100B390E9 /* CourseFile.swift in Sources */, 4BF03C9B2D7838C80098C803 /* NotesHelper.swift in Sources */, 3109639F27824F6F0009A29C /* AppStorageConstants.swift in Sources */, 4BD63D742D70547E00EEF5D7 /* EmptyClass.swift in Sources */, 4BF0C79D2D94681000016202 /* InsideCircle.swift in Sources */, 524B84332B46EF3A006D18BD /* ConnectPage.swift in Sources */, 521E1E8B2C21DF0D00E8C7D2 /* AddFriendCardSearch.swift in Sources */, + 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */, + 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.swift in Sources */, 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */, 5238C7F12B4AAE8700413946 /* FriendRequestView.swift in Sources */, 528CF1782B769E64007298A0 /* TimeTableAPIService.swift in Sources */, 52D5AB972B6FFC8F00B2E66D /* LoginView.swift in Sources */, 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */, + 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */, 4BBB00312D955163003B8FE2 /* AcademicsViewModel.swift in Sources */, 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */, 314A409127383BEC0058082F /* ContentView.swift in Sources */, @@ -1155,6 +1178,7 @@ 52D5AB862B6FE2ED00B2E66D /* AuthViewModel.swift in Sources */, 4BF0C79F2D94694900016202 /* InsideCircleCards.swift in Sources */, 524B843C2B46F6FD006D18BD /* AddFriendsHeader.swift in Sources */, + 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */, 4BD63D7A2D70636400EEF5D7 /* EmptyClassRoomViewModel.swift in Sources */, 521562AC2B70B0FD0054F051 /* InstructionView.swift in Sources */, 522B8BAD2B47297A00EE686E /* CommunityPageViewModel.swift in Sources */, @@ -1164,6 +1188,7 @@ 52D5AB8F2B6FE82E00B2E66D /* AuthAPIService.swift in Sources */, 4BD63D772D70610B00EEF5D7 /* EmptyClassAPIService.swift in Sources */, 528CF1732B769B18007298A0 /* TimeTable.swift in Sources */, + 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */, 521562AE2B710E730054F051 /* UsernameView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1174,6 +1199,7 @@ files = ( 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */, 4BC853C42DF6DA7A0092B2E2 /* TimeTable.swift in Sources */, + 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/VITTY/VITTY/Academics/Components/Alerts.swift b/VITTY/VITTY/Academics/Components/Alerts.swift new file mode 100644 index 0000000..7a2d2bc --- /dev/null +++ b/VITTY/VITTY/Academics/Components/Alerts.swift @@ -0,0 +1,112 @@ +// +// Alerts.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + +import SwiftUI + +struct DeleteNoteAlert: View { + let noteName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete note?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete '\(noteName)'?") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + +struct DeleteFileAlert: View { + let noteName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete File?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete '\(noteName)'?") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} diff --git a/VITTY/VITTY/Academics/Components/FileUploadHelper.swift b/VITTY/VITTY/Academics/Components/FileUploadHelper.swift new file mode 100644 index 0000000..8500c36 --- /dev/null +++ b/VITTY/VITTY/Academics/Components/FileUploadHelper.swift @@ -0,0 +1,146 @@ +// +// FileUploadHelper.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Manager Helper +class FileManagerHelper { + static let shared = FileManagerHelper() + + private init() {} + + var documentsDirectory: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + + func createCourseDirectory(courseCode: String) -> URL { + let courseDir = documentsDirectory.appendingPathComponent("Courses/\(courseCode)") + try? FileManager.default.createDirectory(at: courseDir, withIntermediateDirectories: true) + return courseDir + } + + func saveFile(data: Data, fileName: String, courseCode: String) -> String? { + let courseDir = createCourseDirectory(courseCode: courseCode) + let fileURL = courseDir.appendingPathComponent(fileName) + + do { + try data.write(to: fileURL) + return fileURL.path + } catch { + print("Error saving file: \(error)") + return nil + } + } + + func loadFile(from path: String) -> Data? { + return FileManager.default.contents(atPath: path) + } + + + func fileExists(at path: String) -> Bool { + return FileManager.default.fileExists(atPath: path) + } + + func generateThumbnail(for imageData: Data, courseCode: String, fileName: String) -> String? { + guard let image = UIImage(data: imageData) else { return nil } + + let thumbnailSize = CGSize(width: 150, height: 150) + let renderer = UIGraphicsImageRenderer(size: thumbnailSize) + + let thumbnailImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: thumbnailSize)) + } + + guard let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.7) else { return nil } + + let thumbnailFileName = "thumb_\(fileName)" + return saveFile(data: thumbnailData, fileName: thumbnailFileName, courseCode: courseCode) + } + + func deleteFile(at path: String) { + try? FileManager.default.removeItem(atPath: path) + } + + func formatFileSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + + +extension FileManagerHelper { + + + func getValidFileURL(from storedPath: String, courseCode: String) -> URL? { + + if FileManager.default.fileExists(atPath: storedPath) { + return URL(fileURLWithPath: storedPath) + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: courseCode) + let reconstructedURL = courseDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: reconstructedURL.path) { + return reconstructedURL + } + + return nil + } + + + func updateFilePathsIfNeeded(files: [UploadedFile], modelContext: ModelContext) { + var hasChanges = false + + for file in files { + + if !fileExists(at: file.localPath) { + + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + + file.localPath = newPath + hasChanges = true + } + } + + + if let thumbnailPath = file.thumbnailPath, !fileExists(at: thumbnailPath) { + let thumbnailFileName = URL(fileURLWithPath: thumbnailPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newThumbnailPath = courseDir.appendingPathComponent(thumbnailFileName).path + + if fileExists(at: newThumbnailPath) { + file.thumbnailPath = newThumbnailPath + hasChanges = true + } + } + } + + + if hasChanges { + do { + try modelContext.save() + print("Updated file paths for \(files.count) files") + } catch { + print("Error updating file paths: \(error)") + } + } + } + + +} diff --git a/VITTY/VITTY/Academics/Model/CourseFile.swift b/VITTY/VITTY/Academics/Model/CourseFile.swift new file mode 100644 index 0000000..f6baf3c --- /dev/null +++ b/VITTY/VITTY/Academics/Model/CourseFile.swift @@ -0,0 +1,43 @@ +// +// CourseFile.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + + +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Model +@Model +class UploadedFile { + var id: UUID + var fileName: String + var fileType: String + var fileSize: Int64 + var courseName: String + var courseCode: String + var uploadDate: Date + var localPath: String + var thumbnailPath: String? + var isImage: Bool + + init(fileName: String, fileType: String, fileSize: Int64, courseName: String, courseCode: String, localPath: String, thumbnailPath: String? = nil, isImage: Bool = false) { + self.id = UUID() + self.fileName = fileName + self.fileType = fileType + self.fileSize = fileSize + self.courseName = courseName + self.courseCode = courseCode + self.uploadDate = Date() + self.localPath = localPath + self.thumbnailPath = thumbnailPath + self.isImage = isImage + } +} + diff --git a/VITTY/VITTY/Academics/Model/NotesModel.swift b/VITTY/VITTY/Academics/Model/NotesModel.swift index 4753e5d..9bb0e95 100644 --- a/VITTY/VITTY/Academics/Model/NotesModel.swift +++ b/VITTY/VITTY/Academics/Model/NotesModel.swift @@ -45,3 +45,56 @@ class CreateNoteModel { +extension CreateNoteModel { + + private static var plainTextCache: [String: String] = [:] + private static var attributedStringCache: [String: NSAttributedString] = [:] + + var cachedPlainText: String { + let cacheKey = "\(self.courseId)_\(self.createdAt.timeIntervalSince1970)" + + if let cached = Self.plainTextCache[cacheKey] { + return cached + } + + let plainText = extractPlainText() + Self.plainTextCache[cacheKey] = plainText + return plainText + } + + var cachedAttributedString: NSAttributedString? { + let cacheKey = "\(self.courseId)_\(self.createdAt.timeIntervalSince1970)" + + if let cached = Self.attributedStringCache[cacheKey] { + return cached + } + + let attributedString = extractAttributedString() + if let attributedString = attributedString { + Self.attributedStringCache[cacheKey] = attributedString + } + return attributedString + } + + private func extractPlainText() -> String { + guard let data = Data(base64Encoded: noteContent), + let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + return noteContent + } + return attributedString.string + } + + private func extractAttributedString() -> NSAttributedString? { + guard let data = Data(base64Encoded: noteContent), + let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + return NSAttributedString(string: noteContent) + } + return attributedString + } + + + static func clearCache() { + plainTextCache.removeAll() + attributedStringCache.removeAll() + } +} diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index d7d50a0..dc50d30 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -1,7 +1,14 @@ +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + + import SwiftUI import SwiftData -struct CourseRefs: View { +struct OCourseRefs: View { var courseName: String var courseInstitution: String var slot: String @@ -9,19 +16,43 @@ struct CourseRefs: View { @State private var showBottomSheet = false @State private var showReminderSheet = false + @State private var showNotes = false @State private var navigateToNotesEditor = false - - @State private var showSheet = false - - @State private var myExistingNote: CreateNoteModel = CreateNoteModel(noteName: "", userName: "", courseId: "", courseName: "", noteContent: "") + @State var showCourseNotes: Bool = false + @State private var selectedNote: CreateNoteModel? + @State private var preloadedAttributedString: NSAttributedString? + @State private var searchText = "" + @State private var showDeleteAlert = false + @State private var noteToDelete: CreateNoteModel? + @State private var fileToDelete: UploadedFile? + @State private var isLoadingNote = false + @State private var loadingNoteId: Date? + @State private var showimgDeleteAlert = false + // File upload related states + @State private var showFileUpload = false + @State private var showFileGallery = false + @State private var selectedContentType: ContentType = .notes @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext private let maxVisible = 4 - // Fetch remainders with dynamic predicate @Query private var filteredRemainders: [Remainder] @Query private var courseNotes: [CreateNoteModel] + @Query private var courseFiles: [UploadedFile] + + enum ContentType: String, CaseIterable { + case notes = "Notes" + case files = "Files" + + var icon: String { + switch self { + case .notes: return "doc.text" + case .files: return "folder" + } + } + } init(courseName: String, courseInstitution: String, slot: String, courseCode: String) { self.courseName = courseName @@ -37,11 +68,39 @@ struct CourseRefs: View { ) let notesPredicate = #Predicate<CreateNoteModel> { - $0.courseId == courseCode + $0.courseId == courseCode } _courseNotes = Query( FetchDescriptor(predicate: notesPredicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) ) + + + let filesPredicate = #Predicate<UploadedFile> { + $0.courseCode == courseCode + } + _courseFiles = Query( + FetchDescriptor(predicate: filesPredicate, sortBy: [SortDescriptor(\.uploadDate, order: .reverse)]) + ) + } + + private var filteredNotes: [CreateNoteModel] { + if searchText.isEmpty { + return courseNotes + } else { + return courseNotes.filter { note in + note.noteName.localizedCaseInsensitiveContains(searchText) + } + } + } + + private var filteredFiles: [UploadedFile] { + if searchText.isEmpty { + return courseFiles + } else { + return courseFiles.filter { file in + file.fileName.localizedCaseInsensitiveContains(searchText) + } + } } var body: some View { @@ -66,32 +125,58 @@ struct CourseRefs: View { .foregroundColor(.white) Spacer() - - Button(action: {}) { - Image(systemName: "magnifyingglass") - .foregroundColor(.white) - .font(.title2) + + + if selectedContentType == .files && !courseFiles.isEmpty { + Button("View All") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) } } .padding() HStack { Spacer() - TextField("Search", text: .constant("")) + TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) .padding(10) .frame(width: UIScreen.main.bounds.width * 0.85) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) + .foregroundColor(.white) Spacer() } - Spacer().frame(height: 20) + + + + + Spacer().frame(height: 15) Text("\(courseName) - \(courseInstitution)") .font(.title2) .bold() .foregroundColor(.white) .padding(.horizontal) + + HStack(spacing: 12) { + ForEach(ContentType.allCases, id: \.self) { contentType in + ContentTypeTab( + contentType: contentType, + isSelected: selectedContentType == contentType, + count: contentType == .notes ? filteredNotes.count : filteredFiles.count + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedContentType = contentType + searchText = "" + } + } + } + Spacer() + } + .padding(.horizontal) + .padding(.top, 10) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -111,36 +196,66 @@ struct CourseRefs: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 15) { - if courseNotes.isEmpty { - Text("No notes found for this course") - .foregroundColor(.gray) - .padding() - } else { - ForEach(courseNotes, id: \.createdAt) { note in - - NavigationLink( - destination : NoteEditorView(existingNote: note, courseCode: courseCode, courseName: courseName) - ){ + if selectedContentType == .notes { + + if filteredNotes.isEmpty { + EmptyStateView( + icon: searchText.isEmpty ? "doc.text" : "magnifyingglass", + title: searchText.isEmpty ? "No notes found for this course" : "No notes match your search", + subtitle: searchText.isEmpty ? nil : "Try searching with different keywords" + ) + } else { + ForEach(filteredNotes, id: \.createdAt) { note in CourseCardNotes( title: note.noteName, - description: extractPlainTextFromNote(note.noteContent) + description: note.cachedPlainText, + isLoading: loadingNoteId == note.createdAt, + onDelete: { + noteToDelete = note + showDeleteAlert = true + } ) + .onTapGesture { + openNote(note) + } + } + } + } else { + + if filteredFiles.isEmpty { + EmptyStateView( + icon: searchText.isEmpty ? "folder" : "magnifyingglass", + title: searchText.isEmpty ? "No files found for this course" : "No files match your search", + subtitle: searchText.isEmpty ? "Upload some files to get started" : "Try searching with different keywords" + ) + } else { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 12) { + ForEach(Array(filteredFiles.prefix(6)), id: \.id) { file in + CompactFileCard(file: file) { + + + showimgDeleteAlert = true + fileToDelete = file + } + } + } + + if filteredFiles.count > 6 { + Button("View All \(filteredFiles.count) Files") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) + .padding(.top, 8) + .frame(maxWidth: .infinity) } - -// CourseCardNotes( -// title: note.noteName, -// description: extractPlainTextFromNote(note.noteContent) -// ).onTapGesture { -// myExistingNote = note -// showSheet.toggle() -// } -// - } } } .padding() - } } @@ -162,13 +277,40 @@ struct CourseRefs: View { .padding(.bottom, 30) } } - }.onAppear{ + + if showDeleteAlert { + DeleteNoteAlert( + noteName: noteToDelete?.noteName ?? "", + onCancel: { + showDeleteAlert = false + noteToDelete = nil + }, + onDelete: { + deleteNote() + } + ) + .zIndex(1) + } + if showimgDeleteAlert { + DeleteFileAlert( + noteName: noteToDelete?.noteName ?? "", + onCancel: { + showimgDeleteAlert = false + fileToDelete = nil + }, + onDelete: { + deleteFile() + } + ) + .zIndex(1) + } + + + } + .onAppear { print("this is course code") print(courseCode) } - .sheet(isPresented: $showSheet, content: { - ExistingHotelView(existingNote: myExistingNote) - }) .navigationBarHidden(true) .edgesIgnoringSafeArea(.bottom) .sheet(isPresented: $showBottomSheet) { @@ -181,7 +323,11 @@ struct CourseRefs: View { navigateToNotesEditor = true } - BottomSheetButton(icon: "edit_document", title: "Upload File") + BottomSheetButton(icon: "edit_document", title: "Upload File") { + showBottomSheet = false + showFileUpload = true + } + BottomSheetButton(icon: "alarm", title: "Set Reminder") { showBottomSheet = false showReminderSheet = true @@ -197,24 +343,394 @@ struct CourseRefs: View { ReminderView(courseName: courseName, slot: slot, courseCode: courseCode) .presentationDetents([.fraction(0.8)]) } + .sheet(isPresented: $showFileUpload) { + FileUploadView(courseName: courseName, courseCode: courseCode) + } + .sheet(isPresented: $showFileGallery) { + FileGalleryView(courseCode: courseCode) + } .navigationDestination(isPresented: $navigateToNotesEditor) { - NoteEditorView(courseCode: courseCode, courseName: courseName) + NoteEditorView(courseCode: courseCode, courseName: courseName, courseIns: courseInstitution, courseSlot: slot) } + .sheet(isPresented: $showNotes, content: { + NoteEditorView( + existingNote: selectedNote, + preloadedAttributedString: preloadedAttributedString, + courseCode: courseCode, + courseName: courseName, + courseIns: courseInstitution, + courseSlot: slot + ) + }) + } + } + + // MARK: - Note Loading Function + private func openNote(_ note: CreateNoteModel) { + guard !isLoadingNote else { return } + + isLoadingNote = true + loadingNoteId = note.createdAt + + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + + Task { @MainActor in + do { + let attributedString = try await loadNoteContent(note) + + selectedNote = note + preloadedAttributedString = attributedString + + try await Task.sleep(nanoseconds: 300_000_000) + + isLoadingNote = false + loadingNoteId = nil + showNotes = true + + } catch { + print("Error loading note: \(error)") + isLoadingNote = false + loadingNoteId = nil + + let errorFeedback = UINotificationFeedbackGenerator() + errorFeedback.notificationOccurred(.error) + } + } + } + + @MainActor + private func loadNoteContent(_ note: CreateNoteModel) async throws -> NSAttributedString { + if let cachedAttributedString = note.cachedAttributedString { + return cachedAttributedString + } + + guard let data = Data(base64Encoded: note.noteContent) else { + throw NoteLoadingError.invalidData + } + + if let attributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { + return attributedString + } else { + throw NoteLoadingError.unarchiveFailed } } - private func extractPlainTextFromNote(_ noteContent: String) -> String { + private func deleteNote() { + guard let note = noteToDelete else { return } - guard let data = Data(base64Encoded: noteContent), - let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + modelContext.delete(note) + + do { + try modelContext.save() + } catch { + print("Failed to delete note: \(error)") + } + + showDeleteAlert = false + noteToDelete = nil + } + + private func deleteFile(){ + guard let file = fileToDelete else{return} + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + modelContext.delete(file) + do { + try modelContext.save() + } catch { + print("Failed to delete file: \(error)") + } + showimgDeleteAlert = false + + } + +} + + + +struct ContentTypeTab: View { + let contentType: OCourseRefs.ContentType + let isSelected: Bool + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: contentType.icon) + .font(.system(size: 14)) + + Text(contentType.rawValue) + .font(.system(size: 14, weight: .medium)) + + if count > 0 { + Text("(\(count))") + .font(.system(size: 12)) + .opacity(0.8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color("Accent") : Color("Secondary")) + .foregroundColor(isSelected ? .black : .white) + .cornerRadius(20) + } + } +} + +struct EmptyStateView: View { + let icon: String + let title: String + let subtitle: String? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text(title) + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + .multilineTextAlignment(.center) + + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.gray.opacity(0.8)) + .font(.system(size: 14)) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } +} +struct CompactFileCard: View { + let file: UploadedFile + let onDelete: (() -> Void)? + + @State private var showFileViewer = false + @State private var showActionSheet = false + @State private var fileImage: UIImage? + @State private var imageLoadError = false + @State private var isLoading = true + + init(file: UploadedFile, onDelete: (() -> Void)? = nil) { + self.file = file + self.onDelete = onDelete + } + + var body: some View { + VStack(spacing: 8) { - return noteContent + if file.isImage && !imageLoadError { + Group { + if let image = fileImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else if isLoading { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + ) + } else { + Rectangle() + .fill(Color.red.opacity(0.3)) + .overlay( + VStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + .font(.system(size: 16)) + Text("Not found") + .font(.caption2) + .foregroundColor(.red) + } + ) + } + } + .frame(height: 80) + .clipped() + .cornerRadius(8) + } else { + Rectangle() + .fill(getFileTypeColor(file.fileType).opacity(0.2)) + .frame(height: 80) + .overlay( + VStack(spacing: 4) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 24)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileType.uppercased()) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(getFileTypeColor(file.fileType)) + } + ) + .cornerRadius(8) + } + + + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Text(FileManagerHelper.shared.formatFileSize(file.fileSize)) + .font(.caption2) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onTapGesture { + showFileViewer = true + } + .onLongPressGesture(minimumDuration: 0.5) { + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + showActionSheet = true + } + .onAppear { + if file.isImage { + loadImageFile() + } + } + .sheet(isPresented: $showFileViewer) { + EnhancedFileViewerSheet(file: file) + } + .confirmationDialog("File Options", isPresented: $showActionSheet, titleVisibility: .visible) { + Button("Share") { + shareFile() + } + + if let onDelete = onDelete { + Button("Delete", role: .destructive) { + onDelete() + } + } + + Button("Cancel", role: .cancel) {} + } message: { + Text("Choose an action for \(file.fileName)") } + } + + // MARK: - File Loading Methods + + private func loadImageFile() { + isLoading = true + imageLoadError = false - return attributedString.string + Task { + let imagePaths = [file.thumbnailPath, file.localPath].compactMap { $0 } + var loadedImage: UIImage? + + for path in imagePaths { + if let data = FileManagerHelper.shared.loadFileWithFallback(from: path, courseCode: file.courseCode), + let image = UIImage(data: data) { + loadedImage = image + break + } + } + + await MainActor.run { + if let image = loadedImage { + self.fileImage = image + self.imageLoadError = false + } else { + self.imageLoadError = true + } + self.isLoading = false + } + } + } + + private func shareFile() { + guard let data = FileManagerHelper.shared.loadFileWithFallback(from: file.localPath, courseCode: file.courseCode) else { + print("Cannot share file: File not found") + return + } + + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(file.fileName) + + do { + if FileManager.default.fileExists(atPath: tempURL.path) { + try FileManager.default.removeItem(at: tempURL) + } + try data.write(to: tempURL) + + let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootViewController = window.rootViewController { + + if let popover = activityVC.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) + popover.permittedArrowDirections = [] + } + + rootViewController.present(activityVC, animated: true) + } + } catch { + print("Error sharing file: \(error)") + } + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray + } } } +// MARK: - Error Handling +enum NoteLoadingError: Error { + case invalidData + case unarchiveFailed +} + struct BottomSheetButton: View { var icon: String @@ -271,6 +787,7 @@ struct TagView: View { } } } + struct MoreTagView: View { var count: Int @@ -285,27 +802,71 @@ struct MoreTagView: View { } } - struct CourseCardNotes: View { var title: String var description: String - + var isLoading: Bool = false + var onDelete: () -> Void + + @State private var showComingSoonAlert = false + var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.headline) - .foregroundColor(.white) - .padding(.bottom, 5) - - Text(description) - .font(.subheadline) - .foregroundColor(.gray) - .lineLimit(2) + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(description) + .font(.subheadline) + .foregroundColor(.gray) + .lineLimit(2) + } + + Spacer() + + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + .padding(.trailing, 8) + } else { + Menu { + Button(role: .destructive) { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + showComingSoonAlert = true + } label: { + Label("Export Markdown", systemImage: "square.and.arrow.down") + } + + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundColor(.white) + .font(.system(size: 20, weight: .medium)) + .padding(8) + .clipShape(Circle()) + } + } } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.2)) .cornerRadius(15) + .opacity(isLoading ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isLoading) + .alert("Feature coming soon", isPresented: $showComingSoonAlert) { + Button("OK", role: .cancel) { } + } } } @@ -324,5 +885,3 @@ struct RoundedCorner: Shape { return Path(path.cgPath) } } - - diff --git a/VITTY/VITTY/Academics/View/Courses.swift b/VITTY/VITTY/Academics/View/Courses.swift index 817059e..c5f291a 100644 --- a/VITTY/VITTY/Academics/View/Courses.swift +++ b/VITTY/VITTY/Academics/View/Courses.swift @@ -15,21 +15,11 @@ struct CoursesView: View { VStack(spacing: 0) { SearchBar(searchText: $searchText) - HStack(spacing: 16) { - SemesterFilterButton(isSelected: isCurrentSemester, title: "Current Semester") - .onTapGesture { isCurrentSemester = true } - - SemesterFilterButton(isSelected: !isCurrentSemester, title: "All Semesters") - .onTapGesture { isCurrentSemester = false } - - Spacer() - } - .padding(.horizontal) - .padding(.top, 16) + VStack(spacing: 16) { ForEach(filtered) { course in - NavigationLink(destination: CourseRefs(courseName: course.title, courseInstitution: course.code,slot:course.slot,courseCode: course.code)) { + NavigationLink(destination: OCourseRefs(courseName: course.title, courseInstitution: course.code,slot:course.slot,courseCode: course.code)) { CourseCardView(course: course) } } diff --git a/VITTY/VITTY/Academics/View/FileUpload.swift b/VITTY/VITTY/Academics/View/FileUpload.swift new file mode 100644 index 0000000..b720a52 --- /dev/null +++ b/VITTY/VITTY/Academics/View/FileUpload.swift @@ -0,0 +1,1243 @@ +// +// FileUpload.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + +import SwiftUI +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Upload View +struct FileUploadView: View { + let courseName: String + let courseCode: String + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var selectedImages: [PhotosPickerItem] = [] + @State private var showDocumentPicker = false + @State private var isUploading = false + @State private var uploadProgress: Double = 0 + @State private var showSuccessAlert = false + @State private var uploadedCount = 0 + @State private var showErrorAlert = false + @State private var errorMessage = "" + @State private var capturedImage: UIImage? + @State private var showCamera = false + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 30) { + // Header + VStack(spacing: 8) { + Image(systemName: "icloud.and.arrow.up") + .font(.system(size: 48)) + .foregroundColor(Color("Secondary")) + + Text("Upload Files") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + + Text("Add images and documents to \(courseName)") + .font(.subheadline) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + } + .padding(.top, 20) + + + VStack(spacing: 16) { + Button(action: { + showCamera = true + }) { + UploadOptionCard( + icon: "camera.fill", + title: "Take Photo", + description: "Capture new photos with camera", + color: .orange + ) + } + .disabled(isUploading) + + PhotosPicker( + selection: $selectedImages, + maxSelectionCount: 10, + matching: .images + ) { + UploadOptionCard( + icon: "photo.on.rectangle.angled", + title: "Upload Images", + description: "Take photos or select from gallery", + color: .blue + ) + } + .disabled(isUploading) + + + Button(action: { + showDocumentPicker = true + }) { + UploadOptionCard( + icon: "doc.fill", + title: "Upload Documents", + description: "Select PDF, Word, or other files", + color: .green + ) + } + .disabled(isUploading) + } + + + if isUploading { + VStack(spacing: 12) { + ProgressView(value: uploadProgress) + .progressViewStyle(LinearProgressViewStyle(tint: Color("Secondary"))) + + Text("Uploading files... (\(Int(uploadProgress * 100))%)") + .font(.subheadline) + .foregroundColor(.gray) + } + .padding(.horizontal) + } + + Spacer() + } + .padding(.horizontal) + } + .navigationTitle("Upload Files") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) .sheet(isPresented: $showCamera) { + CameraView(capturedImage: $capturedImage) + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundColor(.white) + .disabled(isUploading) + } + } + } + .onChange(of: selectedImages) { _, newItems in + if !newItems.isEmpty { + uploadImages(newItems) + } + } + .fileImporter( + isPresented: $showDocumentPicker, + allowedContentTypes: [.pdf, .plainText, .rtf, .rtfd, .data, .item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + uploadDocuments(urls) + case .failure(let error): + errorMessage = "Error selecting documents: \(error.localizedDescription)" + showErrorAlert = true + } + } + .alert("Upload Complete", isPresented: $showSuccessAlert) { + Button("OK") { + dismiss() + } + } message: { + Text("Successfully uploaded \(uploadedCount) file(s)") + } + .alert("Upload Error", isPresented: $showErrorAlert) { + Button("OK") { } + } message: { + Text(errorMessage) + } + } + + private func uploadImages(_ items: [PhotosPickerItem]) { + guard !items.isEmpty else { return } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + for (index, item) in items.enumerated() { + do { + if let data = try await item.loadTransferable(type: Data.self) { + let timestamp = Int(Date().timeIntervalSince1970) + let fileName = "image_\(timestamp)_\(index).jpg" + + if let savedPath = FileManagerHelper.shared.saveFile( + data: data, + fileName: fileName, + courseCode: courseCode + ) { + let thumbnailPath = FileManagerHelper.shared.generateThumbnail( + for: data, + courseCode: courseCode, + fileName: fileName + ) + + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: "jpg", + fileSize: Int64(data.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + thumbnailPath: thumbnailPath, + isImage: true + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount += 1 + } + } + } + } catch { + await MainActor.run { + errorMessage = "Failed to process image: \(error.localizedDescription)" + showErrorAlert = true + } + } + + await MainActor.run { + uploadProgress = Double(index + 1) / Double(items.count) + } + } + + await MainActor.run { + isUploading = false + selectedImages = [] + + if uploadedCount > 0 { + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving files: \(error.localizedDescription)" + showErrorAlert = true + } + } + } + } + } + + private func uploadDocuments(_ urls: [URL]) { + guard !urls.isEmpty else { return } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + for (index, url) in urls.enumerated() { + var canAccess = false + + if url.startAccessingSecurityScopedResource() { + canAccess = true + } + + defer { + if canAccess { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let data = try Data(contentsOf: url) + let fileName = url.lastPathComponent + let fileType = url.pathExtension + + if let savedPath = FileManagerHelper.shared.saveFile( + data: data, + fileName: fileName, + courseCode: courseCode + ) { + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: fileType, + fileSize: Int64(data.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + isImage: false + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount += 1 + } + } + } catch { + await MainActor.run { + errorMessage = "Failed to process document \(url.lastPathComponent): \(error.localizedDescription)" + showErrorAlert = true + } + } + + await MainActor.run { + uploadProgress = Double(index + 1) / Double(urls.count) + } + } + + await MainActor.run { + isUploading = false + + if uploadedCount > 0 { + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving files: \(error.localizedDescription)" + showErrorAlert = true + } + } + } + } + } + private func uploadCapturedImage(_ image: UIImage) { + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + errorMessage = "Failed to process captured image" + showErrorAlert = true + return + } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + let timestamp = Int(Date().timeIntervalSince1970) + let fileName = "camera_\(timestamp).jpg" + + if let savedPath = FileManagerHelper.shared.saveFile( + data: imageData, + fileName: fileName, + courseCode: courseCode + ) { + let thumbnailPath = FileManagerHelper.shared.generateThumbnail( + for: imageData, + courseCode: courseCode, + fileName: fileName + ) + + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: "jpg", + fileSize: Int64(imageData.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + thumbnailPath: thumbnailPath, + isImage: true + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount = 1 + uploadProgress = 1.0 + isUploading = false + capturedImage = nil + + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving captured image: \(error.localizedDescription)" + showErrorAlert = true + } + } + } else { + await MainActor.run { + isUploading = false + capturedImage = nil + errorMessage = "Failed to save captured image" + showErrorAlert = true + } + } + } + } + + +} +// MARK: - Camera View +struct CameraView: UIViewControllerRepresentable { + @Binding var capturedImage: UIImage? + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + picker.allowsEditing = true + picker.cameraCaptureMode = .photo + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let editedImage = info[.editedImage] as? UIImage { + parent.capturedImage = editedImage + } else if let originalImage = info[.originalImage] as? UIImage { + parent.capturedImage = originalImage + } + + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + + +// MARK: - Upload Option Card +struct UploadOptionCard: View { + let icon: String + let title: String + let description: String + let color: Color + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(color) + .frame(width: 40, height: 40) + .background(color.opacity(0.1)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(description) + .font(.subheadline) + .foregroundColor(.gray) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + .padding(16) + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + } +} + +// MARK: - File Gallery View +struct FileGalleryView: View { + let courseCode: String + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query private var files: [UploadedFile] + @State private var selectedFilter: FileFilter = .all + @State private var showDeleteAlert = false + @State private var fileToDelete: UploadedFile? + @State private var selectedFile: UploadedFile? + @State private var showFileViewer = false + + enum FileFilter: String, CaseIterable { + case all = "All" + case images = "Images" + case documents = "Documents" + + var icon: String { + switch self { + case .all: return "folder" + case .images: return "photo" + case .documents: return "doc" + } + } + } + + init(courseCode: String) { + self.courseCode = courseCode + let predicate = #Predicate<UploadedFile> { file in + file.courseCode == courseCode + } + _files = Query( + FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.uploadDate, order: .reverse)] + ) + ) + } + + private var filteredFiles: [UploadedFile] { + switch selectedFilter { + case .all: + return files + case .images: + return files.filter { $0.isImage } + case .documents: + return files.filter { !$0.isImage } + } + } + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(FileFilter.allCases, id: \.self) { filter in + FilterTab( + filter: filter, + isSelected: selectedFilter == filter + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedFilter = filter + } + } + } + } + .padding(.horizontal) + } + .padding(.bottom, 16) + + + if filteredFiles.isEmpty { + VStack(spacing: 16) { + Image(systemName: selectedFilter.icon) + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text("No \(selectedFilter.rawValue.lowercased()) found") + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + + Text("Upload some files to get started") + .foregroundColor(.gray.opacity(0.7)) + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + ForEach(filteredFiles, id: \.id) { file in + FileCard(file: file) { + selectedFile = file + showFileViewer = true + } onDelete: { + fileToDelete = file + showDeleteAlert = true + } + } + } + .padding(.horizontal) + .padding(.bottom, 20) + } + } + } + } + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Back") { + dismiss() + } + .foregroundColor(.white) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Text("\(filteredFiles.count) files") + .font(.caption) + .foregroundColor(.gray) + } + } + } + .alert("Delete File", isPresented: $showDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + deleteFile() + } + } message: { + Text("Are you sure you want to delete '\(fileToDelete?.fileName ?? "")'?") + } + .sheet(isPresented: $showFileViewer) { + if let file = selectedFile { + EnhancedFileViewerSheet(file: file) + } + } + } + + private func deleteFile() { + guard let file = fileToDelete else { return } + + + FileManagerHelper.shared.deleteFile(at: file.localPath) + if let thumbnailPath = file.thumbnailPath { + FileManagerHelper.shared.deleteFile(at: thumbnailPath) + } + + + modelContext.delete(file) + + do { + try modelContext.save() + } catch { + print("Error deleting file: \(error)") + } + + fileToDelete = nil + } +} + +// MARK: - Filter Tab +struct FilterTab: View { + let filter: FileGalleryView.FileFilter + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: filter.icon) + .font(.system(size: 14)) + + Text(filter.rawValue) + .font(.system(size: 14, weight: .medium)) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color("Secondary") : Color.white.opacity(0.1)) + .foregroundColor(isSelected ? .black : .white) + .cornerRadius(20) + } + } +} +// MARK: - File Card +struct FileCard: View { + let file: UploadedFile + let onTap: () -> Void + let onDelete: () -> Void + + @State private var imageLoadError = false + @State private var showActionSheet = false + + var body: some View { + VStack(spacing: 0) { + + if file.isImage && !imageLoadError { + Group { + if let thumbnailPath = file.thumbnailPath, + let thumbnailURL = getValidFileURL(from: thumbnailPath) { + AsyncImage(url: thumbnailURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } else if let fileURL = getValidFileURL(from: file.localPath) { + AsyncImage(url: fileURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } else { + Rectangle() + .fill(Color.red.opacity(0.3)) + .overlay( + VStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("File not found") + .font(.caption2) + .foregroundColor(.red) + } + ) + } + } + .frame(height: 120) + .clipped() + .onAppear { + + if getValidFileURL(from: file.localPath) == nil { + imageLoadError = true + } + } + } else { + Rectangle() + .fill(getFileTypeColor(file.fileType).opacity(0.1)) + .frame(height: 120) + .overlay( + VStack(spacing: 8) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 32)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileType.uppercased()) + .font(.caption) + .fontWeight(.bold) + .foregroundColor(getFileTypeColor(file.fileType)) + } + ) + } + + + VStack(alignment: .leading, spacing: 4) { + Text(file.fileName) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(FileManagerHelper.shared.formatFileSize(file.fileSize)) + .font(.caption2) + .foregroundColor(.gray) + + Spacer() + + Text(file.uploadDate, style: .date) + .font(.caption2) + .foregroundColor(.gray) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .onTapGesture { + onTap() + } + .onLongPressGesture(minimumDuration: 0.5) { + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + + showActionSheet = true + } + .confirmationDialog("File Options", isPresented: $showActionSheet, titleVisibility: .visible) { + Button("Share") { + shareFile() + } + + Button("Delete", role: .destructive) { + onDelete() + } + + Button("Cancel", role: .cancel) { + + } + } message: { + Text("Choose an action for \(file.fileName)") + } + } + + // MARK: - Helper Methods + + + private func getValidFileURL(from storedPath: String) -> URL? { + + if FileManager.default.fileExists(atPath: storedPath) { + return URL(fileURLWithPath: storedPath) + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let currentDocumentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let courseDir = currentDocumentsDir.appendingPathComponent("Courses/\(file.courseCode)") + let reconstructedURL = courseDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: reconstructedURL.path) { + return reconstructedURL + } + + return nil + } + + + private func shareFile() { + guard let fileURL = getValidFileURL(from: file.localPath) else { + print("Cannot share file: File not found") + return + } + + let activityVC = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil) + + + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray + } + } +} + +// MARK: - Updated FileManagerHelper +extension FileManagerHelper { + + func updateFilePathsIfNeeded(for file: UploadedFile) -> Bool { + + if fileExists(at: file.localPath) { + return true + } + + + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + + return true + } + + return false + } +} + +struct EnhancedFileViewerSheet: View { + let file: UploadedFile + + @Environment(\.dismiss) private var dismiss + @State private var fileData: Data? + @State private var isLoading = true + @State private var showShareSheet = false + @State private var loadError: String? + @State private var showQuickLook = false + @State private var temporaryFileURL: URL? + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + if isLoading { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + + Text("Loading file...") + .foregroundColor(.gray) + } + } else if let error = loadError { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Unable to load file") + .foregroundColor(.white) + .font(.headline) + + Text(error) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding() + } else if let data = fileData { + contentView(for: data) + } + } + .navigationTitle(file.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done") { + dismiss() + } + .foregroundColor(.white) + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack { + if file.fileType.lowercased() == "pdf" { + Button("Open") { + showQuickLook = true + } + .foregroundColor(.white) + } + + Button("Share") { + showShareSheet = true + } + .foregroundColor(.white) + } + } + } + } + .onAppear { + loadFileData() + } + .sheet(isPresented: $showShareSheet) { + if let url = temporaryFileURL { + ShareSheet(items: [url]) + } + } + .sheet(isPresented: $showQuickLook) { + if let url = temporaryFileURL { + QuickLookView(url: url) + } + } + } + + @ViewBuilder + private func contentView(for data: Data) -> some View { + if file.isImage, let image = UIImage(data: data) { + + ScrollView([.horizontal, .vertical]) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + } + } else if file.fileType.lowercased() == "pdf" { + + PDFViewerWrapper(data: data) + } else if file.fileType.lowercased() == "txt" { + + if let text = String(data: data, encoding: .utf8) { + ScrollView { + Text(text) + .foregroundColor(.white) + .font(.system(size: 14)) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + fileInfoView(data: data) + } + } else { + + fileInfoView(data: data) + } + } + + @ViewBuilder + private func fileInfoView(data: Data) -> some View { + VStack(spacing: 20) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 64)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileName) + .font(.headline) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + VStack(spacing: 8) { + Text("File size: \(FileManagerHelper.shared.formatFileSize(file.fileSize))") + .font(.subheadline) + .foregroundColor(.gray) + + Text("Type: \(file.fileType.uppercased())") + .font(.subheadline) + .foregroundColor(.gray) + + Text("Uploaded: \(file.uploadDate, style: .date)") + .font(.subheadline) + .foregroundColor(.gray) + } + + Button("Open with External App") { + showQuickLook = true + } + .padding() + .background(Color("Secondary")) + .foregroundColor(.black) + .cornerRadius(10) + } + .padding() + } + + private func loadFileData() { + Task { + + let data = FileManagerHelper.shared.loadFileWithFallback(from: file.localPath, courseCode: file.courseCode) + + await MainActor.run { + if let data = data { + fileData = data + createTemporaryFile(data: data) + } else { + loadError = "File not found or corrupted" + } + isLoading = false + } + } + } + + private func createTemporaryFile(data: Data) { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(file.fileName) + + do { + if FileManager.default.fileExists(atPath: tempURL.path) { + try FileManager.default.removeItem(at: tempURL) + } + try data.write(to: tempURL) + temporaryFileURL = tempURL + } catch { + print("Error creating temporary file: \(error)") + } + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray + } + } +} +struct PDFViewerWrapper: UIViewRepresentable { + let data: Data + + func makeUIView(context: Context) -> PDFView { + let pdfView = PDFView() + pdfView.backgroundColor = UIColor.clear + pdfView.autoScales = true + pdfView.displayMode = .singlePageContinuous + pdfView.displayDirection = .vertical + + if let document = PDFDocument(data: data) { + pdfView.document = document + } + + return pdfView + } + + func updateUIView(_ uiView: PDFView, context: Context) { + + } +} + +// MARK: - QuickLook View +struct QuickLookView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, QLPreviewControllerDataSource { + let parent: QuickLookView + + init(_ parent: QuickLookView) { + self.parent = parent + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + return 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + return parent.url as NSURL + } + } +} + +// MARK: - Enhanced FileManagerHelper Extension +extension FileManagerHelper { + + + func loadFileWithFallback(from storedPath: String, courseCode: String) -> Data? { + + if fileExists(at: storedPath), let data = loadFile(from: storedPath) { + return data + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: courseCode) + let reconstructedPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: reconstructedPath), let data = loadFile(from: reconstructedPath) { + return data + } + + + return findAndLoadFile(fileName: fileName, in: courseDir) + } + + + private func findAndLoadFile(fileName: String, in directory: URL) -> Data? { + do { + let contents = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + + for fileURL in contents { + if fileURL.lastPathComponent == fileName { + return try? Data(contentsOf: fileURL) + } + } + } catch { + print("Error searching directory: \(error)") + } + + return nil + } + + + func updateStoredFilePaths(files: [UploadedFile], modelContext: ModelContext) { + var hasChanges = false + + for file in files { + if !fileExists(at: file.localPath) { + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + file.localPath = newPath + hasChanges = true + } + } + + + if let thumbnailPath = file.thumbnailPath, !fileExists(at: thumbnailPath) { + let thumbnailFileName = URL(fileURLWithPath: thumbnailPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newThumbnailPath = courseDir.appendingPathComponent(thumbnailFileName).path + + if fileExists(at: newThumbnailPath) { + file.thumbnailPath = newThumbnailPath + hasChanges = true + } + } + } + + if hasChanges { + do { + try modelContext.save() + print("Updated \(files.count) file paths") + } catch { + print("Error updating file paths: \(error)") + } + } + } +} + + +// MARK: - Share Sheet +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + + } +} diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index 7564de1..bd09353 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -1,3 +1,9 @@ +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + import SwiftUI import UIKit @@ -6,6 +12,7 @@ struct RichTextView: UIViewRepresentable { @Binding var selectedRange: NSRange @Binding var typingAttributes: [NSAttributedString.Key: Any] @Binding var isEmpty: Bool + func makeUIView(context: Context) -> UITextView { let textView = UITextView() @@ -17,7 +24,7 @@ struct RichTextView: UIViewRepresentable { textView.backgroundColor = .clear textView.textColor = .white - // Set initial content + textView.attributedText = attributedText textView.selectedRange = selectedRange @@ -25,25 +32,27 @@ struct RichTextView: UIViewRepresentable { } func updateUIView(_ uiView: UITextView, context: Context) { - // Prevent infinite loops by checking if coordinator is updating + if context.coordinator.isUpdating { return } - // Only update if the content is actually different + if !uiView.attributedText.isEqual(to: attributedText) { let previousSelectedRange = uiView.selectedRange context.coordinator.isUpdating = true uiView.attributedText = attributedText - // Restore selection if valid + if previousSelectedRange.location <= uiView.attributedText.length { - uiView.selectedRange = previousSelectedRange + let maxRange = min(previousSelectedRange.location + previousSelectedRange.length, uiView.attributedText.length) + let validRange = NSRange(location: previousSelectedRange.location, length: maxRange - previousSelectedRange.location) + uiView.selectedRange = validRange } context.coordinator.isUpdating = false } - // Only update selection if it's different and valid + if !NSEqualRanges(uiView.selectedRange, selectedRange) && selectedRange.location <= uiView.attributedText.length && NSMaxRange(selectedRange) <= uiView.attributedText.length { @@ -52,7 +61,7 @@ struct RichTextView: UIViewRepresentable { context.coordinator.isUpdating = false } - // Update typing attributes if they're different + if !NSDictionary(dictionary: uiView.typingAttributes).isEqual(to: typingAttributes) { uiView.typingAttributes = typingAttributes } @@ -71,34 +80,49 @@ struct RichTextView: UIViewRepresentable { } func textViewDidChange(_ textView: UITextView) { - // Prevent recursive updates + guard !isUpdating else { return } isUpdating = true defer { isUpdating = false } - // Update parent state + parent.attributedText = NSMutableAttributedString(attributedString: textView.attributedText) parent.isEmpty = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + + parent.typingAttributes = textView.typingAttributes } func textViewDidChangeSelection(_ textView: UITextView) { - // Prevent recursive updates + guard !isUpdating else { return } isUpdating = true defer { isUpdating = false } parent.selectedRange = textView.selectedRange + + + if textView.selectedRange.length == 0 && textView.selectedRange.location > 0 { + + let location = min(textView.selectedRange.location - 1, textView.attributedText.length - 1) + if location >= 0 { + let attributes = textView.attributedText.attributes(at: location, effectiveRange: nil) + parent.typingAttributes = attributes + } + } } } } -// Optimized NoteEditorView with better state management + + struct NoteEditorView: View { @Environment(\.dismiss) private var dismiss @Environment(AcademicsViewModel.self) private var academicsViewModel @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.presentationMode) var presentationMode @State private var attributedText = NSMutableAttributedString() @State private var selectedRange = NSRange(location: 0, length: 0) @@ -108,6 +132,7 @@ struct NoteEditorView: View { ] let existingNote: CreateNoteModel? + let preloadedAttributedString: NSAttributedString? // Pre-processed content @State private var selectedFont: UIFont = UIFont.systemFont(ofSize: 18) @State private var selectedColor: Color = .white @State private var showFontPicker = false @@ -115,27 +140,50 @@ struct NoteEditorView: View { @State private var isEmpty = true @State private var hasUnsavedChanges = false @State private var isInitialized = false + @State private var goback = false @Environment(\.modelContext) private var modelContext let courseCode: String let courseName: String + let courseIns : String + let courseSlot : String - init(existingNote: CreateNoteModel? = nil, courseCode: String, courseName: String) { + init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String,courseIns: String , courseSlot: String) { self.existingNote = existingNote + self.preloadedAttributedString = preloadedAttributedString self.courseCode = existingNote?.courseId ?? courseCode self.courseName = existingNote?.courseName ?? courseName + self.courseIns = courseIns + self.courseSlot = courseSlot } - + private func handleBackNavigation() { + + + // Fallback for older navigation approaches + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if presentationMode.wrappedValue.isPresented { + presentationMode.wrappedValue.dismiss() + } + } + } private func initializeContent() { guard !isInitialized else { return } if let note = existingNote { - Task { @MainActor in - await loadNoteContent(note) + if let preloaded = preloadedAttributedString { + + attributedText = NSMutableAttributedString(attributedString: preloaded) + isEmpty = preloaded.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty isInitialized = true + } else { + + Task { @MainActor in + await loadNoteContent(note) + isInitialized = true + } } } else { - // New note - initialize with empty content + attributedText = NSMutableAttributedString() isEmpty = true isInitialized = true @@ -144,42 +192,43 @@ struct NoteEditorView: View { @MainActor private func loadNoteContent(_ note: CreateNoteModel) async { + + if let cachedAttributedString = note.cachedAttributedString { + attributedText = NSMutableAttributedString(attributedString: cachedAttributedString) + isEmpty = cachedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return + } + + do { - // Parse the base64 encoded attributed string guard let data = Data(base64Encoded: note.noteContent) else { print("Failed to decode base64 data") - // Fallback to plain text attributedText = NSMutableAttributedString(string: note.noteContent) isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return } - // Try to unarchive the attributed string if let loadedAttributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { attributedText = NSMutableAttributedString(attributedString: loadedAttributedString) isEmpty = loadedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } else { print("Failed to unarchive attributed string") - // Fallback to plain text attributedText = NSMutableAttributedString(string: note.noteContent) isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } catch { print("Error loading note content: \(error)") - // Fallback to plain text attributedText = NSMutableAttributedString(string: note.noteContent) isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } func saveContent() { - guard hasUnsavedChanges || existingNote == nil else { - dismiss() + handleBackNavigation() return } - do { let data = try NSKeyedArchiver.archivedData(withRootObject: attributedText, requiringSecureCoding: false) let dataString = data.base64EncodedString() @@ -189,6 +238,8 @@ struct NoteEditorView: View { note.noteName = title note.noteContent = dataString note.createdAt = Date.now + + CreateNoteModel.clearCache() } else { let newNote = CreateNoteModel( noteName: title, @@ -235,22 +286,22 @@ struct NoteEditorView: View { if isInitialized { VStack { - // Header + headerView - // Text Editor + textEditorView - // Toolbar + toolbarView } } else { - // Loading state + ProgressView("Loading...") .foregroundColor(.white) } - // Overlays + if showFontPicker { fontPickerOverlay } @@ -277,9 +328,9 @@ struct NoteEditorView: View { private var headerView: some View { HStack { - Button(action: { dismiss() }) { + Button(action: { handleBackNavigation() }) { Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) + .foregroundColor(Color("Accent")).font(.title2) } Spacer() Text("Note") @@ -319,7 +370,7 @@ struct NoteEditorView: View { private var toolbarView: some View { HStack(spacing: 20) { - // Font picker button + Button(action: { showFontPicker.toggle() showFontSizePicker = false @@ -328,7 +379,7 @@ struct NoteEditorView: View { .foregroundColor(Color("Accent")) } - // Font size button + Button(action: { showFontSizePicker.toggle() showFontPicker = false @@ -343,12 +394,12 @@ struct NoteEditorView: View { } } - // Formatting buttons + formatButton(action: toggleBold, icon: "bold", isActive: isBoldActive()) formatButton(action: toggleItalic, icon: "italic", isActive: isItalicActive()) formatButton(action: toggleUnderline, icon: "underline", isActive: isUnderlineActive()) - // Color picker + ColorPicker("", selection: $selectedColor, supportsOpacity: false) .labelsHidden() .frame(width: 30, height: 30) @@ -356,7 +407,7 @@ struct NoteEditorView: View { applyAttribute(.foregroundColor, value: UIColor(newColor)) } - // Bullet points button + Button(action: addBulletPoints) { Image(systemName: "list.bullet") .foregroundColor(Color("Accent")) @@ -455,7 +506,7 @@ struct NoteEditorView: View { } } - // MARK: - Text Formatting Functions + func addBulletPoints() { guard selectedRange.length > 0 else { return } @@ -472,13 +523,11 @@ struct NoteEditorView: View { } func isBoldActive() -> Bool { - let font = getCurrentFont() - return font.fontDescriptor.symbolicTraits.contains(.traitBold) + return checkTraitActive(.traitBold) } func isItalicActive() -> Bool { - let font = getCurrentFont() - return font.fontDescriptor.symbolicTraits.contains(.traitItalic) + return checkTraitActive(.traitItalic) } func isUnderlineActive() -> Bool { @@ -486,6 +535,28 @@ struct NoteEditorView: View { return underline == NSUnderlineStyle.single.rawValue } + private func checkTraitActive(_ trait: UIFontDescriptor.SymbolicTraits) -> Bool { + if selectedRange.length > 0 { + var hasTraitThroughout = true + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + + attributedText.enumerateAttribute(.font, in: NSRange(location: selectedRange.location, length: endLocation - selectedRange.location), options: []) { value, range, stop in + if let font = value as? UIFont { + if !font.fontDescriptor.symbolicTraits.contains(trait) { + hasTraitThroughout = false + stop.pointee = true + } + } + } + return hasTraitThroughout + } else { + if let font = typingAttributes[.font] as? UIFont { + return font.fontDescriptor.symbolicTraits.contains(trait) + } + return false + } + } + private func getCurrentFont() -> UIFont { if selectedRange.length > 0 && selectedRange.location < attributedText.length { return attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont ?? UIFont.systemFont(ofSize: 18) @@ -503,8 +574,8 @@ struct NoteEditorView: View { } func applyFontFamily(_ font: UIFont) { - let currentFont = getCurrentFont() - let newFont = UIFont(name: font.fontName, size: currentFont.pointSize) ?? font + let size = getCurrentFont().pointSize + let newFont = UIFont(name: font.fontName, size: size) ?? font applyAttribute(.font, value: newFont) } @@ -515,43 +586,105 @@ struct NoteEditorView: View { } func toggleBold() { - let currentFont = getCurrentFont() - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitBold) { - traits.remove(.traitBold) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString } else { - traits.insert(.traitBold) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - applyAttribute(.font, value: newFont) + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } } + hasUnsavedChanges = true } func toggleItalic() { - let currentFont = getCurrentFont() - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitItalic) { - traits.remove(.traitItalic) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString } else { - traits.insert(.traitItalic) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - applyAttribute(.font, value: newFont) + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } } + hasUnsavedChanges = true } - + func toggleUnderline() { - let currentUnderline = getCurrentUnderlineStyle() - let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue - applyAttribute(.underlineStyle, value: newUnderline) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.underlineStyle, in: range, options: []) { value, subRange, _ in + let currentUnderline = value as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + mutableAttributedString.addAttribute(.underlineStyle, value: newUnderline, range: subRange) + } + attributedText = mutableAttributedString + } else { + let currentUnderline = typingAttributes[.underlineStyle] as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + typingAttributes[.underlineStyle] = newUnderline + } + hasUnsavedChanges = true } func applyAttribute(_ key: NSAttributedString.Key, value: Any) { if selectedRange.length > 0 { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) - mutableAttributedString.addAttribute(key, value: value, range: selectedRange) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + mutableAttributedString.addAttribute(key, value: value, range: range) attributedText = mutableAttributedString } else { typingAttributes[key] = value diff --git a/VITTY/VITTY/Academics/View/NotesHelper.swift b/VITTY/VITTY/Academics/View/NotesHelper.swift index 546d46c..58aa545 100644 --- a/VITTY/VITTY/Academics/View/NotesHelper.swift +++ b/VITTY/VITTY/Academics/View/NotesHelper.swift @@ -3,7 +3,8 @@ import UIKit extension NSAttributedString { // func toMarkdown() -> String { -// let mutableString = NSMutableString() +// let mutableString = NSMutableString()login + // let fullRange = NSRange(location: 0, length: self.length) // // self.enumerateAttributes(in: fullRange, options: []) { (attributes, range, _) in @@ -202,13 +203,13 @@ extension String { var workingLine = line let result = NSMutableAttributedString() - // Default attributes + var attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.white ] - // Handle headings + if workingLine.hasPrefix("### ") { workingLine = String(workingLine.dropFirst(4)) attributes[.font] = UIFont.boldSystemFont(ofSize: 20) @@ -220,12 +221,12 @@ extension String { attributes[.font] = UIFont.boldSystemFont(ofSize: 28) } - // Handle bullet points + if workingLine.trimmingCharacters(in: .whitespaces).hasPrefix("- ") { workingLine = workingLine.replacingOccurrences(of: "- ", with: "• ", options: [], range: workingLine.range(of: "- ")) } - // Process inline formatting + let processedString = processInlineFormatting(workingLine, baseAttributes: attributes) result.append(processedString) @@ -238,15 +239,15 @@ extension String { var currentAttributes = baseAttributes while currentIndex < text.endIndex { - // Handle HTML color spans + if let colorRange = findColorSpan(in: text, from: currentIndex) { - // Add text before color span + if currentIndex < colorRange.range.lowerBound { let beforeText = String(text[currentIndex..<colorRange.range.lowerBound]) result.append(NSAttributedString(string: beforeText, attributes: currentAttributes)) } - // Add colored text + var colorAttributes = currentAttributes colorAttributes[.foregroundColor] = colorRange.color result.append(NSAttributedString(string: colorRange.text, attributes: colorAttributes)) @@ -255,7 +256,7 @@ extension String { continue } - // Handle underline tags + if text[currentIndex...].hasPrefix("<u>") { if let endIndex = text.range(of: "</u>", range: currentIndex..<text.endIndex) { let startTagEnd = text.index(currentIndex, offsetBy: 3) @@ -270,7 +271,7 @@ extension String { } } - // Handle bold and italic + let (formattedString, newIndex) = processBoldItalic(text, from: currentIndex, attributes: currentAttributes) result.append(formattedString) currentIndex = newIndex @@ -313,20 +314,20 @@ extension String { let boldPattern = #"\*\*([^*]+)\*\*"# let italicPattern = #"\*([^*]+)\*"# - // Check for bold first (longer pattern) + if let boldRegex = try? NSRegularExpression(pattern: boldPattern), let boldMatch = boldRegex.firstMatch(in: remainingText, range: NSRange(remainingText.startIndex..<remainingText.endIndex, in: remainingText)) { let matchRange = Range(boldMatch.range, in: remainingText)! let textRange = Range(boldMatch.range(at: 1), in: remainingText)! - // Add text before match + if matchRange.lowerBound > remainingText.startIndex { let beforeText = String(remainingText[remainingText.startIndex..<matchRange.lowerBound]) result.append(NSAttributedString(string: beforeText, attributes: currentAttributes)) } - // Add bold text + let boldText = String(remainingText[textRange]) var boldAttributes = currentAttributes if let font = boldAttributes[.font] as? UIFont { diff --git a/VITTY/VITTY/Academics/View/RemindersData.swift b/VITTY/VITTY/Academics/View/RemindersData.swift index 75ef787..7ff7ae7 100644 --- a/VITTY/VITTY/Academics/View/RemindersData.swift +++ b/VITTY/VITTY/Academics/View/RemindersData.swift @@ -8,14 +8,19 @@ import SwiftUI import SwiftData + struct RemindersView: View { @Environment(\.modelContext) private var modelContext @Query private var allReminders: [Remainder] + @Query private var timeTables: [TimeTable] @State private var searchText = "" @State private var selectedTab = 0 + @State private var showingSubjectSelection = false + @State private var showingReminderCreation = false + @State private var selectedCourse: Course? - // Filtered reminders based on search text + // Your existing computed properties remain the same private var filteredReminders: [Remainder] { if searchText.isEmpty { return allReminders @@ -28,7 +33,6 @@ struct RemindersView: View { } } - // Group reminders by date private var groupedReminders: [ReminderGroup] { let grouped = Dictionary(grouping: filteredReminders) { reminder in Calendar.current.startOfDay(for: reminder.date) @@ -57,10 +61,16 @@ struct RemindersView: View { }.sorted { $0.daysToGo < $1.daysToGo } } + // Extract courses from timetable + private var availableCourses: [Course] { + let courses = timeTables.first.map { extractCourses(from: $0) } ?? [] + return courses + } + var body: some View { ScrollView { VStack(spacing: 0) { - // Search Bar + HStack { Image(systemName: "magnifyingglass") .foregroundColor(.gray) @@ -81,18 +91,30 @@ struct RemindersView: View { .padding(.horizontal) .padding(.top, 16) - // Status Tabs + HStack(spacing: 16) { StatusTabView(isSelected: selectedTab == 0, title: "Pending") .onTapGesture { selectedTab = 0 } StatusTabView(isSelected: selectedTab == 1, title: "Completed") .onTapGesture { selectedTab = 1 } Spacer() + + + Button { + showingSubjectSelection = true + } label: { + Image(systemName: "plus") + .foregroundColor(.blue) + .font(.system(size: 16, weight: .medium)) + .frame(width: 32, height: 32) + + + } } .padding(.horizontal) .padding(.top, 16) - // Reminder Groups + VStack(spacing: 24) { ForEach(groupedReminders, id: \.id) { group in if selectedTab == 0 && !group.items.filter({ !$0.isCompleted }).isEmpty { @@ -146,6 +168,25 @@ struct RemindersView: View { } .scrollIndicators(.hidden) .background(Color("Background").edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $showingSubjectSelection) { + SubjectSelectionView( + courses: availableCourses, + onCourseSelected: { course in + selectedCourse = course + showingSubjectSelection = false + showingReminderCreation = true + } + ) + } + .sheet(isPresented: $showingReminderCreation) { + if let course = selectedCourse { + ReminderView( + courseName: course.title, + slot: course.slot, + courseCode: course.code + ) + } + } } private func completeReminderItem(itemId: PersistentIdentifier) { @@ -156,8 +197,203 @@ struct RemindersView: View { } } } + + + private func extractCourses(from timetable: TimeTable) -> [Course] { + let allLectures = timetable.monday + timetable.tuesday + timetable.wednesday + + timetable.thursday + timetable.friday + timetable.saturday + + timetable.sunday + + let currentSemester = determineSemester(for: Date()) + let groupedLectures = Dictionary(grouping: allLectures, by: { $0.name }) + var result: [Course] = [] + + for title in groupedLectures.keys.sorted() { + if let lectures = groupedLectures[title] { + let uniqueSlot = Set(lectures.map { $0.slot }).sorted().joined(separator: " + ") + let uniqueCode = Set(lectures.map { $0.code }).sorted().joined(separator: " / ") + + result.append( + Course( + title: title, + slot: uniqueSlot, + code: uniqueCode, + semester: currentSemester, + isFavorite: false + ) + ) + } + } + + return result.sorted { $0.title < $1.title } + } + + private func determineSemester(for date: Date) -> String { + let month = Calendar.current.component(.month, from: date) + + switch month { + case 12, 1, 2: + return "Winter \(academicYear(for: date))" + case 3...6: + return "Summer \(academicYear(for: date))" + case 7...11: + return "Fall \(academicYear(for: date))" + default: + return "Unknown" + } + } + + private func academicYear(for date: Date) -> String { + let year = Calendar.current.component(.year, from: date) + let month = Calendar.current.component(.month, from: date) + if month < 3 { + return "\(year - 1)-\(String(format: "%02d", year % 100))" + } else { + return "\(year)-\(String(format: "%02d", (year + 1) % 100))" + } + } } +// MARK: - Subject Selection View +struct SubjectSelectionView: View { + let courses: [Course] + let onCourseSelected: (Course) -> Void + + @Environment(\.presentationMode) var presentationMode + @State private var searchText = "" + + private var filteredCourses: [Course] { + if searchText.isEmpty { + return courses + } else { + return courses.filter { course in + course.title.localizedCaseInsensitiveContains(searchText) || + course.code.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("Search subjects", text: $searchText) + .foregroundColor(.white) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark") + .foregroundColor(.gray) + } + } + } + .padding(10) + .background(Color("Secondary")) + .cornerRadius(8) + .padding(.horizontal) + .padding(.top, 16) + + ScrollView { + LazyVStack(spacing: 12) { + ForEach(filteredCourses) { course in + SubjectSelectionCard(course: course) { + onCourseSelected(course) + } + } + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 24) + } + + + if filteredCourses.isEmpty { + VStack(spacing: 16) { + Image(systemName: "book.closed") + .font(.system(size: 48)) + .foregroundColor(.gray) + + Text(searchText.isEmpty ? "No subjects available" : "No subjects found") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.gray) + + if !searchText.isEmpty { + Text("Try adjusting your search terms") + .font(.system(size: 14)) + .foregroundColor(.gray.opacity(0.7)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .navigationTitle("Select Subject") + .navigationBarTitleDisplayMode(.large) + .navigationBarItems( + leading: Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .foregroundColor(.red) + ) + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Subject Selection Card +struct SubjectSelectionCard: View { + let course: Course + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(course.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.system(size: 14)) + } + .padding(.top, 16) + .padding(.horizontal, 16) + + HStack { + Text(course.code) + .font(.system(size: 14)) + .foregroundColor(Color("Accent")) + + Spacer() + + Text("Slot: \(course.slot)") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color("Secondary").opacity(0.5)) + .cornerRadius(8) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 16).fill(Color("Secondary"))) + } + .buttonStyle(PlainButtonStyle()) + } +} struct StatusTabView: View { let isSelected: Bool let title: String @@ -342,7 +578,7 @@ struct ReminderItemView: View { } } -// Updated models to work with SwiftData + struct ReminderGroup: Identifiable { let id = UUID() let date: String diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index e41404b..addd15e 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -22,6 +22,9 @@ enum LoginOptions { class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { var loggedInFirebaseUser: User? var loggedInBackendUser: AppUser? + + + var isLoading: Bool = false var isLoadingApple: Bool = false let firebaseAuth = Auth.auth() @@ -61,8 +64,9 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { func signInServer(username: String, regNo: String) async { - logger.info("Signing into server...") + logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") do { + self.loggedInBackendUser = try await AuthAPIService.shared .signInUser( with: AuthRequestBody( @@ -71,11 +75,14 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { username: username ) ) + + } catch { logger.error("Signing into server error: \(error)") } - logger.info("Signed into server") + print("this is kinda empty : \(self.loggedInBackendUser?.name ?? "")") + logger.info("Signed into server \(self.loggedInBackendUser?.name ?? "empty")") } private func firebaseUserAuthUpdate(with auth: Auth, user: User?) { diff --git a/VITTY/VITTY/Auth/Views/LoginView.swift b/VITTY/VITTY/Auth/Views/LoginView.swift index 38cd03f..2e5891a 100644 --- a/VITTY/VITTY/Auth/Views/LoginView.swift +++ b/VITTY/VITTY/Auth/Views/LoginView.swift @@ -154,7 +154,7 @@ struct CarouselItemView: View { .foregroundColor(Color.white) Text(item.subtitle) .font(.footnote) - .foregroundColor(Color("tfBlueLight")) + .foregroundColor(Color("Accent")) .multilineTextAlignment(.center) .frame(width: 400) .padding(.top, 1) diff --git a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift index fe5488c..4da5572 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift @@ -12,14 +12,20 @@ struct AddFriendsView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel @Environment(FriendRequestViewModel.self) private var friendRequestViewModel + @Environment(\.dismiss) private var dismiss @State private var isSearchViewPresented = false var body: some View { NavigationStack { ZStack { + headerView BackgroundView() VStack(alignment: .leading) { + Button(action: {dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + } if !suggestedFriendsViewModel.suggestedFriends.isEmpty || !friendRequestViewModel.requests.isEmpty { @@ -42,28 +48,18 @@ struct AddFriendsView: View { Text("Request and Suggestions") .multilineTextAlignment(.center) .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white) + .foregroundColor(Color.white).padding() Text("Your friend requests and suggested friends will be shown here") .multilineTextAlignment(.center) .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundColor(Color.white) + .foregroundColor(Color.white).padding() Spacer() } } - } + } .navigationBarBackButtonHidden(true) .toolbar { - Button(action: { - isSearchViewPresented = true - }) { - Image(systemName: "magnifyingglass") - .foregroundColor(.white) - } - .navigationDestination( - isPresented: $isSearchViewPresented, - destination: { SearchView() } - ) } - .navigationTitle("Add Friends") + } .onAppear { suggestedFriendsViewModel.fetchData( @@ -73,4 +69,31 @@ struct AddFriendsView: View { ) } } + private var headerView: some View { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + } + Spacer() + Text("Note") + .foregroundColor(.white) + .font(.system(size: 25, weight: .bold)) + Spacer() + Button(action: { + isSearchViewPresented = true + }) { + Image(systemName: "magnifyingglass") + .foregroundColor(.white) + } + .navigationDestination( + isPresented: $isSearchViewPresented, + destination: { SearchView() } + ) + + + }.padding() + } + + } diff --git a/VITTY/VITTY/Connect/Models/CircleModel.swift b/VITTY/VITTY/Connect/Models/CircleModel.swift index 83d6642..3c6a764 100644 --- a/VITTY/VITTY/Connect/Models/CircleModel.swift +++ b/VITTY/VITTY/Connect/Models/CircleModel.swift @@ -5,6 +5,11 @@ // Created by Rujin Devkota on 3/25/25. // +//TODO: the Circle doesnt have image in the endpoint , the circle members dont have thier venu status currently in the endpoint + + + + import Foundation struct CircleModel: Decodable { @@ -23,7 +28,6 @@ struct CircleResponse: Decodable { let data: [CircleModel] } - struct CircleMember: Identifiable { let id = UUID() let picture: String @@ -32,29 +36,40 @@ struct CircleMember: Identifiable { let venue: String? } -import Foundation - - -// TEMP beacuse the endpoint has to contain the status and venue need to update the db -struct CircleUserTemp: Codable{ - +struct CircleUserTemp: Codable { let email: String let name: String let picture: String let username: String + let status: String? + let venue: String? enum CodingKeys: String, CodingKey { - case email, name, picture, username - + case email, name, picture, username, status, venue } } - struct CircleUserResponseTemp: Codable { let data: [CircleUserTemp] - enum CodingKeys: String , CodingKey{ + enum CodingKeys: String, CodingKey { case data } } +// MARK: - Request Models +struct CircleRequest: Codable, Identifiable { + let id = UUID() + let circle_id: String + let circle_name: String + let from_username: String + let to_username: String + + enum CodingKeys: String, CodingKey { + case circle_id, circle_name, from_username, to_username + } +} + +struct CircleRequestResponse: Codable { + let data: [CircleRequest] +} diff --git a/VITTY/VITTY/Connect/Models/Friend.swift b/VITTY/VITTY/Connect/Models/Friend.swift index 8d0851c..e1a6008 100644 --- a/VITTY/VITTY/Connect/Models/Friend.swift +++ b/VITTY/VITTY/Connect/Models/Friend.swift @@ -51,17 +51,4 @@ struct Friend: Decodable { } } -extension Friend { - static var sampleFriend: Friend { - return Friend( - currentStatus: CurrentStatus(status: "free"), - friendStatus: "friends", - friendsCount: 2, - mutualFriendsCount: 2, - name: "Rudrank Basant", - picture: - "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", - username: "rudrank" - ) - } -} + diff --git a/VITTY/VITTY/Connect/Search/Views/SearchView.swift b/VITTY/VITTY/Connect/Search/Views/SearchView.swift index 0f89a0b..ade68f8 100644 --- a/VITTY/VITTY/Connect/Search/Views/SearchView.swift +++ b/VITTY/VITTY/Connect/Search/Views/SearchView.swift @@ -27,6 +27,8 @@ struct SearchView: View { ZStack { BackgroundView() VStack(alignment: .leading) { + + headerView RoundedRectangle(cornerRadius: 20) .foregroundColor(Color("Secondary")) .frame(maxWidth: .infinity) @@ -75,11 +77,25 @@ struct SearchView: View { Spacer() } - } - .navigationTitle("Search") + }.navigationBarBackButtonHidden(true) + } } - + private var headerView: some View { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + } + Spacer() + Text("Search") + .foregroundColor(.white) + .font(.system(size: 22, weight: .bold)) + Spacer() + + } + .padding() + } func search() { loading = true let url = URL(string: "\(APIConstants.base_url)/api/v2/users/search?query=\(searchText)")! diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift index f6a7a07..e1881e8 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift @@ -8,8 +8,31 @@ import SwiftUI struct CirclesRow: View { - let circle: CircleModel + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + + + private var circleMembers: [CircleUserTemp] { + communityPageViewModel.circleMembers(for: circle.circleID) + } + + + private var busyCount: Int { + circleMembers.filter { + $0.status != nil && $0.status != "available" && $0.status != "free" + }.count + } + + private var availableCount: Int { + circleMembers.filter { + $0.status == nil || $0.status == "available" || $0.status == "free" + }.count + } + + private var isLoadingMembers: Bool { + communityPageViewModel.isLoadingCircleMembers(for: circle.circleID) + } var body: some View { HStack { @@ -21,36 +44,40 @@ struct CirclesRow: View { .font(Font.custom("Poppins-SemiBold", size: 18)) .foregroundColor(Color.white) - HStack{ - - Image("inclass").resizable().frame(width: 20,height: 20) - - Text("3 busy").foregroundStyle(Color("Accent")) - Spacer().frame(width: 20) - - Image("available").resizable().frame(width: 20,height: 20) - - Text("2 available").foregroundStyle(Color("Accent")) - - - - + if isLoadingMembers { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Loading...") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + + if busyCount > 0 { + Image("inclass").resizable().frame(width: 20, height: 20) + Text("\(busyCount) busy").foregroundStyle(Color("Accent")) + + if availableCount > 0 { + Spacer().frame(width: 20) + } + } + + + if availableCount > 0 { + Image("available").resizable().frame(width: 20, height: 20) + Text("\(availableCount) available").foregroundStyle(Color("Accent")) + } + + + if circleMembers.isEmpty && !isLoadingMembers { + Text("No members") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent").opacity(0.7)) + } + } } - - -// if friend.currentStatus.status == "free" { -// HStack { -// Image("available").resizable().frame(width: 20, height: 20) -// Text("Available").foregroundStyle(Color("Accent")) -// } -// } else { -// HStack { -// Image("inclass") -// Text(friend.currentStatus.venue ?? "") -// .font(Font.custom("Poppins-Regular", size: 14)) -// .foregroundColor(Color("Accent")) -// } -// } } Spacer() } @@ -59,11 +86,19 @@ struct CirclesRow: View { RoundedRectangle(cornerRadius: 15) .fill(Color("Secondary")) ) + .onAppear { + + communityPageViewModel.fetchCircleMemberData( + from: "\(APIConstants.base_url)circles/\(circle.circleID)", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: true, + circleID: circle.circleID + ) + } } - func cleanName(_ fullName: String) -> String { - let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" // + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" let regex = try? NSRegularExpression(pattern: pattern, options: []) let range = NSRange(location: 0, length: fullName.utf16.count) @@ -72,4 +107,3 @@ struct CirclesRow: View { return cleanedName } } - diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift index b538944..44ab789 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -1,4 +1,10 @@ +// +// Freinds.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. import SwiftUI +import Alamofire struct CreateGroup: View { let screenHeight = UIScreen.main.bounds.height @@ -8,7 +14,16 @@ struct CreateGroup: View { @State private var groupName: String = "" @State private var selectedImage: UIImage? = nil @State private var showImagePicker = false - @State private var selectedFriends: [String] = ["A", "B", "C", "D", "E"] + @State private var selectedFriends: [Friend] = [] + @State private var showFriendSelector = false + @State private var isCreatingGroup = false + @State private var showAlert = false + @State private var alertMessage = "" + + @Environment(CommunityPageViewModel.self) private var viewModel + let token: String + + @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 20) { @@ -24,7 +39,7 @@ struct CreateGroup: View { Spacer().frame(height: 20) - // Group Icon Picker + Button(action: { showImagePicker = true }) { @@ -80,7 +95,7 @@ struct CreateGroup: View { Spacer() Button(action: { - + showFriendSelector = true }) { Image(systemName: "person.badge.plus") .foregroundColor(.white) @@ -90,49 +105,116 @@ struct CreateGroup: View { } - HStack { - Spacer().frame(width : 90) - ZStack { - ForEach(Array(selectedFriends.prefix(3).enumerated()), id: \.element) { index, friend in - Circle() - .fill(Color.green.opacity(0.8)) - .frame(width: 40, height: 40) - .overlay(Text(friend).foregroundColor(.white)) - .offset(x: CGFloat(index * -25)) - } - } - Spacer() - if selectedFriends.count > 3 { - Text("+ \(selectedFriends.count - 3) more") + if selectedFriends.isEmpty { + + VStack { + Image(systemName: "person.2") + .font(.system(size: 30)) .foregroundColor(.gray) - .padding(.trailing, 20) + Text("No friends selected") + .foregroundColor(.gray) + .font(.system(size: 14)) } + .frame(width: screenWidth * 0.9, height: 80) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 2) + ) + .background(Color.black.opacity(0.3)) + .cornerRadius(12) + .padding(.horizontal, 20) + } else { - + HStack { + if selectedFriends.count <= 3 { + + Spacer() + HStack(spacing: -15) { + ForEach(Array(selectedFriends.enumerated()), id: \.element.username) { index, friend in + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.green.opacity(0.8)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(.system(size: 16, weight: .bold)) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + } + Spacer() + } else { + + Spacer().frame(width: 30) + HStack(spacing: -15) { + ForEach(Array(selectedFriends.prefix(3).enumerated()), id: \.element.username) { index, friend in + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.green.opacity(0.8)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(.system(size: 16, weight: .bold)) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + } + Spacer() + Text("+ \(selectedFriends.count - 3) more") + .foregroundColor(.gray) + .font(.system(size: 14)) + Spacer().frame(width: 20) + } + } + .frame(width: screenWidth * 0.9, height: 80) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 2) + ) + .background(Color.black.opacity(0.3)) + .cornerRadius(12) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + .onTapGesture { + showFriendSelector = true + } } - .frame(width: screenWidth * 0.9, height: 80).overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color("Accent"), lineWidth: 2) - ) - .background(Color.black.opacity(0.3)) - .cornerRadius(12) - .padding(.horizontal, 20) Spacer() - + HStack { Spacer() Button(action: { - + createGroup() }) { - Text("Cretae ") - .font(.system(size: 18, weight: .bold)).foregroundStyle(Color.black) - - .frame(width: 90, height: 40) - .background(Color("Accent")) - .cornerRadius(10) + HStack { + if isCreatingGroup { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } + Text(isCreatingGroup ? "Creating..." : "Create") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(Color.black) + } + .frame(width: 120, height: 40) + .background(groupName.isEmpty ? Color.gray : Color("Accent")) + .cornerRadius(10) } + .disabled(groupName.isEmpty || isCreatingGroup) .padding(.trailing, 20) } .padding(.bottom, 20) @@ -140,10 +222,263 @@ struct CreateGroup: View { } .presentationDetents([.height(screenHeight * 0.65)]) .background(Color("Secondary")) + .sheet(isPresented: $showFriendSelector) { + FriendSelectorView( + friends: viewModel.friends, + selectedFriends: $selectedFriends, + loadingFriends: viewModel.loadingFreinds + ) + } + .alert("Group Creation", isPresented: $showAlert) { + Button("OK") { + if alertMessage.contains("successfully") { + dismiss() + } + } + } message: { + Text(alertMessage) + } + } + + private func createGroup() { + guard !groupName.isEmpty else { return } + + isCreatingGroup = true + + + let createURL = "\(APIConstants.base_url)circles/create/\(groupName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? groupName)" + + AF.request(createURL, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: CreateCircleResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + + self.sendInvitations(circleId: data.circleId) + + case .failure(let error): + self.isCreatingGroup = false + self.alertMessage = "Failed to create group: \(error.localizedDescription)" + self.showAlert = true + } + } + } + } + + private func sendInvitations(circleId: String) { + guard !selectedFriends.isEmpty else { + + self.isCreatingGroup = false + self.alertMessage = "Group created successfully!" + self.showAlert = true + return + } + + let dispatchGroup = DispatchGroup() + var invitationResults: [String: Bool] = [:] + + for friend in selectedFriends { + dispatchGroup.enter() + + let inviteURL = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(friend.username)" + + AF.request(inviteURL, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + invitationResults[friend.username] = response.error == nil + dispatchGroup.leave() + } + } + } + + dispatchGroup.notify(queue: .main) { + self.isCreatingGroup = false + + let successCount = invitationResults.values.filter { $0 }.count + let totalCount = self.selectedFriends.count + + if successCount == totalCount { + self.alertMessage = "Group created successfully! All invitations sent." + } else if successCount > 0 { + self.alertMessage = "Group created successfully! \(successCount) out of \(totalCount) invitations sent." + } else { + self.alertMessage = "Group created successfully, but failed to send invitations." + } + + self.showAlert = true + } } } -#Preview { - CreateGroup(groupCode: .constant("")) + +struct FriendSelectorView: View { + let friends: [Friend] + @Binding var selectedFriends: [Friend] + let loadingFriends: Bool + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack { + if loadingFriends { + ProgressView("Loading friends...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.white) + } else if friends.isEmpty { + VStack { + Image(systemName: "person.2.slash") + .font(.system(size: 50)) + .foregroundColor(.gray) + Text("No friends found") + .font(.title2) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(friends, id: \.username) { friend in + FriendRowView( + friend: friend, + isSelected: selectedFriends.contains { $0.username == friend.username } + ) { isSelected in + if isSelected { + selectedFriends.append(friend) + } else { + selectedFriends.removeAll { $0.username == friend.username } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + } + .background(Color("Background")) + .navigationTitle("Select Friends") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + leading: Button(action: { + dismiss() + }) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + }, + trailing: Button(action: { + dismiss() + }) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + } + ) + } + .background(Color("Background")) + } } + +struct FriendRowView: View { + let friend: Friend + let isSelected: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack { + + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.blue.opacity(0.3)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(Font.custom("Poppins-SemiBold", size: 16)) + ) + } + .frame(width: 48, height: 48) + .clipShape(Circle()) + + Spacer().frame(width: 20) + + + VStack(alignment: .leading, spacing: 4) { + Text(cleanName(friend.name)) + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(Color.white) + + if friend.currentStatus.status == "free" { + HStack { + Image("available") + .resizable() + .frame(width: 20, height: 20) + Text("Available") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + Image("inclass") + .resizable() + .frame(width: 20, height: 20) + Text(friend.currentStatus.venue ?? "In Class") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + } + } + + Spacer() + + + Button(action: { + onToggle(!isSelected) + }) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? Color("Accent") : .gray) + .font(.system(size: 24)) + } + } + .padding() + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + ) + .contentShape(Rectangle()) + .onTapGesture { + onToggle(!isSelected) + } + } + + func cleanName(_ fullName: String) -> String { + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + let range = NSRange(location: 0, length: fullName.utf16.count) + let cleanedName = regex?.stringByReplacingMatches(in: fullName, options: [], range: range, withTemplate: "").trimmingCharacters(in: .whitespaces) ?? fullName + + return cleanedName + } +} + + +struct CreateCircleResponse: Decodable { + let circleId: String + let message: String + + enum CodingKeys: String, CodingKey { + case circleId = "circle_id" + case message + } +} + + diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index d4fd066..e1d65c5 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -1,94 +1,500 @@ +// JoinGroup.swift +// VITTY +// +// Created by Rujin Devkota on 2/28/25. +// import SwiftUI import AVFoundation +import UIKit struct JoinGroup: View { let screenHeight = UIScreen.main.bounds.height let screenWidth = UIScreen.main.bounds.width - + @Binding var groupCode: String @State private var isScanning = false @State private var scannedCode: String = "" - + @State private var showingAlert = false + @State private var alertMessage = "" + @State private var isJoining = false + @State private var showToast = false + @State private var toastMessage = "" + @State private var circleName = "" + @State private var localGroupCode = "" + + @Environment(AuthViewModel.self) private var authViewModel + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(\.dismiss) private var dismiss + var body: some View { - VStack(spacing: 20) { - Capsule() - .fill(Color.gray.opacity(0.5)) - .frame(width: 50, height: 5) - .padding(.top, 10) - - Text("Join Group") - .font(.system(size: 21, weight: .bold)) - .foregroundColor(.white) - - VStack(alignment: .leading, spacing: 10) { - Text("Enter group code") - .font(.system(size: 16, weight: .bold)) + ZStack { + VStack(spacing: 20) { + Capsule() + .fill(Color.gray.opacity(0.5)) + .frame(width: 50, height: 5) + .padding(.top, 10) + + Text("Join Circle") + .font(.system(size: 21, weight: .bold)) + .foregroundColor(.white) + + Spacer().frame(width: 20) + + VStack(alignment: .leading, spacing: 10) { + Text("Enter circle code") + .font(.system(size: 16, weight: .bold)) .foregroundColor(Color("Accent")) - - TextField("", text: $groupCode) - .padding() + + TextField("Enter circle code", text: $localGroupCode) + .padding() + .background(Color.black.opacity(0.3)) + .cornerRadius(8) + .foregroundColor(.white) + .onChange(of: localGroupCode) { oldValue, newValue in + let filtered = newValue.filter { $0.isLetter || $0.isNumber } + localGroupCode = filtered + groupCode = filtered + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) + ) + } + .padding(.horizontal, 20) + + HStack { + Rectangle() + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + Text("OR") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 10) + Rectangle() + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + } + .padding(.horizontal, 20) + + HStack { + Text("Scan QR Code") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(Color("Accent")) + .padding(.leading, 20) + Spacer() + } + + Button(action: { + isScanning = true + }) { + VStack { + if isScanning { + QRScannerView(scannedCode: $scannedCode, isScanning: $isScanning) + .frame(width: screenWidth * 0.8, height: screenHeight * 0.25) + } else { + Image(systemName: "qrcode.viewfinder") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .foregroundColor(Color.white) + + Text("Tap to scan QR code") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + } + .frame(width: screenWidth * 0.8, height: screenHeight * 0.25) .background(Color.black.opacity(0.3)) - .cornerRadius(8) - .foregroundColor(.white) + .cornerRadius(12) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 12) .stroke(Color.gray.opacity(0.5), lineWidth: 1) ) + } + .disabled(isJoining) + + Spacer() + + HStack { + Spacer() + Button(action: { + joinCircle() + }) { + HStack { + if isJoining { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(.white) + } + Text(isJoining ? "JOINING..." : "JOIN") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(isJoining ? .white : Color("Accent")) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isJoining ? Color.gray.opacity(0.5) : Color.clear) + ) + } + .disabled(isJoining || localGroupCode.isEmpty) + .padding(.trailing, 20) + } + .padding(.leading, 20) + .padding(.bottom, 20) } - .padding(.horizontal, 20) - + .presentationDetents([.height(screenHeight * 0.65)]) + .background(Color("Secondary")) + .onChange(of: scannedCode) { oldValue, newValue in + if !newValue.isEmpty { + handleScannedCode(newValue) + } + } + + if showToast { + VStack { + Spacer() + ToastView(message: toastMessage, isShowing: $showToast) + .padding(.bottom, 50) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .alert("Join Circle", isPresented: $showingAlert) { + Button("OK") { + if alertMessage.contains("successfully") || alertMessage.contains("requested") { + dismiss() + } + } + } message: { + Text(alertMessage) + } + .onOpenURL { url in + handleDeepLink(url) + } + .onAppear { + localGroupCode = groupCode + } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + + // MARK: - Handle Deep Link + private func handleDeepLink(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value else { + return + } + + let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value ?? "Unknown Circle" + + showJoinAlert(circleId: circleId, circleName: circleName) + } + + // MARK: - Show Join Alert + private func showJoinAlert(circleId: String, circleName: String) { + let alert = UIAlertController( + title: "Join Circle", + message: "Do you want to join '\(circleName)'?", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Join", style: .default) { _ in + self.localGroupCode = circleId + self.groupCode = circleId + self.circleName = circleName + self.joinCircle() + }) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + } + + private func handleScannedCode(_ code: String) { + if code.contains("vitty.app/invite") || code.contains("circleId=") { + if let components = URLComponents(string: code), + let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value { + localGroupCode = circleId + groupCode = circleId + if let name = components.queryItems?.first(where: { $0.name == "circleName" })?.value { + circleName = name + } + joinCircle() + } + } else { + localGroupCode = code + groupCode = code + joinCircle() + } + isScanning = false + } + + // MARK: - Join Circle + private func joinCircle() { + guard !localGroupCode.isEmpty, + let username = authViewModel.loggedInBackendUser?.username, + let token = authViewModel.loggedInBackendUser?.token else { + showToast(message: "Error: Unable to get user information", isError: true) + return + } + + if localGroupCode.count < 3 { + showToast(message: "Error: Circle code must be at least 3 characters", isError: true) + return + } + + isJoining = true + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + let urlString = "\(APIConstants.base_url)circles/sendRequest/\(localGroupCode)/\(username)" + guard let url = URL(string: urlString) else { + showToast(message: "Error: Invalid URL", isError: true) + isJoining = false + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isJoining = false + + if let error = error { + showToast(message: "Network error: \(error.localizedDescription)", isError: true) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + showToast(message: "Error: Invalid response", isError: true) + return + } + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + showToast(message: "Circle join request sent successfully! 🎉", isError: false) + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + + localGroupCode = "" + groupCode = "" + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + dismiss() + } + } else { + if let data = data, + let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = errorResponse["message"] as? String { + showToast(message: "Error: \(message)", isError: true) + } else { + switch httpResponse.statusCode { + case 400: + showToast(message: "Error: Invalid circle code", isError: true) + case 404: + showToast(message: "Error: Circle not found", isError: true) + case 409: + showToast(message: "Error: Already a member or request pending", isError: true) + default: + showToast(message: "Error: Failed to join circle (Code: \(httpResponse.statusCode))", isError: true) + } + } + } + } + }.resume() + } + + // MARK: - Show Toast + private func showToast(message: String, isError: Bool) { + toastMessage = message + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showToast = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showToast = false + } + } + } +} + + +// MARK: - Toast View +struct ToastView: View { + let message: String + @Binding var isShowing: Bool + + var body: some View { + if isShowing { HStack { - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) - Text("OR") - .font(.system(size: 16, weight: .bold)) + Text(message) + .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) - .padding(.horizontal, 10) - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) + .multilineTextAlignment(.center) } .padding(.horizontal, 20) - - HStack{ - Text("Scan Qr Code").font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")).padding(.leading,20) - Spacer() - } - - VStack { - Image(systemName: "qrcode.viewfinder") - .resizable() - .scaledToFit() - .frame(width: 120, height: 120) - .foregroundColor(Color.gray) - } - .frame(width: screenWidth*0.8, height: screenHeight*0.25) - .background(Color.black.opacity(0.3)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray.opacity(0.5), lineWidth: 1) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(Color.black.opacity(0.8)) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) ) - - Spacer() - - HStack { - Spacer() - Text("JOIN") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")).padding(.trailing,20) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onTapGesture { + withAnimation { + isShowing = false + } } - .padding(.leading, 20) - .padding(.bottom, 20) + } + } +} + + +struct QRScannerView: UIViewControllerRepresentable { + @Binding var scannedCode: String + @Binding var isScanning: Bool + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, QRScannerDelegate { + let parent: QRScannerView + + init(_ parent: QRScannerView) { + self.parent = parent } - .presentationDetents([.height(screenHeight * 0.65)]) - .background(Color("Secondary")) + func didScanCode(_ code: String) { + parent.scannedCode = code + parent.isScanning = false + } + + func didFailWithError(_ error: Error) { + parent.isScanning = false + } } } -#Preview { - JoinGroup(groupCode: .constant("")) + +protocol QRScannerDelegate: AnyObject { + func didScanCode(_ code: String) + func didFailWithError(_ error: Error) +} + +class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + weak var delegate: QRScannerDelegate? + + private var captureSession: AVCaptureSession! + private var previewLayer: AVCaptureVideoPreviewLayer! + + override func viewDidLoad() { + super.viewDidLoad() + setupCamera() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if captureSession?.isRunning == false { + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if captureSession?.isRunning == true { + captureSession.stopRunning() + } + } + + private func setupCamera() { + captureSession = AVCaptureSession() + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -1, userInfo: [NSLocalizedDescriptionKey: "Camera not available"])) + return + } + + let videoInput: AVCaptureDeviceInput + + do { + videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) + } catch { + delegate?.didFailWithError(error) + return + } + + if captureSession.canAddInput(videoInput) { + captureSession.addInput(videoInput) + } else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -2, userInfo: [NSLocalizedDescriptionKey: "Could not add video input"])) + return + } + + let metadataOutput = AVCaptureMetadataOutput() + + if captureSession.canAddOutput(metadataOutput) { + captureSession.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr] + } else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -3, userInfo: [NSLocalizedDescriptionKey: "Could not add metadata output"])) + return + } + + previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.frame = view.layer.bounds + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + captureSession.stopRunning() + + if let metadataObject = metadataObjects.first { + guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } + guard let stringValue = readableObject.stringValue else { return } + + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + delegate?.didScanCode(stringValue) + } + } + + override var prefersStatusBarHidden: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift new file mode 100644 index 0000000..05bcd18 --- /dev/null +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -0,0 +1,139 @@ +// +// QrCode.swift +// VITTY +// +// Created by Rujin Devkota on 6/24/25. +// + +import CoreImage.CIFilterBuiltins +import SwiftUI + + +struct QRCodeModalView: View { + let groupCode: String + let circleName: String + let onDismiss: () -> Void + + @State private var showingShareSheet = false + + var body: some View { + VStack { + Spacer() + VStack(spacing: 20) { + + HStack { + Text("Circle QR Code") + .font(.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(.white) + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark") + .foregroundColor(.white) + .font(.system(size: 18)) + } + } + + + VStack(spacing: 8) { + Text(circleName) + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + + } + + + if let qrImage = generateQRCode(from: createInvitationLink()) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .background(Color.white) + .cornerRadius(12) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 200, height: 200) + .cornerRadius(12) + .overlay( + Text("QR Code\nGeneration Failed") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + ) + } + + + Text("Share this code for others to join your circle") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal) + + + Button(action: { + showingShareSheet = true + }) { + HStack { + Image(systemName: "square.and.arrow.up") + Text("Share Invitation") + } + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(Color("Background")) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color("Accent")) + .cornerRadius(8) + } + } + .frame(maxWidth: 300) + .padding(24) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $showingShareSheet) { + ShareSheetQr(items: [createInvitationLink(), "Join my circle '\(circleName)' on VITTY!"]) + } + } + + private func createInvitationLink() -> String { + + let baseURL = "https://vitty.app/invite" + let encodedCircleName = circleName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? circleName + return "\(baseURL)/circles/sendRequest/\(groupCode)&circleName=\(encodedCircleName)" + } + + private func generateQRCode(from string: String) -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + + filter.message = Data(string.utf8) + + if let outputImage = filter.outputImage { + let scaleX = 200 / outputImage.extent.size.width + let scaleY = 200 / outputImage.extent.size.height + let transformedImage = outputImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) + + if let cgImage = context.createCGImage(transformedImage, from: transformedImage.extent) { + return UIImage(cgImage: cgImage) + } + } + return nil + } +} + +struct ShareSheetQr: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift new file mode 100644 index 0000000..2fbcf4a --- /dev/null +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift @@ -0,0 +1,269 @@ +// +// CircleRequests.swift +// VITTY +// +// Created by Rujin Devkota on 6/24/25. +// + + + +import SwiftUI + +struct CircleRequestRow: View { + let request: CircleRequest + let onAccept: () -> Void + let onDecline: () -> Void + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + + var body: some View { + HStack { + UserImage(url: "https://picsum.photos/200/300", height: 48, width: 48) + + Spacer().frame(width: 16) + + VStack(alignment: .leading, spacing: 4) { + Text("@\(request.from_username)") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text("wants to join \(request.circle_name)") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + .lineLimit(2) + } + + Spacer() + + if communityPageViewModel.loadingRequestAction { + ProgressView() + .scaleEffect(0.8) + .padding(.trailing) + } else { + HStack(spacing: 8) { + Button(action: onDecline) { + Image(systemName: "xmark") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color.red.opacity(0.8)) + .cornerRadius(18) + } + + Button(action: onAccept) { + Image(systemName: "checkmark") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color.green.opacity(0.8)) + .cornerRadius(18) + } + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + ) + .animation(.easeInOut(duration: 0.2), value: communityPageViewModel.loadingRequestAction) + } +} + +struct CircleRequestsView: View { + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.presentationMode) var presentationMode + + @State private var showSuccessAlert = false + @State private var alertMessage = "" + @State private var searchText = "" + + private var filteredRequests: [CircleRequest] { + if searchText.isEmpty { + return communityPageViewModel.circleRequests + } else { + return communityPageViewModel.circleRequests.filter { request in + request.from_username.localizedCaseInsensitiveContains(searchText) || + request.circle_name.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.title2) + } + + Spacer() + + Text("Group Requests") + .font(.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(.white) + + Spacer() + + + Button(action: { + refreshRequests() + }) { + Image(systemName: "arrow.clockwise") + .foregroundColor(.white) + .font(.system(size: 16)) + } + } + .padding() + + + SearchBar(searchText: $searchText) + .padding(.horizontal) + + Spacer().frame(height: 16) + + + if communityPageViewModel.loadingCircleRequests { + Spacer() + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.2) + Text("Loading requests...") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + Spacer() + + } else if communityPageViewModel.errorCircleRequests { + Spacer() + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(.red) + + Text("Failed to load requests") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text("Please try again") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + + Button(action: refreshRequests) { + Text("Retry") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color("Accent")) + .cornerRadius(20) + } + .padding(.top, 8) + } + Spacer() + + } else if filteredRequests.isEmpty { + Spacer() + VStack(spacing: 12) { + Image(systemName: searchText.isEmpty ? "person.2" : "magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(Color("Accent")) + + Text(searchText.isEmpty ? "No pending requests" : "No matching requests") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text(searchText.isEmpty ? + "You're all caught up! No one is waiting to join your circles." : + "Try adjusting your search terms") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + Spacer() + + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(filteredRequests, id: \.id) { request in + CircleRequestRow( + request: request, + onAccept: { + acceptRequest(request) + }, + onDecline: { + declineRequest(request) + } + ) + } + } + .padding(.horizontal) + .padding(.bottom, 100) + } + } + + Spacer() + } + .background(Color("Background").edgesIgnoringSafeArea(.all)) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .onAppear { + refreshRequests() + } + .refreshable { + refreshRequests() + } + .alert("Request Processed", isPresented: $showSuccessAlert) { + Button("OK") { } + } message: { + Text(alertMessage) + } + } + + private func refreshRequests() { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + communityPageViewModel.fetchCircleRequests(token: token) + } + + private func acceptRequest(_ request: CircleRequest) { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + + communityPageViewModel.acceptCircleRequest(circleId: request.circle_id, token: token) { success in + if success { + alertMessage = "@\(request.from_username) has been added to \(request.circle_name)" + showSuccessAlert = true + + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + } + } else { + alertMessage = "Failed to accept the request. Please try again." + showSuccessAlert = true + } + } + } + + private func declineRequest(_ request: CircleRequest) { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + + communityPageViewModel.declineCircleRequest(circleId: request.circle_id, token: token) { success in + if success { + alertMessage = "Request from @\(request.from_username) has been declined" + showSuccessAlert = true + } else { + alertMessage = "Failed to decline the request. Please try again." + showSuccessAlert = true + } + } + } +} diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index a99e29d..16c4279 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -4,7 +4,6 @@ // // Created by Rujin Devkota on 3/26/25. - import SwiftUI struct LeaveCircleAlert: View { @@ -59,14 +58,82 @@ struct LeaveCircleAlert: View { } } +struct CircleMenuView: View { + let circleName: String + let onLeaveGroup: () -> Void + let onGroupRequests: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 0) { + Button(action: { + onCancel() + onLeaveGroup() + }) { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + .foregroundColor(.red) + Text("Leave Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.red) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + + + + + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Background")) + } + } + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + struct InsideCircle: View { var circleName : String var groupCode: String @State var searchText: String = "" @State var showLeaveAlert: Bool = false + @State var showCircleMenu: Bool = false + @State var showGroupRequests : Bool = false @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(AuthViewModel.self) private var authViewModel @Environment(\.presentationMode) var presentationMode + @State var showQRCode: Bool = false + + + private var busyCount: Int { + communityPageViewModel.circleMembers.filter { + $0.status != nil && $0.status != "available" && $0.status != "free" + }.count + } + + private var availableCount: Int { + communityPageViewModel.circleMembers.filter { + $0.status == nil || $0.status == "available" || $0.status == "free" + }.count + } var body: some View { VStack(spacing: 0) { @@ -75,7 +142,7 @@ struct InsideCircle: View { presentationMode.wrappedValue.dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(.white) + .foregroundColor(.white).font(.title2) } Spacer() Text("Circle") @@ -83,10 +150,11 @@ struct InsideCircle: View { .foregroundColor(.white) Spacer() Button(action: { - showLeaveAlert = true + showCircleMenu = true }) { - Image(systemName: "rectangle.portrait.and.arrow.right") + Image(systemName: "ellipsis") .foregroundColor(.white) + .font(.system(size: 18)) } } .padding() @@ -101,31 +169,52 @@ struct InsideCircle: View { .font(.custom("Poppins-SemiBold", size: 20)) .foregroundColor(.white) Spacer() - Text(groupCode) - .font(.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) + } Spacer().frame(height: 5) HStack { - HStack { - Image("inclass").resizable().frame(width: 18, height: 18) - Text("3 busy") - .foregroundStyle(Color("Accent")) + // Dynamic busy count + if busyCount > 0 { + HStack { + Image("inclass").resizable().frame(width: 18, height: 18) + Text("\(busyCount) busy") + .foregroundStyle(Color("Accent")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color("Secondary")) + .cornerRadius(12) + + Spacer().frame(width: 10) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color("Secondary")) - .cornerRadius(12) - Spacer().frame(width: 10) - HStack { - Image("available").resizable().frame(width: 18, height: 18) - Text("2 available") - .foregroundStyle(Color("Accent")) + + // Dynamic available count + if availableCount > 0 { + HStack { + Image("available").resizable().frame(width: 18, height: 18) + Text("\(availableCount) available") + .foregroundStyle(Color("Accent")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color("Secondary")) + .cornerRadius(12) + } + + Spacer() + + + Button(action: { + showQRCode = true + print("QR Code tapped") + }) { + Image(systemName: "qrcode") + .foregroundColor(Color("Accent")) + .font(.system(size: 20)) + .padding(8) + .background(Color("Secondary")) + .cornerRadius(8) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color("Secondary")) - .cornerRadius(12) } } .padding() @@ -139,12 +228,12 @@ struct InsideCircle: View { } else { ScrollView { VStack(spacing: 10) { - ForEach(communityPageViewModel.circleMembers, id: \ .username) { member in + ForEach(communityPageViewModel.circleMembers, id: \.username) { member in InsideCircleRow( picture: member.picture, name: member.name, - status: "free", - venue: "318" + status: member.status ?? "free", + venue: member.venue ?? "available" ) .padding(.horizontal) } @@ -154,7 +243,9 @@ struct InsideCircle: View { } Spacer() } - .background(Color("Background").edgesIgnoringSafeArea(.all)) + .background(Color("Background").edgesIgnoringSafeArea(.all)).sheet(isPresented: $showGroupRequests, content: { + CircleRequestsView() + }) .onAppear { communityPageViewModel.fetchCircleMemberData( from: "\(APIConstants.base_url)circles/\(groupCode)", @@ -173,16 +264,39 @@ struct InsideCircle: View { communityPageViewModel.leaveCircle(from: url, token: token) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showLeaveAlert = false presentationMode.wrappedValue.dismiss() } }) } + + if showCircleMenu { + CircleMenuView( + circleName: circleName, + onLeaveGroup: { + showLeaveAlert = true + }, + onGroupRequests: { + showGroupRequests = true + print("Navigate to Circle Requests") + }, + onCancel: { + showCircleMenu = false + } + ) + } + if showQRCode { + QRCodeModalView( + groupCode: groupCode, + circleName: circleName, + onDismiss: { + showQRCode = false + } + ) + } } ) - .navigationBarHidden(true) .navigationBarBackButtonHidden(true) } diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 87aada1..3655743 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -4,9 +4,23 @@ // // Created by Rujin Devkota on 2/27/25. - import SwiftUI +enum SheetType: Identifiable { + case addCircleOptions + case createGroup + case joinGroup + case groupRequests + + var id: Int { + switch self { + case .addCircleOptions: return 0 + case .createGroup: return 1 + case .joinGroup: return 2 + case .groupRequests: return 3 + } + } +} struct ConnectPage: View { @Environment(AuthViewModel.self) private var authViewModel @@ -14,21 +28,20 @@ struct ConnectPage: View { @Environment(FriendRequestViewModel.self) private var friendRequestViewModel @State private var isShowingRequestView = false @State var isCircleView = false - @State var isAddCircleFunc = false - @State var showCreateGroupSheet = false - @State var showJoinGroupSheet = false + @State private var activeSheet: SheetType? + @State private var showCircleMenu = false + @Environment(\.dismiss) private var dismiss @Binding var isCreatingGroup : Bool @State private var isAddFriendsViewPresented = false @State private var selectedTab = 0 + @State private var hasLoadedInitialData = false var body: some View { ZStack { BackgroundView() - - VStack(spacing: 0) { HStack { @@ -37,10 +50,8 @@ struct ConnectPage: View { isCircleView = false } AcademicsTabButton(title: "Circles", isActive: selectedTab == 1) { - selectedTab = 1 isCircleView = true - } } .padding(.top,20) @@ -53,10 +64,10 @@ struct ConnectPage: View { } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) } + if isCircleView == false { Button(action: { isShowingRequestView.toggle() - }) { Image(systemName: "person.fill.badge.plus") .foregroundColor(.white) @@ -66,81 +77,219 @@ struct ConnectPage: View { destination: { AddFriendsView() } - ).offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) - } else{ + ) + .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) + } else { Button(action: { - isAddCircleFunc.toggle() - + showCircleMenu = true }) { - Image(systemName: "person.fill.badge.plus") + Image(systemName: "ellipsis") .foregroundColor(.white) + .font(.system(size: 18)) } - .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) + .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } - - }.sheet(isPresented: $isAddCircleFunc){ - ZStack{ - Color("Background") - HStack(spacing: 40) { - - Button(action:{ - showJoinGroupSheet.toggle() - }) { - VStack { - Image("joingroup") - .resizable() - .frame(width: 55, height: 55) - Text("Join Group") - .font(.system(size: 15)) - .foregroundStyle(Color.white) - } - } - - Button(action:{ - showJoinGroupSheet.toggle() - }) { - VStack { - Image("creategroup") - .resizable() - .frame(width: 55, height: 55) - Text("Create Group") - .font(.system(size: 15)) - .foregroundStyle(Color.white) - } - } - }.presentationDetents([.height(200)]) - .padding(.top, 10) - }.background(Color("Background")) - } - .sheet(isPresented: $showCreateGroupSheet) { - CreateGroup(groupCode:.constant("")) } - .sheet(isPresented: $showJoinGroupSheet) { - JoinGroup(groupCode: .constant("")) + .overlay( + Group { + if showCircleMenu { + ConnectCircleMenuView( + onCreateGroup: { + activeSheet = .createGroup + }, + onJoinGroup: { + activeSheet = .joinGroup + }, + onGroupRequests: { + activeSheet = .groupRequests + }, + onCancel: { + showCircleMenu = false + } + ) + } + } + ) + .sheet(item: $activeSheet) { sheetType in + switch sheetType { + case .addCircleOptions: + AddCircleOptionsView(activeSheet: $activeSheet) + case .createGroup: + CreateGroup(groupCode: .constant(""), token:authViewModel.loggedInBackendUser?.token ?? "" ) + case .joinGroup: + JoinGroup(groupCode: .constant("")) + case .groupRequests: + CircleRequestsView() + } } .onAppear { - - communityPageViewModel.fetchFriendsData( - from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)requests/")!, - authToken: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - + let shouldShowLoading = !hasLoadedInitialData + + + if communityPageViewModel.friends.isEmpty || !hasLoadedInitialData { + communityPageViewModel.fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + if communityPageViewModel.circles.isEmpty || !hasLoadedInitialData { + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + if communityPageViewModel.circleRequests.isEmpty || !hasLoadedInitialData { + friendRequestViewModel.fetchFriendRequests( + from: URL(string: "\(APIConstants.base_url)requests/")!, + authToken: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + hasLoadedInitialData = true } } } +struct ConnectCircleMenuView: View { + let onCreateGroup: () -> Void + let onJoinGroup: () -> Void + let onGroupRequests: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 0) { + Button(action: { + onCancel() + onCreateGroup() + }) { + HStack { + Image("creategroup") + .resizable() + .frame(width: 24, height: 24) + Text("Create Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + onJoinGroup() + }) { + HStack { + Image("joingroup") + .resizable() + .frame(width: 24, height: 24) + Text("Join Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + onGroupRequests() + }) { + HStack { + Image(systemName: "person.badge.plus") + .foregroundColor(.white) + Text("Group Requests") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Background")) + } + } + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + +struct AddCircleOptionsView: View { + @Binding var activeSheet: SheetType? + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Color("Background") + HStack(spacing: 40) { + Button(action: { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + activeSheet = .joinGroup + } + }) { + VStack { + Image("joingroup") + .resizable() + .frame(width: 55, height: 55) + Text("Join Group") + .font(.system(size: 15)) + .foregroundStyle(Color.white) + } + } + + Button(action: { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + activeSheet = .createGroup + } + }) { + VStack { + Image("creategroup") + .resizable() + .frame(width: 55, height: 55) + Text("Create Group") + .font(.system(size: 15)) + .foregroundStyle(Color.white) + } + } + } + .padding(.top, 10) + } + .background(Color("Background")) + .presentationDetents([.height(150)]) + } +} struct FilterPill: View { let title: String @@ -161,5 +310,4 @@ struct FilterPill: View { ) ) } - } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index f56c353..75b76d9 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -5,6 +5,7 @@ // Created by Rujin Devkota on 2/27/25. // import SwiftUI + struct FriendsView: View { @State private var searchText = "" @State private var selectedFilterOption = 0 @@ -17,7 +18,8 @@ struct FriendsView: View { SearchBar(searchText: $searchText) Spacer().frame(height: 8) - // Filter pills - always visible + + HStack { FilterPill(title: "Available", isSelected: selectedFilterOption == 0) .onTapGesture { @@ -32,7 +34,7 @@ struct FriendsView: View { .padding(.horizontal) Spacer().frame(height: 7) - // Conditional content based on state + if communityPageViewModel.errorFreinds { Spacer() VStack(spacing: 5) { @@ -51,27 +53,53 @@ struct FriendsView: View { ProgressView() Spacer() } else { - // Filter friends based on search text + let filteredFriends = communityPageViewModel.friends.filter { friend in + + let matchesSearch: Bool if searchText.isEmpty { - return true + matchesSearch = true } else { - return friend.username.localizedCaseInsensitiveContains(searchText) || + matchesSearch = friend.username.localizedCaseInsensitiveContains(searchText) || (friend.name.localizedCaseInsensitiveContains(searchText) ?? false) } + + + let matchesFilter: Bool + switch selectedFilterOption { + case 0: + matchesFilter = friend.currentStatus.status == "free" + case 1: + matchesFilter = true + default: + matchesFilter = true + } + + return matchesSearch && matchesFilter } if filteredFriends.isEmpty { Spacer() - Text("No friends match your search") - .font(Font.custom("Poppins-Regular", size: 16)) - .foregroundColor(.white) + VStack(spacing: 5) { + if selectedFilterOption == 0 && !searchText.isEmpty { + Text("No available friends match your search") + } else if selectedFilterOption == 0 { + Text("No friends are currently available") + } else if !searchText.isEmpty { + Text("No friends match your search") + } else { + Text("You don't have any friends yet") + } + } + .font(Font.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + .multilineTextAlignment(.center) Spacer() } else { ScrollView { VStack(spacing: 10) { ForEach(filteredFriends, id: \.username) { friend in - NavigationLink(destination: TimeTableView(friend: friend)) { + NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { FriendRow(friend: friend) } } @@ -79,10 +107,10 @@ struct FriendsView: View { .padding(.horizontal) } .safeAreaPadding(.bottom, 100) - } } - }.refreshable { + } + .refreshable { communityPageViewModel.fetchFriendsData( from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", token: authViewModel.loggedInBackendUser?.token ?? "", diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 24fbeb9..e09862e 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -4,119 +4,265 @@ // // Created by Chandram Dutta on 04/01/24. // +// import Foundation import Alamofire import OSLog - - @Observable class CommunityPageViewModel { var friends = [Friend]() var circles = [CircleModel]() + var circleRequests = [CircleRequest]() + var loadingFreinds = false var loadingCircle = false var loadingCircleMembers = false + var loadingCircleRequests = false + var loadingRequestAction = false var errorFreinds = false var errorCircle = false var errorCircleMembers = false + var errorCircleRequests = false + var circleMembers = [CircleUserTemp]() + + var circleMembersDict: [String: [CircleUserTemp]] = [:] + var loadingCircleMembersDict: [String: Bool] = [:] private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: CommunityPageViewModel.self) ) - func fetchFriendsData(from url: String, token: String, loading: Bool) { - self.loadingFreinds = loading + func fetchFriendsData(from url: String, token: String, loading: Bool = false) { + + if loading || friends.isEmpty { + self.loadingFreinds = true + } + + + self.errorFreinds = false + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: FriendRaw.self) { response in - - switch response.result { + DispatchQueue.main.async { + self.loadingFreinds = false + + switch response.result { case .success(let data): self.friends = data.data - self.loadingFreinds = false - - + self.errorFreinds = false + case .failure(let error): - self.logger.error("Error fetching data: \(error)") - self.loadingFreinds = false - self.errorFreinds.toggle() + self.logger.error("Error fetching friends: \(error)") + + if self.friends.isEmpty { + self.errorFreinds = true + } + } } } } //MARK: Circle DATA - func fetchCircleData(from url: String, token: String, loading: Bool) { - self.loadingCircle = loading + func fetchCircleData(from url: String, token: String, loading: Bool = false) { + + if loading || circles.isEmpty { + self.loadingCircle = true + } + + + self.errorCircle = false + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleResponse.self) { response in - print("***********") - print(response) - switch response.result { + DispatchQueue.main.async { + self.loadingCircle = false + + switch response.result { case .success(let data): self.circles = data.data - self.loadingCircle = false - print(data.data) - print("Successfully fetched circles:") - print(data.data) + self.errorCircle = false + print("Successfully fetched circles: \(data.data)") + case .failure(let error): self.logger.error("Error fetching circles: \(error)") - self.loadingCircle = false - self.errorCircle.toggle() + + if self.circles.isEmpty { + self.errorCircle = true + } + } } } } - //MARK : Circle Members NetwrokCall - func fetchCircleMemberData(from url: String, token: String, loading: Bool) { - self.loadingCircleMembers = loading + + // MARK: - Circle Requests + + func fetchCircleRequests(token: String, loading: Bool = false) { + if loading || circleRequests.isEmpty { + self.loadingCircleRequests = true + } + + self.errorCircleRequests = false + + let url = "\(APIConstants.base_url)circles/requests/received" AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() - .responseDecodable(of: CircleUserResponseTemp.self) { response in - print("***********") - - switch response.result { + .responseData { response in + DispatchQueue.main.async { + self.loadingCircleRequests = false + switch response.result { case .success(let data): - self.circleMembers = data.data - self.loadingCircleMembers = false - print(data.data) - print("Successfully fetched circles members :") - print(data.data) + do { + let decodedResponse = try JSONDecoder().decode(CircleRequestResponse.self, from: data) + self.circleRequests = decodedResponse.data + self.errorCircleRequests = false + self.logger.info("Successfully fetched circle requests: \(decodedResponse.data.count) requests") + } catch { + self.logger.error("Error decoding circle requests: \(error)") + + if let jsonString = String(data: data, encoding: .utf8) { + self.logger.info("Raw response: \(jsonString)") + } + + response + self.circleRequests = [] + self.errorCircleRequests = false + } + case .failure(let error): - self.logger.error("Error fetching circles members: \(error)") - self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + self.logger.error("Error fetching circle requests: \(error)") + if self.circleRequests.isEmpty { + self.errorCircleRequests = true + } + } + } + } + } + + func acceptCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { + self.loadingRequestAction = true + + let url = "\(APIConstants.base_url)circles/acceptRequest/\(circleId)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + self.loadingRequestAction = false + + switch response.result { + case .success: + self.logger.info("Successfully accepted circle request for circle: \(circleId)") + self.circleRequests.removeAll { $0.circle_id == circleId } + completion(true) + + case .failure(let error): + self.logger.error("Error accepting circle request: \(error)") + completion(false) + } + } + } + } + + func declineCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { + self.loadingRequestAction = true + + let url = "\(APIConstants.base_url)circles/declineRequest/\(circleId)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + self.loadingRequestAction = false + + switch response.result { + case .success: + self.logger.info("Successfully declined circle request for circle: \(circleId)") + self.circleRequests.removeAll { $0.circle_id == circleId } + completion(true) + + case .failure(let error): + self.logger.error("Error declining circle request: \(error)") + completion(false) + } + } + } + } + + func fetchCircleMemberData(from url: String, token: String, loading: Bool = false, circleID: String? = nil) { + if let circleID = circleID { + if loading || circleMembersDict[circleID]?.isEmpty != false { + self.loadingCircleMembersDict[circleID] = true + } + } else { + if loading || circleMembers.isEmpty { + self.loadingCircleMembers = true + } + } + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: CircleUserResponseTemp.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + if let circleID = circleID { + self.circleMembersDict[circleID] = data.data + self.loadingCircleMembersDict[circleID] = false + } else { + self.circleMembers = data.data + self.loadingCircleMembers = false + } + print("Successfully fetched circle members: \(data.data)") + + case .failure(let error): + self.logger.error("Error fetching circle members: \(error)") + + if let circleID = circleID { + self.loadingCircleMembersDict[circleID] = false + } else { + self.loadingCircleMembers = false + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } + } + } } } } + //MARK : Circle Leave - func fetchCircleLeave(from url: String, token: String, loading: Bool) { - self.loadingCircleMembers = loading + func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { + if loading { + self.loadingCircleMembers = true + } AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in - print("***********") - - switch response.result { + DispatchQueue.main.async { + self.loadingCircleMembers = false + switch response.result { case .success(let data): - self.circleMembers = data.data - self.loadingCircleMembers = false - print(data.data) - print("Successfully fetched circles members :") - print(data.data) + self.circleMembers = data.data + print("Successfully fetched circle members after leave: \(data.data)") + case .failure(let error): - self.logger.error("Error fetching circles members: \(error)") - self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + self.logger.error("Error fetching circle members: \(error)") + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } + } } } } @@ -129,20 +275,131 @@ class CommunityPageViewModel { AF.request(url, method: .delete, headers: ["Authorization": "Token \(token)"]) .validate() .response { response in - switch response.result { - case .success(let value): - if let json = value as? [String: Any], let detail = json["detail"] as? String { - self.logger.info("Success: \(detail)") - } + DispatchQueue.main.async { self.loadingCircleMembers = false - - case .failure(let error): - self.logger.error("Error leaving circle: \(error)") - self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + + switch response.result { + case .success(let value): + if let json = value as? [String: Any], let detail = json["detail"] as? String { + self.logger.info("Success: \(detail)") + } + + case .failure(let error): + self.logger.error("Error leaving circle: \(error)") + self.errorCircleMembers = true + } } } } - + // MARK: Helper methods for circle members + + func circleMembers(for circleID: String) -> [CircleUserTemp] { + return circleMembersDict[circleID] ?? [] + } + + func isLoadingCircleMembers(for circleID: String) -> Bool { + return loadingCircleMembersDict[circleID] ?? false + } + + func clearCircleMembers(for circleID: String) { + circleMembersDict.removeValue(forKey: circleID) + loadingCircleMembersDict.removeValue(forKey: circleID) + } + + // MARK: - Group Creation + + func createCircle(name: String, token: String, completion: @escaping (Result<String, Error>) -> Void) { + let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name + let url = "\(APIConstants.base_url)circles/create/\(encodedName)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseJSON { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + if let json = data as? [String: Any], + let circleId = json["circle_id"] as? String { + self.logger.info("Successfully created circle: \(circleId)") + completion(.success(circleId)) + } else { + + if let json = data as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let circleId = dataDict["id"] as? String { + self.logger.info("Successfully created circle: \(circleId)") + completion(.success(circleId)) + } else { + let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + completion(.failure(error)) + } + } + + case .failure(let error): + self.logger.error("Error creating circle: \(error)") + completion(.failure(error)) + } + } + } + } + + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + switch response.result { + case .success: + self.logger.info("Successfully sent invitation to \(username) for circle \(circleId)") + completion(true) + + case .failure(let error): + self.logger.error("Error sending invitation to \(username): \(error)") + completion(false) + } + } + } + } + + func sendMultipleInvitations(circleId: String, usernames: [String], token: String, completion: @escaping ([String: Bool]) -> Void) { + let dispatchGroup = DispatchGroup() + var results: [String: Bool] = [:] + + for username in usernames { + dispatchGroup.enter() + + sendCircleInvitation(circleId: circleId, username: username, token: token) { success in + results[username] = success + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + completion(results) + } + } + + // MARK: - Refresh Methods + + func refreshAllData(token: String, username: String) { + + fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(username)/", + token: token, + loading: false + ) + + + fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + + + fetchCircleRequests(token: token, loading: false) + } } diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 84494c6..2b15a06 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -52,7 +52,7 @@ struct HomeView: View { ZStack { switch selectedPage { case 1: - TimeTableView(friend: nil) + TimeTableView(friend: nil,isFriendsTimeTable: false) case 2: ConnectPage(isCreatingGroup: $isCreatingGroup) case 3: diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 44cce07..19fbd36 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -1,6 +1,9 @@ import SwiftUI import SwiftData + + + struct SettingsView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.dismiss) private var dismiss @@ -12,6 +15,7 @@ struct SettingsView: View { @State private var showDaySelection = false @State private var selectedDay: String? = nil + @State private var showResetAlert = false private let selectedDayKey = "SelectedSaturdayDay" @@ -45,11 +49,9 @@ struct SettingsView: View { } SettingsSectionView(title: "Class Settings") { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 12) { Button { - withAnimation(.easeInOut(duration: 0.5)) { - showDaySelection.toggle() - } + showDaySelection.toggle() } label: { SettingsRowView( icon: "calendar.badge.plus", @@ -57,6 +59,7 @@ struct SettingsView: View { subtitle: selectedDay == nil ? "Select a day to copy classes to Saturday" : "Copy \(selectedDay!) classes to Saturday" ) } + .buttonStyle(PlainButtonStyle()) if showDaySelection { VStack(alignment: .leading, spacing: 8) { @@ -77,11 +80,7 @@ struct SettingsView: View { selectedDay = day UserDefaults.standard.set(day, forKey: selectedDayKey) copyLecturesToSaturday(from: day) - - - withAnimation(.easeInOut(duration: 0.3)) { - showDaySelection = false - } + showDaySelection = false } } } @@ -92,17 +91,29 @@ struct SettingsView: View { )) } - SettingsRowView( - icon: "pencil.and.ellipsis.rectangle", - title: "Update Timetable", - subtitle: "Keep your timetable up-to-date. Don't miss a class." - ).onTapGesture { + + Button { + showResetAlert = true + } label: { + SettingsRowView( + icon: "trash.circle.fill", + title: "Reset Saturday Classes", + subtitle: "Remove all classes from Saturday" + ) + } + .buttonStyle(PlainButtonStyle()) + Button { if let url = URL(string: "https://vitty.dscvit.com") { UIApplication.shared.open(url) } + } label: { + SettingsRowView( + icon: "pencil.and.ellipsis.rectangle", + title: "Update Timetable", + subtitle: "Keep your timetable up-to-date. Don't miss a class." + ) } - - + .buttonStyle(PlainButtonStyle()) } } @@ -126,6 +137,20 @@ struct SettingsView: View { } .scrollContentBackground(.hidden) } + + + if showResetAlert { + ResetSaturdayAlert( + onCancel: { + showResetAlert = false + }, + onReset: { + resetSaturdayClasses() + showResetAlert = false + } + ) + .zIndex(1) + } } .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) @@ -150,6 +175,38 @@ struct SettingsView: View { selectedDay = UserDefaults.standard.string(forKey: selectedDayKey) } + private func resetSaturdayClasses() { + guard let timeTable = timeTables.first else { return } + + + let newTimeTable = TimeTable( + monday: timeTable.monday, + tuesday: timeTable.tuesday, + wednesday: timeTable.wednesday, + thursday: timeTable.thursday, + friday: timeTable.friday, + saturday: [], // Empty Saturday + sunday: timeTable.sunday + ) + + + modelContext.delete(timeTable) + modelContext.insert(newTimeTable) + + + do { + try modelContext.save() + print("Successfully reset Saturday classes") + + + UserDefaults.standard.removeObject(forKey: selectedDayKey) + selectedDay = nil + + } catch { + print("Error saving context: \(error)") + } + } + private func copyLecturesToSaturday(from day: String) { guard let timeTable = timeTables.first else { return } @@ -257,8 +314,11 @@ struct SettingsView: View { .font(.system(size: 12)) .foregroundColor(.gray.opacity(0.8)) } + + Spacer() } .padding(.vertical, 6) + .contentShape(Rectangle()) } } @@ -287,3 +347,58 @@ struct SettingsView: View { } } } + +// Custom Reset Alert Component +struct ResetSaturdayAlert: View { + let onCancel: () -> Void + let onReset: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Reset Saturday Classes?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to remove all classes from Saturday? This action cannot be undone.") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onReset) { + Text("Reset") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .onTapGesture { + + } + } +} diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift index 8c9d67b..64bf1f7 100644 --- a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -2,13 +2,11 @@ import Foundation import SwiftUI import UserNotifications - class SettingsViewModel : ObservableObject{ - var notificationsEnabled: Bool = false { + @Published var notificationsEnabled: Bool = false { didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") if notificationsEnabled { - if let timetable = self.timetable { self.scheduleAllNotifications(from: timetable) } @@ -19,12 +17,12 @@ class SettingsViewModel : ObservableObject{ } } - var timetable: TimeTable? - var showNotificationDisabledAlert = false + @Published var timetable: TimeTable? + @Published var showNotificationDisabledAlert = false init(timetable: TimeTable? = nil) { self.timetable = timetable - // Load the stored value + self.notificationsEnabled = UserDefaults.standard.bool(forKey: "notificationsEnabled") checkNotificationAuthorization() } @@ -40,8 +38,6 @@ class SettingsViewModel : ObservableObject{ } func requestNotificationPermission() { - // Since you handle permission elsewhere, this method can be simplified or removed - // Just schedule notifications if permission is already granted UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { if settings.authorizationStatus == .authorized { @@ -56,24 +52,35 @@ class SettingsViewModel : ObservableObject{ } func scheduleAllNotifications(from timetable: TimeTable) { + // Clear existing notifications first + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + let weekdays: [(Int, [Lecture])] = [ - (2, timetable.monday), - (3, timetable.tuesday), - (4, timetable.wednesday), - (5, timetable.thursday), - (6, timetable.friday), - (7, timetable.saturday), - (1, timetable.sunday) + (2, timetable.monday), // Monday = 2 + (3, timetable.tuesday), // Tuesday = 3 + (4, timetable.wednesday), // Wednesday = 4 + (5, timetable.thursday), // Thursday = 5 + (6, timetable.friday), // Friday = 6 + (7, timetable.saturday), // Saturday = 7 + (1, timetable.sunday) // Sunday = 1 ] for (weekday, lectures) in weekdays { for lecture in lectures { - guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { continue } + guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { + print("Failed to parse time for lecture: \(lecture.name) with time: \(lecture.startTime)") + continue + } + scheduleNotification(for: lecture.name, at: startDate, title: "Class Starting", minutesBefore: 0) + + scheduleNotification(for: lecture.name, at: startDate, title: "Upcoming Class", minutesBefore: 10) } } + + print("Scheduled notifications for all lectures") } private func scheduleNotification(for lectureName: String, at date: Date, title: String, minutesBefore: Int) { @@ -87,28 +94,89 @@ class SettingsViewModel : ObservableObject{ let triggerComponents = Calendar.current.dateComponents([.weekday, .hour, .minute], from: triggerDate) let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: true) + let identifier = "\(lectureName)-\(title)-\(minutesBefore)min-weekday\(triggerComponents.weekday ?? 0)" let request = UNNotificationRequest( - identifier: "\(lectureName)-\(title)-\(triggerDate)", + identifier: identifier, content: content, trigger: trigger ) - UNUserNotificationCenter.current().add(request) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error scheduling notification: \(error)") + } else { + print("Successfully scheduled notification: \(identifier)") + } + } } + private func parseLectureTime(_ timeString: String, weekday: Int) -> Date? { - var cleaned = timeString.components(separatedBy: "T").last ?? "" - cleaned = cleaned.components(separatedBy: "Z").first ?? "" - - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - guard let time = formatter.date(from: cleaned) else { return nil } - - var components = Calendar.current.dateComponents([.year, .month, .weekOfYear], from: Date()) - components.weekday = weekday - components.hour = Calendar.current.component(.hour, from: time) - components.minute = Calendar.current.component(.minute, from: time) - - return Calendar.current.nextDate(after: Date(), matching: components, matchingPolicy: .nextTime) + + let formattedTimeString = formatTime(time: timeString) + + + if formattedTimeString == "Failed to parse the time string." { + return nil + } + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mm a" + timeFormatter.locale = Locale(identifier: "en_US_POSIX") + + guard let timeDate = timeFormatter.date(from: formattedTimeString) else { + print("Failed to parse formatted time: \(formattedTimeString)") + return nil + } + + + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute], from: timeDate) + + + let today = Date() + let currentWeekday = calendar.component(.weekday, from: today) + + + let daysFromToday = weekday - currentWeekday + let targetDate = calendar.date(byAdding: .day, value: daysFromToday, to: today) ?? today + + + var finalDateComponents = calendar.dateComponents([.year, .month, .day], from: targetDate) + finalDateComponents.hour = timeComponents.hour + finalDateComponents.minute = timeComponents.minute + finalDateComponents.second = 0 + + guard let lectureDate = calendar.date(from: finalDateComponents) else { + print("Failed to create lecture date") + return nil + } + + + if weekday == currentWeekday && lectureDate < today { + return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) + } + + + if lectureDate < today { + return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) + } + + return lectureDate + } + + // Your existing formatTime function + private func formatTime(time: String) -> String { + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return formattedTime + } else { + return "Failed to parse the time string." + } } } diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 0c37cd6..1e3dafb 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -8,8 +8,10 @@ import Foundation class Constants { - static let url = "https://vitty-api.dscvit.com/api/v2/" + static let url = -// "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + +// "https://vitty-api.dscvit.com/api/v2/" } diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index 68c6fa8..4c933d3 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -208,21 +208,26 @@ class Lecture: Codable, Identifiable, Comparable { try container.encode(endTime, forKey: .endTime) } } + extension TimeTable { var isEmpty: Bool { monday.isEmpty && tuesday.isEmpty && wednesday.isEmpty && thursday.isEmpty && friday.isEmpty && saturday.isEmpty && sunday.isEmpty } - private func extractStartDate(from timeString: String) -> Date? { - let components = timeString.components(separatedBy: " - ") - guard let startTimeString = components.first else { return nil } + + private func extractStartTime(from lecture: Lecture) -> Date? { + let formattedTime = formatTime(time: lecture.startTime) + + + guard formattedTime != "Failed to parse the time string." else { return nil } - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.locale = Locale(identifier: "en_US_POSIX") + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + + return formatter.date(from: formattedTime) + } - return formatter.date(from: startTimeString) - } func classesFor(date: Date) -> [Classes] { let calendar = Calendar.current @@ -248,29 +253,45 @@ extension TimeTable { ) } - return mapped.sorted { - guard let d1 = extractStartDate(from: $0.time), - let d2 = extractStartDate(from: $1.time) else { + // Sort using the original lecture objects instead of formatted strings + return lectures.sorted { lecture1, lecture2 in + guard let time1 = extractStartTime(from: lecture1), + let time2 = extractStartTime(from: lecture2) else { return false } - return d1 < d2 + return time1 < time2 + }.map { + Classes( + title: $0.name, + time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", + slot: $0.slot + ) } } - - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return formattedTime - } else { - return "Failed to parse the time string." + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } } + + func isDifferentFrom(_ other: TimeTable) -> Bool { + return monday != other.monday || + tuesday != other.tuesday || + wednesday != other.wednesday || + thursday != other.thursday || + friday != other.friday || + saturday != other.saturday || + sunday != other.sunday } } diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index 97b6bf9..b66bc69 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -10,71 +10,214 @@ import OSLog import SwiftData public enum Stage { - case loading - case error - case data + case loading + case error + case data } -extension TimeTableView { - - @Observable - class TimeTableViewModel { - - var timeTable: TimeTable? - var stage: Stage = .loading - var lectures = [Lecture]() - var dayNo = Date.convertToMondayWeek() - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: TimeTableViewModel.self - ) - ) - - func changeDay() { - switch dayNo { - case 0: - self.lectures = timeTable?.monday ?? [] - case 1: - self.lectures = timeTable?.tuesday ?? [] - case 2: - self.lectures = timeTable?.wednesday ?? [] - case 3: - self.lectures = timeTable?.thursday ?? [] - case 4: - self.lectures = timeTable?.friday ?? [] - case 5: - self.lectures = timeTable?.saturday ?? [] - case 6: - self.lectures = timeTable?.sunday ?? [] - default: - self.lectures = [] - } - } +extension TimeTableView { + @Observable + class TimeTableViewModel { - - func fetchTimeTable(username: String, authToken: String) async { - logger.info("Fetching TimeTable Started") - do { - stage = .loading - let data = try await TimeTableAPIService.shared.getTimeTable( - with: username, - authToken: authToken - ) - logger.info("TimeTable Fetched from API") - timeTable = data - changeDay() - stage = .data - } - catch { - logger.error("\(error)") - stage = .error - } - logger.info("Fetching TimeTable Ended") - } + var timeTable: TimeTable? + var stage: Stage = .loading + var lectures = [Lecture]() + var dayNo = Date.convertToMondayWeek() + + private var hasSyncedThisSession = false + private var isSyncing = false + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String( + describing: TimeTableViewModel.self + ) + ) + + func changeDay() { + switch dayNo { + case 0: + self.lectures = timeTable?.monday ?? [] + case 1: + self.lectures = timeTable?.tuesday ?? [] + case 2: + self.lectures = timeTable?.wednesday ?? [] + case 3: + self.lectures = timeTable?.thursday ?? [] + case 4: + self.lectures = timeTable?.friday ?? [] + case 5: + self.lectures = timeTable?.saturday ?? [] + case 6: + self.lectures = timeTable?.sunday ?? [] + default: + self.lectures = [] + } + } + + @MainActor + func loadTimeTable( + existingTimeTable: TimeTable?, + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Starting timetable loading process") + + if let existing = existingTimeTable { + logger.debug("Using existing local timetable") + timeTable = existing + changeDay() + stage = .data + print("\(existing)") + + if !hasSyncedThisSession && !isSyncing { + Task { + await backgroundSync( + localTimeTable: existing, + username: username, + authToken: authToken + ) + } + + } + } else { + logger.debug("No local timetable, fetching from API") + await fetchTimeTableFromAPI( + username: username, + authToken: authToken, + context: context + ) + } + } - + private func backgroundSync( + localTimeTable: TimeTable, + username: String, + authToken: String + ) async { + guard !isSyncing else { return } + + isSyncing = true + hasSyncedThisSession = true + + logger.info("Starting background sync") + + do { + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + logger.info("Background sync: Fetched remote timetable") + + if shouldUpdateLocalTimeTable(local: localTimeTable, remote: remoteTimeTable) { + logger.info("Background sync: Timetables differ, updating local data") + await updateLocalTimeTable(newTimeTable: remoteTimeTable) + } else { + logger.info("Background sync: Timetables are identical, no update needed") + } + + } catch { + logger.error("Background sync failed: \(error)") + } + + isSyncing = false + } - } + private func shouldUpdateLocalTimeTable(local: TimeTable, remote: TimeTable) -> Bool { + let daysToCompare = [ + (local.monday, remote.monday), + (local.tuesday, remote.tuesday), + (local.wednesday, remote.wednesday), + (local.thursday, remote.thursday), + (local.friday, remote.friday), + (local.sunday, remote.sunday) + ] + + for (localDay, remoteDay) in daysToCompare { + if !areLectureArraysEqual(localDay, remoteDay) { + return true + } + } + + return false + } + + private func areLectureArraysEqual(_ local: [Lecture], _ remote: [Lecture]) -> Bool { + guard local.count == remote.count else { return false } + + let sortedLocal = local.sorted { $0.startTime < $1.startTime } + let sortedRemote = remote.sorted { $0.startTime < $1.startTime } + + for (localLecture, remoteLecture) in zip(sortedLocal, sortedRemote) { + if !areLecturesEqual(localLecture, remoteLecture) { + return false + } + } + + return true + } + + private func areLecturesEqual(_ local: Lecture, _ remote: Lecture) -> Bool { + return local.name == remote.name && + local.code == remote.code && + local.venue == remote.venue && + local.slot == remote.slot && + local.type == remote.type && + local.startTime == remote.startTime && + local.endTime == remote.endTime + } + + + + @MainActor + private func updateLocalTimeTable(newTimeTable: TimeTable) async { + timeTable = newTimeTable + changeDay() + logger.info("Timetable updated in memory, view will handle persistence") + } + + @MainActor + private func fetchTimeTableFromAPI( + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Fetching TimeTable from API") + + do { + stage = .loading + let data = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + logger.info("TimeTable fetched from API") + + timeTable = data + changeDay() + stage = .data + + context.insert(data) + hasSyncedThisSession = true + + } catch { + logger.error("API fetch failed: \(error)") + stage = .error + } + } + + var updatedTimeTable: TimeTable? { + timeTable + } + + func resetSyncStatus() { + hasSyncedThisSession = false + logger.debug("Sync status reset") + } + } } + + diff --git a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift index 8aa7a11..8f16f2a 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift @@ -89,19 +89,19 @@ struct LectureDetailView: View { } } - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + private func formatTime(time: String) -> String { + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return (formattedTime) - } - else { - return ("Failed to parse the time string.") - } - } + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } + } } diff --git a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift index b557ed3..206f806 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift @@ -59,26 +59,21 @@ struct LectureItemView: View { } private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return formattedTime - } else { - return "Failed to parse the time string." + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } } - } } -#Preview { - LectureItemView( - lecture: Lecture(name: "hello", code: "qww", venue: "123", slot: "asd", type: "asad", startTime: "time1", endTime: "time") - , onTap: {} - ) -} diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index b32a10f..7fa9a78 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -1,31 +1,46 @@ + import OSLog import SwiftData import SwiftUI - struct TimeTableView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - + @State private var viewModel = TimeTableViewModel() @State private var selectedLecture: Lecture? = nil - @Query private var timetableItem : [TimeTable] + @Query private var timetableItem: [TimeTable] + @Environment(\.dismiss) private var dismiss let friend: Friend? - + + var isFriendsTimeTable: Bool + private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String( describing: TimeTableView.self ) ) - + var body: some View { - NavigationStack{ + NavigationStack { ZStack { BackgroundView() - switch viewModel.stage { + VStack { + if isFriendsTimeTable { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + } + Spacer() + }.padding(8) + } + + switch viewModel.stage { case .loading: VStack { Spacer() @@ -48,11 +63,11 @@ struct TimeTableView: View { ForEach(daysOfWeek, id: \.self) { day in Text(day) .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) + ? Color("Background") : Color("Accent")) .frame(width: 60, height: 54) .background( daysOfWeek[viewModel.dayNo] == day - ? Color("Accent") : Color.clear + ? Color("Accent") : Color.clear ) .onTapGesture { withAnimation { @@ -71,7 +86,6 @@ struct TimeTableView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) - if viewModel.lectures.isEmpty { Spacer() Text("No classes today!") @@ -93,35 +107,34 @@ struct TimeTableView: View { } } } - } - } - .sheet(item: $selectedLecture) { lecture in - LectureDetailView(lecture: lecture) - } - .onAppear { - logger.debug("onAppear triggered") - - - if let existing = timetableItem.first { - logger.debug("existing timetable found") - viewModel.timeTable = existing - viewModel.changeDay() - viewModel.stage = .data - } else { - logger.debug("no local timetable, fetching from API") - Task { - await viewModel.fetchTimeTable( - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), - authToken: authViewModel.loggedInBackendUser?.token ?? "" - ) - if let fetched = viewModel.timeTable { - context.insert(fetched) - } } } } - - + } + .sheet(item: $selectedLecture) { lecture in + LectureDetailView(lecture: lecture) + } + .navigationBarBackButtonHidden(true) + .onAppear { + logger.debug("onAppear triggered") + loadTimetable() + } + .onChange(of: scenePhase) { _, newPhase in + + if newPhase == .active { + viewModel.resetSyncStatus() + } + } + } + + private func loadTimetable() { + Task { + await viewModel.loadTimeTable( + existingTimeTable: timetableItem.first, + username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), + authToken: authViewModel.loggedInBackendUser?.token ?? "", + context: context + ) } } } diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index d6340ce..a362897 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -1,22 +1,24 @@ import SwiftUI + + + struct UserProfileSidebar: View { @Environment(AuthViewModel.self) private var authViewModel @Binding var isPresented: Bool @State private var ghostMode: Bool = false - + @State private var isUpdatingGhostMode: Bool = false + var body: some View { ZStack(alignment: .topTrailing) { Button { - withAnimation(.easeInOut(duration: 0.8)) { - isPresented = false - } + isPresented = false } label: { Image(systemName: "xmark") .foregroundColor(.white) .padding() } - + VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 8) { UserImage( @@ -24,56 +26,68 @@ struct UserProfileSidebar: View { height: 60, width: 60 ) - Text(authViewModel.loggedInBackendUser?.name ?? "User") .font(Font.custom("Poppins-Bold", size: 18)) .foregroundColor(.white) - Text("@\(authViewModel.loggedInBackendUser?.username ?? "")") .font(Font.custom("Poppins-Regular", size: 14)) .foregroundColor(.white.opacity(0.8)) } .padding(.top, 40) - + Divider().background(Color.clear) - + NavigationLink { EmptyClassRoom() } label: { MenuOption(icon: "emptyclassroom", title: "Find Empty Classroom") } - + NavigationLink { SettingsView() } label: { MenuOption(icon: "settings", title: "Settings") } - + Divider().background(Color.clear) - - MenuOption(icon: "share", title: "Share") - MenuOption(icon: "support", title: "Support") - MenuOption(icon: "about", title: "About") - + +// MenuOption(icon: "share", title: "Share") + MenuOption(icon: "support", title: "Support").onTapGesture { + let supportUrl = URL(string: "https://github.com/GDGVIT/vitty-ios/issues/new?template=bug_report.md") + UIApplication.shared.open(supportUrl!) + } +// MenuOption(icon: "about", title: "About") + Divider().background(Color.clear) - + VStack(alignment: .leading, spacing: 4) { Text("Ghost Mode") .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) - Text("(your timetable will be visible only to you)") .font(Font.custom("Poppins-Regular", size: 12)) .foregroundColor(.white.opacity(0.7)) - - Toggle("", isOn: $ghostMode) - .labelsHidden() - .toggleStyle(SwitchToggleStyle(tint: Color("Accent"))) - .padding(.top, 4) + + HStack { + Toggle("", isOn: $ghostMode) + .labelsHidden() + .toggleStyle(SwitchToggleStyle(tint: Color("Accent"))) + .disabled(isUpdatingGhostMode) + .padding(.top, 4) + .onChange(of: ghostMode) { oldValue, newValue in + updateGhostMode(enabled: newValue) + } + + if isUpdatingGhostMode { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(.white) + } + } } - + Spacer() - + Button { authViewModel.signOut() } label: { @@ -91,24 +105,84 @@ struct UserProfileSidebar: View { .frame(width: UIScreen.main.bounds.width * 0.75, alignment: .leading) .frame(maxHeight: .infinity) .background(Color("Background")) + .transition(.move(edge: .trailing)) } + .animation(.easeInOut(duration: 0.3), value: isPresented) + .onAppear { + loadGhostModeState() + } + } + + // MARK: - Ghost Mode Functions + + private func loadGhostModeState() { + + let username = authViewModel.loggedInBackendUser?.username ?? "" + ghostMode = UserDefaults.standard.bool(forKey: "ghostMode_\(username)") + } + + private func updateGhostMode(enabled: Bool) { + guard let username = authViewModel.loggedInBackendUser?.username, + let token = authViewModel.loggedInBackendUser?.token else { + return + } + + isUpdatingGhostMode = true + + + let endpoint = enabled ? "ghost" : "alive" + let urlString = "\(APIConstants.base_url)friends/\(endpoint)/\(username)" + + guard let url = URL(string: urlString) else { + isUpdatingGhostMode = false + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isUpdatingGhostMode = false + + if let error = error { + print("Ghost mode update failed: \(error.localizedDescription)") + + ghostMode = !enabled + return + } + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + + UserDefaults.standard.set(enabled, forKey: "ghostMode_\(username)") + print("Ghost mode \(enabled ? "enabled" : "disabled") successfully") + } else { + print("Ghost mode update failed with status code: \(httpResponse.statusCode)") + + ghostMode = !enabled + } + } + } + }.resume() } } struct MenuOption: View { let icon: String let title: String - + + var body: some View { HStack(spacing: 16) { Image(icon) .foregroundColor(.white) .frame(width: 24) - Text(title) .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) } } } - diff --git a/VITTY/VITTY/Username/Views/UsernameView.swift b/VITTY/VITTY/Username/Views/UsernameView.swift index 090130a..4aa7ef9 100644 --- a/VITTY/VITTY/Username/Views/UsernameView.swift +++ b/VITTY/VITTY/Username/Views/UsernameView.swift @@ -18,12 +18,15 @@ struct UsernameView: View { @State private var isLoading = false @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ZStack { BackgroundView() VStack(alignment: .leading) { + headerView + Text("Enter username and your registration number below.") .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) @@ -63,7 +66,7 @@ struct UsernameView: View { TextField("Username", text: $username) .padding() } - .background(Color("tfBlue")) + .cornerRadius(18) .padding(.top) Text(userNameErrorString) @@ -73,7 +76,7 @@ struct UsernameView: View { TextField("Registration No.", text: $regNo) .padding() } - .background(Color("tfBlue")) + .cornerRadius(18) .padding(.top) Text(regNoErrorString) @@ -108,17 +111,43 @@ struct UsernameView: View { } } - .background(Color("brightBlue")) + .cornerRadius(18) } .padding(.horizontal) } - .navigationTitle("Let's Sign You In") + .navigationBarBackButtonHidden(true) } .accentColor(.white) } - + private var headerView: some View { + VStack{ + HStack { + Button(action: { + dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.title2) + } + Spacer() + + + } + + .padding(.top) + + HStack{ + Text("Let's Sign you in ") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + Spacer() + }.padding([.top,.bottom]) + } + } + func checkUserExists(completion: @escaping (Result<Bool, Error>) -> Void) { guard let url = URL(string: "\(Constants.url)auth/check-username") else { completion(.failure(AuthAPIServiceError.invalidUrl)) @@ -160,7 +189,3 @@ struct UsernameView: View { } } -#Preview { - UsernameView() - .preferredColorScheme(.dark) -} diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 8b1202d..8236c5a 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -9,4 +9,10 @@ import Foundation struct APIConstants { static let base_url = "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + static let createCircle = "circles/create/" + static let sendRequest = "circles/sendRequest/" + static let acceptRequest = "circles/acceptRequest/" + static let declineRequest = "circles/declineRequest/" + static let circleRequests = "circles/requests/received" + static let friends = "friends" } diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index ec5785d..af10f0d 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -38,6 +38,12 @@ import SwiftData - use // MARK: <title> when u create a function, it helps to navigate. */ + + + +/// Empty classrooms testing +/// empty sheet in reaminder view +/// @main struct VITTYApp: App { @@ -60,7 +66,7 @@ struct VITTYApp: App { }.modelContainer(sharedModelContainer) } var sharedModelContainer: ModelContainer { - let schema = Schema([TimeTable.self,Remainder.self,CreateNoteModel.self]) + let schema = Schema([TimeTable.self,Remainder.self,CreateNoteModel.self,UploadedFile.self]) let config = ModelConfiguration( "group.com.gdscvit.vittyioswidget" From a4a8584bf12cf19384d334e5bc8d590839d0cb7a Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Sat, 5 Jul 2025 12:10:37 +0545 Subject: [PATCH 14/16] api constants --- VITTY/ContentView.swift | 57 ++- VITTY/VITTY.xcodeproj/project.pbxproj | 24 +- .../VIewModel/AcademicsViewModel.swift | 35 -- VITTY/VITTY/Academics/View/CourseRefs.swift | 149 ++++-- VITTY/VITTY/Academics/View/Courses.swift | 89 ++-- .../VITTY/Academics/View/CreateReminder.swift | 232 +++++---- .../Academics/View/ExistingHotelView.swift | 4 - VITTY/VITTY/Academics/View/Notes.swift | 1 - VITTY/VITTY/Academics/View/NotesHelper.swift | 16 +- .../VITTY/Academics/View/RemindersData.swift | 245 ++++++---- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 110 ++++- VITTY/VITTY/Auth/Views/LoginView.swift | 44 +- .../AddFriends/View/AddFriendsView.swift | 165 ++++--- .../View/Components/AddFriendsHeader.swift | 3 - .../View/Components/FreindRequestCard.swift | 149 ++++++ VITTY/VITTY/Connect/Models/CircleModel.swift | 43 +- .../Connect/Models/FreindRequestModel.swift | 37 ++ .../Search/Views/AddFriendCardSearch.swift | 192 +++++--- .../Connect/Search/Views/SearchView.swift | 457 +++++++++++++----- .../Views/Components/AddFriendCard.swift | 189 +++++--- .../View/Circles/Components/CirclesRow.swift | 32 +- .../View/Circles/Components/CreateGroup.swift | 386 ++++++++------- .../Components/InsideCircleCards.swift | 4 +- .../View/Circles/Components/JoinGroup.swift | 45 +- .../View/Circles/View/CircleRequests.swift | 4 +- .../View/Circles/View/InsideCircle.swift | 389 +++++++++++++-- VITTY/VITTY/Connect/View/ConnectPage.swift | 35 +- .../Connect/View/Freinds/View/Freinds.swift | 1 + .../View/Freinds/View/FriendCard.swift | 6 - .../ViewModel/CommunityPageViewModel.swift | 159 +++++- .../ViewModel/FreindRequestViewModel.swift | 200 ++++++++ .../Service/EmptyClassAPIService.swift | 28 +- .../EmptyClassroom/View/EmptyClass.swift | 182 ++++++- VITTY/VITTY/Home/View/HomeView.swift | 186 ++++--- VITTY/VITTY/Home/View/ToolTip.swift | 237 +++++++++ .../ViewModel/SettingsViewModel.swift | 1 + VITTY/VITTY/Shared/Constants.swift | 9 +- VITTY/VITTY/TimeTable/Models/TimeTable.swift | 1 + .../ViewModel/TimeTableViewModel.swift | 59 ++- .../TimeTable/Views/LectureDetailView.swift | 1 + .../TimeTable/Views/LectureItemView.swift | 220 ++++++++- .../VITTY/TimeTable/Views/TimeTableView.swift | 9 +- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 12 + .../Utilities/Constants/APIConstants.swift | 4 +- VITTY/VITTYApp.swift | 143 ++++-- .../classellipse.imageset/Contents.json | 21 + .../classellipse.imageset/classellipse.png | Bin 0 -> 375 bytes .../currentellipse.imageset/Contents.json | 21 + .../currentellipse.png | Bin 0 -> 244 bytes .../fourclassesline.imageset/Contents.json | 21 + .../allclassesline.png | Bin 0 -> 588 bytes .../ScheduleEntryControlView.swift | 4 +- .../Control/VittyWidgetControl.swift | 4 +- .../Providers/ScheduleProvider.swift | 241 ++++++--- VITTY/VittyWidget/Views/LargeWidget.swift | 182 +++++++ 55 files changed, 3931 insertions(+), 1157 deletions(-) create mode 100644 VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift create mode 100644 VITTY/VITTY/Connect/Models/FreindRequestModel.swift create mode 100644 VITTY/VITTY/Connect/ViewModel/FreindRequestViewModel.swift create mode 100644 VITTY/VITTY/Home/View/ToolTip.swift create mode 100644 VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/Contents.json create mode 100644 VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/classellipse.png create mode 100644 VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/Contents.json create mode 100644 VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/currentellipse.png create mode 100644 VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/Contents.json create mode 100644 VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/allclassesline.png diff --git a/VITTY/ContentView.swift b/VITTY/ContentView.swift index eb09b89..edec9e3 100644 --- a/VITTY/ContentView.swift +++ b/VITTY/ContentView.swift @@ -5,40 +5,45 @@ // Created by Ananya George on 11/7/21. // + + import SwiftUI struct ContentView: View { - - @State private var communityPageViewModel = CommunityPageViewModel() - @State private var suggestedFriendsViewModel = SuggestedFriendsViewModel() - @State private var friendRequestViewModel = FriendRequestViewModel() - @State private var authViewModel = AuthViewModel() + @State private var communityPageViewModel = CommunityPageViewModel() + @State private var suggestedFriendsViewModel = SuggestedFriendsViewModel() + @State private var friendRequestViewModel = FriendRequestViewModel() + @State private var authViewModel = AuthViewModel() + @State private var requestViewModel = RequestsViewModel() + @State private var academicsViewModel = AcademicsViewModel() - var body: some View { - Group { - if authViewModel.loggedInFirebaseUser != nil { - if authViewModel.loggedInBackendUser == nil { - InstructionView() - } - else { - HomeView() - } - } - else { - LoginView() - } - - } - .environment(authViewModel) - .environment(communityPageViewModel) - .environment(suggestedFriendsViewModel) - .environment(friendRequestViewModel) + var body: some View { + Group { + // Check if backend user exists first + if authViewModel.loggedInBackendUser != nil { + HomeView() + } + // If no backend user but Firebase user exists, show instruction + else if authViewModel.loggedInFirebaseUser != nil { + InstructionView() + } + // If neither exists, show login + else { + LoginView() + } + } + .environment(authViewModel) + .environment(communityPageViewModel) + .environment(suggestedFriendsViewModel) + .environment(friendRequestViewModel) .environment(academicsViewModel) + .environment(requestViewModel) + - } + } } #Preview { - ContentView() + ContentView() } diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 38ff062..bd35b22 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -27,7 +27,11 @@ 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE72D7C78B300C9D801 /* Courses.swift */; }; 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; + 4B1BDBCC2E1396B1008C2DE9 /* ToolTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */; }; 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */; }; + 4B341C0E2E1802910073906B /* FreindRequestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */; }; + 4B341C102E1803070073906B /* FreindRequestViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */; }; + 4B341C122E1803260073906B /* FreindRequestCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B341C112E18031E0073906B /* FreindRequestCard.swift */; }; 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */; }; 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */; }; 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; @@ -185,7 +189,11 @@ 4B183EE72D7C78B300C9D801 /* Courses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Courses.swift; sourceTree = "<group>"; }; 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = "<group>"; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = "<group>"; }; + 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolTip.swift; sourceTree = "<group>"; }; 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = "<group>"; }; + 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestModel.swift; sourceTree = "<group>"; }; + 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestViewModel.swift; sourceTree = "<group>"; }; + 4B341C112E18031E0073906B /* FreindRequestCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestCard.swift; sourceTree = "<group>"; }; 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderNotifcationManager.swift; sourceTree = "<group>"; }; 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingHotelView.swift; sourceTree = "<group>"; }; 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; }; @@ -497,18 +505,10 @@ isa = PBXGroup; children = ( 4B7DA5ED2D71E100007354A3 /* View */, - 4B7DA5EA2D71E0E2007354A3 /* Components */, ); path = Freinds; sourceTree = "<group>"; }; - 4B7DA5EA2D71E0E2007354A3 /* Components */ = { - isa = PBXGroup; - children = ( - ); - path = Components; - sourceTree = "<group>"; - }; 4B7DA5EB2D71E0F4007354A3 /* Components */ = { isa = PBXGroup; children = ( @@ -672,6 +672,7 @@ 522B8BAB2B47296900EE686E /* ViewModel */ = { isa = PBXGroup; children = ( + 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */, 522B8BAC2B47297A00EE686E /* CommunityPageViewModel.swift */, ); path = ViewModel; @@ -680,6 +681,7 @@ 522B8BAE2B4732C200EE686E /* Models */ = { isa = PBXGroup; children = ( + 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */, 4BF0C77C2D932B8A00016202 /* CircleModel.swift */, 522B8BAF2B4732CC00EE686E /* Friend.swift */, ); @@ -740,6 +742,7 @@ 524B842D2B46EBAE006D18BD /* View */ = { isa = PBXGroup; children = ( + 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */, 524B842E2B46EBBD006D18BD /* HomeView.swift */, ); path = View; @@ -798,6 +801,7 @@ 524B843D2B46F705006D18BD /* Components */ = { isa = PBXGroup; children = ( + 4B341C112E18031E0073906B /* FreindRequestCard.swift */, 524B843B2B46F6FD006D18BD /* AddFriendsHeader.swift */, ); path = Components; @@ -1117,6 +1121,7 @@ 528D32232C18C679007C9106 /* BackgroundView.swift in Sources */, 520BA6452B48013200124850 /* SuggestedFriendsView.swift in Sources */, 4B7DA5DF2D7094E8007354A3 /* Academics.swift in Sources */, + 4B341C0E2E1802910073906B /* FreindRequestModel.swift in Sources */, 4B7DA5F22D7228F9007354A3 /* JoinGroup.swift in Sources */, 524B842F2B46EBBD006D18BD /* HomeView.swift in Sources */, 527E3E082B7662920086F23D /* TimeTableView.swift in Sources */, @@ -1131,16 +1136,19 @@ 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */, 5DC0AF552AD2B586006B081D /* UserImage.swift in Sources */, 5238C7F42B4AB07400413946 /* FriendReqCard.swift in Sources */, + 4B1BDBCC2E1396B1008C2DE9 /* ToolTip.swift in Sources */, 4B7DA5DC2D708BD3007354A3 /* LectureItemView.swift in Sources */, 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */, 4BC853C32DF693780092B2E2 /* SaveTimeTableView.swift in Sources */, 52D5AB892B6FE3B200B2E66D /* AppUser.swift in Sources */, 31128D0C277300470084C9EA /* StringConstants.swift in Sources */, + 4B341C102E1803070073906B /* FreindRequestViewModel.swift in Sources */, 522B8BB02B4732CC00EE686E /* Friend.swift in Sources */, 52D5AB8C2B6FE4D600B2E66D /* UserDefaultKeys.swift in Sources */, 5D7F04F72AAB9E9900ECED15 /* APIConstants.swift in Sources */, 4B74D8742E0BDF2100B390E9 /* CourseFile.swift in Sources */, 4BF03C9B2D7838C80098C803 /* NotesHelper.swift in Sources */, + 4B341C122E1803260073906B /* FreindRequestCard.swift in Sources */, 3109639F27824F6F0009A29C /* AppStorageConstants.swift in Sources */, 4BD63D742D70547E00EEF5D7 /* EmptyClass.swift in Sources */, 4BF0C79D2D94681000016202 /* InsideCircle.swift in Sources */, diff --git a/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift b/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift index 7ee65af..6438574 100644 --- a/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift +++ b/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift @@ -21,40 +21,5 @@ import Alamofire subsystem: Bundle.main.bundleIdentifier!, category: String(describing: AcademicsViewModel.self) ) - -// func createNote(at url: URL, authToken: String, note: CreateNoteModel) { -// self.loading = true -// -// let headers: HTTPHeaders = [ -// "Authorization": "Bearer \(authToken)", -// "Content-Type": "application/json" -// ] -// -// do { -// let jsonData = try JSONEncoder().encode(note) -// -// AF.request(url, method: .post, parameters: nil, encoding: JSONEncoding.default, headers: headers) -// .responseData { response in -// switch response.result { -// case .success: -// DispatchQueue.main.async { -// self.notes.append(note) -// self.loading = false -// } -// case .failure(let error): -// self.logger.error("Error creating note: \(error.localizedDescription)") -// self.error = true -// self.loading = false -// } -// } -// } catch { -// self.logger.error("Error encoding JSON: \(error)") -// self.error = true -// self.loading = false -// } -// } - -    - } diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index dc50d30..615a5eb 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -32,6 +32,7 @@ struct OCourseRefs: View { @State private var showFileUpload = false @State private var showFileGallery = false @State private var selectedContentType: ContentType = .notes + @State private var showExpandedFAB = false @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -259,25 +260,91 @@ struct OCourseRefs: View { } } + VStack { Spacer() HStack { Spacer() - Button(action: { - showBottomSheet.toggle() - }) { - Image(systemName: "plus") - .font(.title) - .padding(18) - .background(Color("Secondary")) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + + // Expandable FAB + VStack(spacing: 16) { + // Action buttons (shown when expanded) + if showExpandedFAB { + VStack(spacing: 12) { + // Set Reminder Button + ExpandableFABButton( + icon: "bell.fill", + title: "Set Reminder", + color: Color.orange + ) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showExpandedFAB = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showReminderSheet = true + } + } + + // Upload File Button + ExpandableFABButton( + icon: "doc.fill", + title: "Upload File", + color: Color.blue + ) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showExpandedFAB = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showFileUpload = true + } + } + + // Write Note Button + ExpandableFABButton( + icon: "pencil", + title: "Write Note", + color: Color.green + ) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showExpandedFAB = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + navigateToNotesEditor = true + } + } + } + .transition(.asymmetric( + insertion: .scale(scale: 0.8).combined(with: .opacity).combined(with: .move(edge: .bottom)), + removal: .scale(scale: 0.8).combined(with: .opacity).combined(with: .move(edge: .bottom)) + )) + } + + // Main FAB Button + Button(action: { + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showExpandedFAB.toggle() + } + }) { + Image(systemName: showExpandedFAB ? "xmark" : "plus") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(width: 56, height: 56) + .background(Color("Secondary")) + .clipShape(Circle()) + .rotationEffect(.degrees(showExpandedFAB ? 45 : 0)) + .scaleEffect(showExpandedFAB ? 1.1 : 1.0) + .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 5) + } } .padding(.trailing, 20) .padding(.bottom, 30) } } - + if showDeleteAlert { DeleteNoteAlert( noteName: noteToDelete?.noteName ?? "", @@ -310,35 +377,15 @@ struct OCourseRefs: View { .onAppear { print("this is course code") print(courseCode) - } - .navigationBarHidden(true) - .edgesIgnoringSafeArea(.bottom) - .sheet(isPresented: $showBottomSheet) { - ZStack { - Color("Secondary").edgesIgnoringSafeArea(.all) - - HStack { - BottomSheetButton(icon: "upload", title: "Write Note") { - showBottomSheet = false - navigateToNotesEditor = true - } - - BottomSheetButton(icon: "edit_document", title: "Upload File") { - showBottomSheet = false - showFileUpload = true - } - - BottomSheetButton(icon: "alarm", title: "Set Reminder") { - showBottomSheet = false - showReminderSheet = true - } + }.onTapGesture { + if showExpandedFAB { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showExpandedFAB = false } - .frame(maxWidth: .infinity) - .padding(.top, 20) } - .presentationDetents([.height(200)]) - .presentationDragIndicator(.visible) } + .navigationBarHidden(true) + .edgesIgnoringSafeArea(.bottom) .sheet(isPresented: $showReminderSheet) { ReminderView(courseName: courseName, slot: slot, courseCode: courseCode) .presentationDetents([.fraction(0.8)]) @@ -398,6 +445,36 @@ struct OCourseRefs: View { } } } + struct ExpandableFABButton: View { + let icon: String + let title: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(width: 44, height: 44) + .background(.white) + .clipShape(Circle()) + .shadow(color: color.opacity(0.3), radius: 8, x: 0, y: 4) + + Text(title) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.black.opacity(0.8)) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + } + } + .buttonStyle(PlainButtonStyle()) + } + } @MainActor private func loadNoteContent(_ note: CreateNoteModel) async throws -> NSAttributedString { diff --git a/VITTY/VITTY/Academics/View/Courses.swift b/VITTY/VITTY/Academics/View/Courses.swift index c5f291a..b21751e 100644 --- a/VITTY/VITTY/Academics/View/Courses.swift +++ b/VITTY/VITTY/Academics/View/Courses.swift @@ -1,3 +1,4 @@ + import SwiftUI import SwiftData @@ -6,32 +7,39 @@ struct CoursesView: View { @State private var searchText = "" @State private var isCurrentSemester = true @Environment(\.modelContext) private var modelContext + @State private var navigateToNotesEditor = false + @State private var selectedSubject : Course = Course(title: "", slot: "", code: "", semester: "", isFavorite: false) var body: some View { let courses = timeTables.first.map { extractCourses(from: $0) } ?? [] let filtered = filteredCourses(from: courses) - ScrollView { + VStack { VStack(spacing: 0) { SearchBar(searchText: $searchText) - - - VStack(spacing: 16) { - ForEach(filtered) { course in - NavigationLink(destination: OCourseRefs(courseName: course.title, courseInstitution: course.code,slot:course.slot,courseCode: course.code)) { - CourseCardView(course: course) + ScrollView{ + VStack(spacing: 16) { + ForEach(filtered) { course in + NavigationLink(destination: OCourseRefs(courseName: course.title, courseInstitution: course.code,slot:course.slot,courseCode: course.code)) { + CourseCardView(course: course,isNotesClicked: $navigateToNotesEditor,selectedCourse: $selectedSubject) + } } } - } - .padding(.horizontal) - .padding(.top, 16) - .padding(.bottom, 24) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 24) + } .scrollIndicators(.hidden) } } - .scrollIndicators(.hidden) + .background(Color("Background").edgesIgnoringSafeArea(.all)) + + .navigationDestination(isPresented: $navigateToNotesEditor) { + NoteEditorView(courseCode: selectedSubject.code , courseName:selectedSubject.title, courseIns:selectedSubject.code, courseSlot: selectedSubject.slot) + } } + private func filteredCourses(from allCourses: [Course]) -> [Course] { allCourses.filter { course in let matchesSearch = searchText.isEmpty || course.title.lowercased().contains(searchText.lowercased()) @@ -50,12 +58,10 @@ struct CoursesView: View { let currentSemester = determineSemester(for: Date()) - let groupedLectures = Dictionary(grouping: allLectures, by: { $0.name }) var result: [Course] = [] - for title in groupedLectures.keys.sorted() { if let lectures = groupedLectures[title] { let uniqueSlot = Set(lectures.map { $0.slot }).sorted().joined(separator: " + ") @@ -73,12 +79,9 @@ struct CoursesView: View { } } - return result.sorted { $0.title < $1.title } } - - private func determineSemester(for date: Date) -> String { let month = Calendar.current.component(.month, from: date) @@ -103,7 +106,6 @@ struct CoursesView: View { return "\(year)-\(String(format: "%02d", (year + 1) % 100))" } } - } struct SemesterFilterButton: View { @@ -134,31 +136,46 @@ struct SemesterFilterButton: View { struct CourseCardView: View { let course: Course + @Binding var isNotesClicked : Bool + @Binding var selectedCourse : Course var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(course.title) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - - Spacer() + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(course.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + + Spacer() + } + .padding(.top, 16) + .padding(.horizontal, 16) - if course.isFavorite { - Image(systemName: "star.fill") - .foregroundColor(Color.yellow) + HStack { + Text(course.code + " | " + course.semester) + .font(.system(size: 14)) + .foregroundColor(Color("Accent")) + .multilineTextAlignment(.leading) + + Spacer() } - } - .padding(.top, 16) - .padding(.horizontal, 16) - - Text(course.code + " | " + course.semester) - .font(.system(size: 14)) - .foregroundColor(Color("Accent")) .padding(.horizontal, 16) .padding(.bottom, 16) + } + Button { + isNotesClicked = true + selectedCourse = course + } label: { + Image(systemName: "pencil.and.list.clipboard") + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(Color("Accent")) + .padding(.trailing, 20) + } } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 16).fill(Color("Secondary"))) } } diff --git a/VITTY/VITTY/Academics/View/CreateReminder.swift b/VITTY/VITTY/Academics/View/CreateReminder.swift index a42607d..069b142 100644 --- a/VITTY/VITTY/Academics/View/CreateReminder.swift +++ b/VITTY/VITTY/Academics/View/CreateReminder.swift @@ -1,3 +1,9 @@ +// +// CreateGroup.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + import SwiftUI import SwiftData @@ -27,7 +33,7 @@ struct ReminderView: View { Color("Background").edgesIgnoringSafeArea(.all) VStack(spacing: 0) { - // Top bar + HStack { Button("Cancel") { presentationMode.wrappedValue.dismiss() @@ -54,7 +60,7 @@ struct ReminderView: View { try modelContext.save() print("Saved successfully") - // Schedule local notifications + NotificationManager.shared.scheduleReminderNotifications( title: title, date: startTime, @@ -67,7 +73,6 @@ struct ReminderView: View { presentationMode.wrappedValue.dismiss() } - .disabled(!isFormValid) .foregroundColor(isFormValid ? .red : .gray) } @@ -102,116 +107,144 @@ struct ReminderView: View { .cornerRadius(20) } - // Alert Date Picker - HStack { - Text("Alert Date") - .foregroundColor(.white) - Spacer() - Text(selectedDate, style: .date) - .foregroundColor(.gray) - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - .padding() - .background(Color("Secondary")) - .cornerRadius(10) - .onTapGesture { - withAnimation { - showDatePicker.toggle() + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Alert Date") + .foregroundColor(.white) + Spacer() + Text(selectedDate, style: .date) + .foregroundColor(.gray) + Image(systemName: showDatePicker ? "chevron.down" : "chevron.right") + .foregroundColor(.gray) + .rotationEffect(.degrees(showDatePicker ? 0 : 0)) + } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + + showStartTimePicker = false + showEndTimePicker = false + showDatePicker.toggle() + } } - } - - if showDatePicker { - DatePicker( - "Select Date", - selection: $selectedDate, - displayedComponents: [.date] - ) - .datePickerStyle(.graphical) - .colorScheme(.dark) - .labelsHidden() - Button("Done") { - withAnimation { - showDatePicker = false + if showDatePicker { + DatePicker( + "Select Date", + selection: $selectedDate, + displayedComponents: [.date] + ) + .datePickerStyle(.graphical) + .colorScheme(.dark) + .labelsHidden() + .onChange(of: selectedDate) { + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.easeInOut(duration: 0.3)) { + showDatePicker = false + } + } } + .transition(.opacity.combined(with: .scale)) } - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.top, 5) } - // Start Time - HStack { - Text("Start Time") - .foregroundColor(.white) - Spacer() - Text(startTime, style: .time) - .foregroundColor(.gray) - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - .padding() - .background(Color("Secondary")) - .cornerRadius(10) - .onTapGesture { - withAnimation { - showStartTimePicker.toggle() + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Start Time") + .foregroundColor(.white) + Spacer() + Text(startTime, style: .time) + .foregroundColor(.gray) + Image(systemName: showStartTimePicker ? "chevron.down" : "chevron.right") + .foregroundColor(.gray) + } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + + showDatePicker = false + showEndTimePicker = false + showStartTimePicker.toggle() + } } - } - if showStartTimePicker { - DatePicker( - "Start Time", - selection: $startTime, - displayedComponents: [.hourAndMinute] - ) - .datePickerStyle(.wheel) - .labelsHidden() - .colorScheme(.dark) + if showStartTimePicker { + VStack(spacing: 12) { + DatePicker( + "Start Time", + selection: $startTime, + displayedComponents: [.hourAndMinute] + ) + .datePickerStyle(.wheel) + .labelsHidden() + .colorScheme(.dark) + .frame(height: 120) + .clipped() - Button("Done") { - withAnimation { - showStartTimePicker = false + Button("Done") { + withAnimation(.easeInOut(duration: 0.3)) { + showStartTimePicker = false + } + } + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .trailing) } + .transition(.opacity.combined(with: .scale)) } - .frame(maxWidth: .infinity, alignment: .trailing) } - // End Time - HStack { - Text("End Time") - .foregroundColor(.white) - Spacer() - Text(endTime, style: .time) - .foregroundColor(.gray) - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - .padding() - .background(Color("Secondary")) - .cornerRadius(10) - .onTapGesture { - withAnimation { - showEndTimePicker.toggle() + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("End Time") + .foregroundColor(.white) + Spacer() + Text(endTime, style: .time) + .foregroundColor(.gray) + Image(systemName: showEndTimePicker ? "chevron.down" : "chevron.right") + .foregroundColor(.gray) + } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + + showDatePicker = false + showStartTimePicker = false + showEndTimePicker.toggle() + } } - } - if showEndTimePicker { - DatePicker( - "End Time", - selection: $endTime, - displayedComponents: [.hourAndMinute] - ) - .datePickerStyle(.wheel) - .labelsHidden() - .colorScheme(.dark) + if showEndTimePicker { + VStack(spacing: 12) { + DatePicker( + "End Time", + selection: $endTime, + displayedComponents: [.hourAndMinute] + ) + .datePickerStyle(.wheel) + .labelsHidden() + .colorScheme(.dark) + .frame(height: 120) + .clipped() - Button("Done") { - withAnimation { - showEndTimePicker = false + Button("Done") { + withAnimation(.easeInOut(duration: 0.3)) { + showEndTimePicker = false + } + } + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .trailing) } + .transition(.opacity.combined(with: .scale)) } - .frame(maxWidth: .infinity, alignment: .trailing) } } .padding() @@ -219,6 +252,13 @@ struct ReminderView: View { } } .preferredColorScheme(.dark) + .onTapGesture { + + withAnimation(.easeInOut(duration: 0.3)) { + showDatePicker = false + showStartTimePicker = false + showEndTimePicker = false + } + } } } - diff --git a/VITTY/VITTY/Academics/View/ExistingHotelView.swift b/VITTY/VITTY/Academics/View/ExistingHotelView.swift index 2682587..fc9e2ca 100644 --- a/VITTY/VITTY/Academics/View/ExistingHotelView.swift +++ b/VITTY/VITTY/Academics/View/ExistingHotelView.swift @@ -38,7 +38,3 @@ struct ExistingHotelView: View { } } } - -#Preview { - ExistingHotelView(existingNote: CreateNoteModel(noteName: "", userName: "", courseId: "", courseName: "", noteContent: "")) -} diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index bd09353..0058060 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -159,7 +159,6 @@ struct NoteEditorView: View { private func handleBackNavigation() { - // Fallback for older navigation approaches DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if presentationMode.wrappedValue.isPresented { presentationMode.wrappedValue.dismiss() diff --git a/VITTY/VITTY/Academics/View/NotesHelper.swift b/VITTY/VITTY/Academics/View/NotesHelper.swift index 58aa545..7f21d8d 100644 --- a/VITTY/VITTY/Academics/View/NotesHelper.swift +++ b/VITTY/VITTY/Academics/View/NotesHelper.swift @@ -1,6 +1,10 @@ import Foundation import UIKit + +//TODO : Will make a mark down parser in future updates + + extension NSAttributedString { // func toMarkdown() -> String { // let mutableString = NSMutableString()login @@ -177,8 +181,7 @@ extension NSAttributedString { // MARK: - Markdown to NSAttributedString Parser extension String { - /// Converts Markdown string to NSAttributedString - /// Handles bold, italic, underline, headings, colors, and bullet points + func fromMarkdown() -> NSMutableAttributedString { let result = NSMutableAttributedString() let lines = self.components(separatedBy: .newlines) @@ -309,7 +312,7 @@ extension String { var currentAttributes = attributes let result = NSMutableAttributedString() - // Find next formatting marker + let remainingText = String(text[currentIndex...]) let boldPattern = #"\*\*([^*]+)\*\*"# let italicPattern = #"\*([^*]+)\*"# @@ -337,20 +340,19 @@ extension String { currentIndex = text.index(startIndex, offsetBy: matchRange.upperBound.utf16Offset(in: remainingText)) } - // Check for italic + else if let italicRegex = try? NSRegularExpression(pattern: italicPattern), let italicMatch = italicRegex.firstMatch(in: remainingText, range: NSRange(remainingText.startIndex..<remainingText.endIndex, in: remainingText)) { let matchRange = Range(italicMatch.range, in: remainingText)! let textRange = Range(italicMatch.range(at: 1), in: remainingText)! - // Add text before match + if matchRange.lowerBound > remainingText.startIndex { let beforeText = String(remainingText[remainingText.startIndex..<matchRange.lowerBound]) result.append(NSAttributedString(string: beforeText, attributes: currentAttributes)) } - // Add italic text let italicText = String(remainingText[textRange]) var italicAttributes = currentAttributes if let font = italicAttributes[.font] as? UIFont { @@ -360,7 +362,7 @@ extension String { currentIndex = text.index(startIndex, offsetBy: matchRange.upperBound.utf16Offset(in: remainingText)) } - // No formatting found, add single character + else { let char = String(text[currentIndex]) result.append(NSAttributedString(string: char, attributes: currentAttributes)) diff --git a/VITTY/VITTY/Academics/View/RemindersData.swift b/VITTY/VITTY/Academics/View/RemindersData.swift index 7ff7ae7..08722b0 100644 --- a/VITTY/VITTY/Academics/View/RemindersData.swift +++ b/VITTY/VITTY/Academics/View/RemindersData.swift @@ -4,11 +4,9 @@ // // Created by Rujin Devkota on 2/27/25. // - import SwiftUI import SwiftData - struct RemindersView: View { @Environment(\.modelContext) private var modelContext @Query private var allReminders: [Remainder] @@ -19,8 +17,14 @@ struct RemindersView: View { @State private var showingSubjectSelection = false @State private var showingReminderCreation = false @State private var selectedCourse: Course? + @State private var availableCourses: [Course] = [] + @State private var isLoadingCourses = false - // Your existing computed properties remain the same + + private var firstTimeTable: TimeTable? { + timeTables.first + } + private var filteredReminders: [Remainder] { if searchText.isEmpty { return allReminders @@ -61,12 +65,6 @@ struct RemindersView: View { }.sorted { $0.daysToGo < $1.daysToGo } } - // Extract courses from timetable - private var availableCourses: [Course] { - let courses = timeTables.first.map { extractCourses(from: $0) } ?? [] - return courses - } - var body: some View { ScrollView { VStack(spacing: 0) { @@ -91,7 +89,6 @@ struct RemindersView: View { .padding(.horizontal) .padding(.top, 16) - HStack(spacing: 16) { StatusTabView(isSelected: selectedTab == 0, title: "Pending") .onTapGesture { selectedTab = 0 } @@ -99,22 +96,20 @@ struct RemindersView: View { .onTapGesture { selectedTab = 1 } Spacer() - Button { + // Load courses immediately when button is pressed + loadCoursesIfNeeded() showingSubjectSelection = true - } label: { + } label: { Image(systemName: "plus") .foregroundColor(.blue) .font(.system(size: 16, weight: .medium)) .frame(width: 32, height: 32) - - } } .padding(.horizontal) .padding(.top, 16) - VStack(spacing: 24) { ForEach(groupedReminders, id: \.id) { group in if selectedTab == 0 && !group.items.filter({ !$0.isCompleted }).isEmpty { @@ -146,7 +141,7 @@ struct RemindersView: View { .padding(.top, 16) // Empty state - if groupedReminders.isEmpty { + if groupedReminders.isEmpty && !isLoadingCourses { VStack(spacing: 16) { Image(systemName: "calendar.badge.exclamationmark") .font(.system(size: 48)) @@ -168,13 +163,21 @@ struct RemindersView: View { } .scrollIndicators(.hidden) .background(Color("Background").edgesIgnoringSafeArea(.all)) + .onAppear { + // Load courses immediately on appear + loadCoursesIfNeeded() + } .sheet(isPresented: $showingSubjectSelection) { SubjectSelectionView( courses: availableCourses, + isLoading: isLoadingCourses, onCourseSelected: { course in selectedCourse = course showingSubjectSelection = false - showingReminderCreation = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showingReminderCreation = true + } } ) } @@ -185,6 +188,25 @@ struct RemindersView: View { slot: course.slot, courseCode: course.code ) + } else { + // Fallback view in case selectedCourse is nil + VStack { + Text("Error: No course selected") + .foregroundColor(.red) + Button("Close") { + showingReminderCreation = false + } + } + .padding() + .background(Color("Background")) + } + } + .onChange(of: showingReminderCreation) { isPresented in + + if !isPresented { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + selectedCourse = nil + } } } } @@ -198,31 +220,61 @@ struct RemindersView: View { } } + + private func loadCoursesIfNeeded() { + + guard availableCourses.isEmpty else { return } + + guard let firstTimeTable = firstTimeTable else { + + self.availableCourses = [] + self.isLoadingCourses = false + return + } + + isLoadingCourses = true + + // Use async dispatch to avoid blocking UI + DispatchQueue.global(qos: .userInitiated).async { + let courses = extractCourses(from: firstTimeTable) + + DispatchQueue.main.async { + self.availableCourses = courses + self.isLoadingCourses = false + } + } + } + private func extractCourses(from timetable: TimeTable) -> [Course] { let allLectures = timetable.monday + timetable.tuesday + timetable.wednesday + timetable.thursday + timetable.friday + timetable.saturday + timetable.sunday let currentSemester = determineSemester(for: Date()) - let groupedLectures = Dictionary(grouping: allLectures, by: { $0.name }) - var result: [Course] = [] + - for title in groupedLectures.keys.sorted() { - if let lectures = groupedLectures[title] { - let uniqueSlot = Set(lectures.map { $0.slot }).sorted().joined(separator: " + ") - let uniqueCode = Set(lectures.map { $0.code }).sorted().joined(separator: " / ") + var courseDict: [String: [Lecture]] = [:] + for lecture in allLectures { + courseDict[lecture.name, default: []].append(lecture) + } + + var result: [Course] = [] + result.reserveCapacity(courseDict.count) + + for (title, lectures) in courseDict { + let uniqueSlot = Set(lectures.map { $0.slot }).sorted().joined(separator: " + ") + let uniqueCode = Set(lectures.map { $0.code }).sorted().joined(separator: " / ") - result.append( - Course( - title: title, - slot: uniqueSlot, - code: uniqueCode, - semester: currentSemester, - isFavorite: false - ) + result.append( + Course( + title: title, + slot: uniqueSlot, + code: uniqueCode, + semester: currentSemester, + isFavorite: false ) - } + ) } return result.sorted { $0.title < $1.title } @@ -254,9 +306,11 @@ struct RemindersView: View { } } -// MARK: - Subject Selection View +// MARK: - Optimized Subject Selection View + struct SubjectSelectionView: View { let courses: [Course] + let isLoading: Bool let onCourseSelected: (Course) -> Void @Environment(\.presentationMode) var presentationMode @@ -278,76 +332,94 @@ struct SubjectSelectionView: View { ZStack { Color("Background").edgesIgnoringSafeArea(.all) - VStack(spacing: 0) { - - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - - TextField("Search subjects", text: $searchText) - .foregroundColor(.white) + if isLoading { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + .progressViewStyle(CircularProgressViewStyle(tint: Color("Accent"))) - if !searchText.isEmpty { - Button(action: { searchText = "" }) { - Image(systemName: "xmark") - .foregroundColor(.gray) - } - } + Text("Loading subjects...") + .font(.system(size: 16)) + .foregroundColor(.gray) } - .padding(10) - .background(Color("Secondary")) - .cornerRadius(8) - .padding(.horizontal) - .padding(.top, 16) - - ScrollView { - LazyVStack(spacing: 12) { - ForEach(filteredCourses) { course in - SubjectSelectionCard(course: course) { - onCourseSelected(course) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 0) { + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("Search subjects", text: $searchText) + .foregroundColor(.white) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark") + .foregroundColor(.gray) } } } + .padding(10) + .background(Color("Secondary")) + .cornerRadius(8) .padding(.horizontal) .padding(.top, 16) - .padding(.bottom, 24) - } - - - if filteredCourses.isEmpty { - VStack(spacing: 16) { - Image(systemName: "book.closed") - .font(.system(size: 48)) - .foregroundColor(.gray) - - Text(searchText.isEmpty ? "No subjects available" : "No subjects found") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.gray) - - if !searchText.isEmpty { - Text("Try adjusting your search terms") - .font(.system(size: 14)) - .foregroundColor(.gray.opacity(0.7)) + + if filteredCourses.isEmpty { + VStack(spacing: 16) { + Image(systemName: "book.closed") + .font(.system(size: 48)) + .foregroundColor(.gray) + + Text(searchText.isEmpty ? "No subjects available" : "No subjects found") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.gray) + + if !searchText.isEmpty { + Text("Try adjusting your search terms") + .font(.system(size: 14)) + .foregroundColor(.gray.opacity(0.7)) + } else { + Text("Please check your timetable data") + .font(.system(size: 14)) + .foregroundColor(.gray.opacity(0.7)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(filteredCourses) { course in + SubjectSelectionCard(course: course) { + onCourseSelected(course) + } + } + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 24) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) } } } .navigationTitle("Select Subject") .navigationBarTitleDisplayMode(.large) - .navigationBarItems( - leading: Button("Cancel") { - presentationMode.wrappedValue.dismiss() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .foregroundColor(.red) } - .foregroundColor(.red) - ) + } } .preferredColorScheme(.dark) } } -// MARK: - Subject Selection Card +// MARK: - Subject Selection Card (Optimized) struct SubjectSelectionCard: View { let course: Course let onTap: () -> Void @@ -394,6 +466,8 @@ struct SubjectSelectionCard: View { .buttonStyle(PlainButtonStyle()) } } + + struct StatusTabView: View { let isSelected: Bool let title: String @@ -403,11 +477,11 @@ struct StatusTabView: View { if isSelected { Image(systemName: "checkmark") .font(.system(size: 12)) - .foregroundColor(.white) + .foregroundColor(isSelected ? .black : .white) } Text(title) .font(.system(size: 14)) - .foregroundColor(.white) + .foregroundColor(isSelected ? .black : .white) } .padding(.vertical, 6) .padding(.horizontal, 12) @@ -578,7 +652,6 @@ struct ReminderItemView: View { } } - struct ReminderGroup: Identifiable { let id = UUID() let date: String diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index addd15e..7805525 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -11,12 +11,44 @@ import OSLog import GoogleSignIn import CryptoKit import FirebaseAuth +import Alamofire + enum LoginOptions { case googleSignIn case appleSignIn } +struct FirebaseAuthRequest: Codable { + let uuid: String +} +struct FirebaseAuthResponse: Codable { + let name: String + let picture: String + let role: String + let token: String + let username: String +} +struct AuthError: Codable { + let detail: String +} + +enum AuthenticationError: Error, LocalizedError { + case userNotFound(String) + case firebaseAuthFailed + case backendAuthFailed + + var errorDescription: String? { + switch self { + case .userNotFound(let detail): + return detail + case .firebaseAuthFailed: + return "Firebase authentication failed" + case .backendAuthFailed: + return "Backend authentication failed" + } + } + } @Observable class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { @@ -62,6 +94,73 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.info("Auth Initialisation Complete") } + private func authenticateWithFirebase(uuid: String,url:String) async throws -> FirebaseAuthResponse { + guard let url = URL(string: "\(url)auth/firebase") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = FirebaseAuthRequest(uuid: uuid) + request.httpBody = try JSONEncoder().encode(requestBody) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if httpResponse.statusCode == 200 { + + return try JSONDecoder().decode(FirebaseAuthResponse.self, from: data) + } else if httpResponse.statusCode == 404 { + + let authError = try JSONDecoder().decode(AuthError.self, from: data) + throw AuthenticationError.userNotFound(authError.detail) + } else { + throw URLError(.badServerResponse) + } + } + private func checkBackendUserExists(uuid: String,url:String) async { + do { + let backendUser = try await authenticateWithFirebase(uuid: uuid,url: url) + + + DispatchQueue.main.async { + self.loggedInBackendUser = AppUser( + name: backendUser.name, + picture: backendUser.picture, + role: backendUser.role, + token: backendUser.token, + username: backendUser.username + ) + + + UserDefaults.standard.set(backendUser.token, forKey: UserDefaultKeys.tokenKey) + UserDefaults.standard.set(backendUser.username, forKey: UserDefaultKeys.usernameKey) + UserDefaults.standard.set(backendUser.name, forKey: UserDefaultKeys.nameKey) + UserDefaults.standard.set(backendUser.picture, forKey: UserDefaultKeys.pictureKey) + UserDefaults.standard.set(backendUser.role, forKey: UserDefaultKeys.roleKey) + } + + logger.info("User exists in backend: \(backendUser.username)") + + } catch AuthenticationError.userNotFound(let detail) { + logger.info("User not found in backend: \(detail)") + + DispatchQueue.main.async { + self.loggedInBackendUser = nil + } + } catch { + logger.error("Error checking backend user: \(error)") + DispatchQueue.main.async { + self.loggedInBackendUser = nil + } + } + } + func signInServer(username: String, regNo: String) async { logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") @@ -76,6 +175,8 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { ) ) + + } catch { @@ -85,6 +186,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.info("Signed into server \(self.loggedInBackendUser?.name ?? "empty")") } + private func firebaseUserAuthUpdate(with auth: Auth, user: User?) { logger.info("Firebase User Auth State Updated") DispatchQueue.main.async { @@ -146,7 +248,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.debug("\(UserDefaults.standard.string(forKey: UserDefaultKeys.usernameKey)!)") } else { - self.loggedInBackendUser = nil // tbh no need for this, but just to make sure + self.loggedInBackendUser = nil } } catch { logger.error("Error in logging in: \(error)") @@ -171,6 +273,12 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { self.loggedInFirebaseUser = authDataResult.user logger.info("Signed in with Google") + + if let firebaseUser = self.loggedInFirebaseUser { + await checkBackendUserExists(uuid: firebaseUser.uid,url: APIConstants.base_url) + } + + } private func signInWithApple() { diff --git a/VITTY/VITTY/Auth/Views/LoginView.swift b/VITTY/VITTY/Auth/Views/LoginView.swift index 2e5891a..0692064 100644 --- a/VITTY/VITTY/Auth/Views/LoginView.swift +++ b/VITTY/VITTY/Auth/Views/LoginView.swift @@ -11,6 +11,7 @@ import SwiftUI struct LoginView: View { @Environment(AuthViewModel.self) private var authViewModel @State private var animationProgress = 0.0 + @State private var scrollPosition: Int? = 0 // Changed to optional Int private let carouselItems = [ LoginViewCarouselItem(image: "LoginViewIllustration 2", heading: "Never miss a class", subtitle: "Notifications to remind you about your upcoming classes"), @@ -33,6 +34,10 @@ struct LoginView: View { } .scrollIndicators(.hidden) .scrollTargetBehavior(.viewAligned) + .scrollPosition(id: $scrollPosition) // Use scrollPosition instead of currentPage + .onChange(of: scrollPosition) { _, newValue in + print("Current page changed to: \(newValue ?? 0)") + } .offset(x: -animationProgress * 75) .animation(.spring(), value: animationProgress) .onAppear { @@ -47,6 +52,10 @@ struct LoginView: View { } } } + + + PageIndicatorView(currentPage: scrollPosition ?? 0, totalPages: carouselItems.count) // Use scrollPosition + .padding(.top, 20) } .safeAreaPadding() } @@ -54,6 +63,27 @@ struct LoginView: View { } } +struct PageIndicatorView: View { + let currentPage: Int + let totalPages: Int + + var body: some View { + HStack(spacing: 8) { + ForEach(0..<totalPages, id: \.self) { index in + Circle() + .fill(index == currentPage ? Color("Accent") : Color.white) + .frame(width: 8, height: 8) + .scaleEffect(index == currentPage ? 1.2 : 1.0) + .animation(.easeInOut(duration: 0.3), value: currentPage) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.black.opacity(0.2)) + .cornerRadius(16) + } +} + struct SignInButtonsView: View { @Environment(AuthViewModel.self) private var authViewModel @@ -120,18 +150,18 @@ struct SignInButtonsView: View { } } - struct LoginViewCarouselItem { - let image: String - let heading: String - let subtitle: String + let image: String + let heading: String + let subtitle: String } extension Comparable { - func clamped(to range: Range<Self>) -> Self { - return min(max(self, range.lowerBound), range.upperBound) - } + func clamped(to range: Range<Self>) -> Self { + return min(max(self, range.lowerBound), range.upperBound) + } } + struct CarouselItemView: View { let item: LoginViewCarouselItem let index: Int diff --git a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift index 4da5572..10c4dfd 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift @@ -4,79 +4,112 @@ // // Created by Chandram Dutta on 04/01/24. // - import SwiftUI struct AddFriendsView: View { - - @Environment(AuthViewModel.self) private var authViewModel - @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel - @Environment(FriendRequestViewModel.self) private var friendRequestViewModel + @Environment(AuthViewModel.self) private var authViewModel + @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel + @Environment(RequestsViewModel.self) private var friendRequestsViewModel @Environment(\.dismiss) private var dismiss - - @State private var isSearchViewPresented = false - - var body: some View { - NavigationStack { - ZStack { - headerView - BackgroundView() - VStack(alignment: .leading) { - Button(action: {dismiss() }) { - Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")).font(.title2) + + @State private var isSearchViewPresented = false + + var body: some View { + NavigationStack { + ZStack { + BackgroundView() + + VStack(alignment: .leading, spacing: 0) { + headerView + + if !friendRequestsViewModel.friendRequests.isEmpty + || !suggestedFriendsViewModel.suggestedFriends.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + + if !friendRequestsViewModel.friendRequests.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Friend Requests") + .font(Font.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(Color("Accent")) + .padding(.horizontal, 20) + + LazyVStack(spacing: 8) { + ForEach(friendRequestsViewModel.friendRequests) { request in + FriendRequestCard(request: request) + .padding(.horizontal, 4) + } + } + } + } + + + if !suggestedFriendsViewModel.suggestedFriends.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Suggested Friends") + .font(Font.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(Color("Accent")) + .padding(.horizontal, 20) + + SuggestedFriendsView() + .padding(.horizontal, 20) + } + } + } + .padding(.top, 20) + } + } else { + + VStack(spacing: 20) { + Spacer() + + Image(systemName: "person.2.badge.plus") + .font(.system(size: 30)) + .foregroundColor(Color("Accent")) + + Text("Requests and Suggestions") + .multilineTextAlignment(.center) + .font(Font.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(Color.white) + + Text("Your friend requests and suggested friends will appear here. Tap the search icon to find friends manually.") + .multilineTextAlignment(.center) + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color.white.opacity(0.8)) + .padding(.horizontal, 40) + .lineLimit(nil) + + Spacer() + } } - if !suggestedFriendsViewModel.suggestedFriends.isEmpty - || !friendRequestViewModel.requests.isEmpty - { - VStack(alignment: .leading) { - if !suggestedFriendsViewModel.suggestedFriends.isEmpty { - Text("Suggested Friends") - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) - .padding(.top) - .padding(.horizontal) - SuggestedFriendsView() - .padding(.horizontal) - - } - Spacer() - } - } - else { - Spacer() - Text("Request and Suggestions") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white).padding() - Text("Your friend requests and suggested friends will be shown here") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundColor(Color.white).padding() - Spacer() - } - } - } .navigationBarBackButtonHidden(true) - .toolbar { - } - - } - .onAppear { - suggestedFriendsViewModel.fetchData( - from: "\(APIConstants.base_url)/api/v2/users/suggested/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - } - } + } + } + .navigationBarBackButtonHidden(true) + } + .onAppear { + + friendRequestsViewModel.fetchFriendRequests( + token: authViewModel.loggedInBackendUser?.token ?? "" + ) + + + suggestedFriendsViewModel.fetchData( + from: "\(APIConstants.base_url)users/suggested/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: true + ) + } + } + private var headerView: some View { HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")).font(.title2) + .foregroundColor(Color("Accent")) + .font(.title2) } Spacer() - Text("Note") + Text("Add Friends") .foregroundColor(.white) .font(.system(size: 25, weight: .bold)) Spacer() @@ -85,15 +118,13 @@ struct AddFriendsView: View { }) { Image(systemName: "magnifyingglass") .foregroundColor(.white) + .font(.title2) } .navigationDestination( isPresented: $isSearchViewPresented, destination: { SearchView() } ) - - - }.padding() } - - + .padding() + } } diff --git a/VITTY/VITTY/Connect/AddFriends/View/Components/AddFriendsHeader.swift b/VITTY/VITTY/Connect/AddFriends/View/Components/AddFriendsHeader.swift index 955231b..cd8dfca 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/Components/AddFriendsHeader.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/Components/AddFriendsHeader.swift @@ -41,6 +41,3 @@ struct AddFriendsHeader: View { } } -#Preview { - AddFriendsHeader() -} diff --git a/VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift b/VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift new file mode 100644 index 0000000..7d2f353 --- /dev/null +++ b/VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift @@ -0,0 +1,149 @@ +// +// FreindRequestCard.swift +// VITTY +// +// Created by Rujin Devkota on 7/4/25. +// + +import SwiftUI +import OSLog + +struct FriendRequestCard: View { + @Environment(AuthViewModel.self) private var authViewModel + @Environment(RequestsViewModel.self) private var friendRequestsViewModel + @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel + + let request: FriendRequest + @State private var isAccepting = false + @State private var isDeclining = false + @State private var isProcessed = false + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: FriendRequestCard.self) + ) + + var body: some View { + if !isProcessed { + HStack { + + UserImage(url: request.from.picture, height: 48, width: 48) + + + VStack(alignment: .leading, spacing: 2) { + Text(request.from.name) + .font(Font.custom("Poppins-SemiBold", size: 15)) + .foregroundColor(Color.white) + + Text("@\(request.from.username)") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + + if request.from.mutualFriendsCount > 0 { + Text("\(request.from.mutualFriendsCount) mutual friends") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundColor(Color.white.opacity(0.7)) + } + } + + Spacer() + + + HStack(spacing: 12) { + + Button(action: { + declineRequest() + }) { + if isDeclining { + ProgressView() + .scaleEffect(0.8) + .frame(width: 24, height: 24) + } else { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + } + } + .frame(width: 36, height: 36) + .background(Color.red.opacity(0.2)) + .foregroundColor(.red) + .cornerRadius(18) + .disabled(isDeclining || isAccepting) + + + Button(action: { + acceptRequest() + }) { + if isAccepting { + ProgressView() + .scaleEffect(0.8) + .frame(width: 24, height: 24) + } else { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .medium)) + } + } + .frame(width: 36, height: 36) + .background(Color("Accent").opacity(0.2)) + .foregroundColor(Color("Accent")) + .cornerRadius(18) + .disabled(isAccepting || isDeclining) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + } + + private func acceptRequest() { + guard !isAccepting else { return } + + isAccepting = true + + Task { + let success = await friendRequestsViewModel.acceptFriendRequest( + username: request.from.username, + token: authViewModel.loggedInBackendUser?.token ?? "" + ) + + await MainActor.run { + if success { + isProcessed = true + logger.info("Friend request accepted successfully") + + + suggestedFriendsViewModel.fetchData( + from: "\(APIConstants.base_url)users/suggested/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: false + ) + } else { + logger.error("Failed to accept friend request") + } + isAccepting = false + } + } + } + + private func declineRequest() { + guard !isDeclining else { return } + + isDeclining = true + + Task { + let success = await friendRequestsViewModel.declineFriendRequest( + username: request.from.username, + token: authViewModel.loggedInBackendUser?.token ?? "" + ) + + await MainActor.run { + if success { + isProcessed = true + logger.info("Friend request declined successfully") + } else { + logger.error("Failed to decline friend request") + } + isDeclining = false + } + } + } +} diff --git a/VITTY/VITTY/Connect/Models/CircleModel.swift b/VITTY/VITTY/Connect/Models/CircleModel.swift index 3c6a764..eaea8f2 100644 --- a/VITTY/VITTY/Connect/Models/CircleModel.swift +++ b/VITTY/VITTY/Connect/Models/CircleModel.swift @@ -5,8 +5,7 @@ // Created by Rujin Devkota on 3/25/25. // -//TODO: the Circle doesnt have image in the endpoint , the circle members dont have thier venu status currently in the endpoint - +//TODO: the Circle doesnt have image in the endpoint @@ -36,17 +35,47 @@ struct CircleMember: Identifiable { let venue: String? } +// MARK: - Current Status Model +struct CurrentStatus: Codable { + let className: String? + let slot: String? + let status: String + let venue: String? + + enum CodingKeys: String, CodingKey { + case className = "class" + case slot, status, venue + } +} +// MARK: - Updated CircleUserTemp Model struct CircleUserTemp: Codable { let email: String let name: String let picture: String let username: String - let status: String? - let venue: String? - + let currentStatus: CurrentStatus? + enum CodingKeys: String, CodingKey { - case email, name, picture, username, status, venue + case email, name, picture, username + case currentStatus = "current_status" + } + + + var status: String { + return currentStatus?.status ?? "free" + } + + var venue: String? { + return currentStatus?.venue + } + + var className: String? { + return currentStatus?.className + } + + var slot: String? { + return currentStatus?.slot } } @@ -57,6 +86,7 @@ struct CircleUserResponseTemp: Codable { case data } } + // MARK: - Request Models struct CircleRequest: Codable, Identifiable { let id = UUID() @@ -69,7 +99,6 @@ struct CircleRequest: Codable, Identifiable { case circle_id, circle_name, from_username, to_username } } - struct CircleRequestResponse: Codable { let data: [CircleRequest] } diff --git a/VITTY/VITTY/Connect/Models/FreindRequestModel.swift b/VITTY/VITTY/Connect/Models/FreindRequestModel.swift new file mode 100644 index 0000000..b1cf566 --- /dev/null +++ b/VITTY/VITTY/Connect/Models/FreindRequestModel.swift @@ -0,0 +1,37 @@ +// +// FreindRequestModel.swift +// VITTY +// +// Created by Rujin Devkota on 7/4/25. +// + +import Foundation + +// MARK: - Friend Request Models +struct FriendRequest: Codable, Identifiable { + let id = UUID() + let from: RequestUser + + enum CodingKeys: String, CodingKey { + case from + } +} + +struct RequestUser: Codable { + let username: String + let name: String + let picture: String + let friendStatus: String + let friendsCount: Int + let mutualFriendsCount: Int + let currentStatus: CurrentStatus + + enum CodingKeys: String, CodingKey { + case username, name, picture + case friendStatus = "friend_status" + case friendsCount = "friends_count" + case mutualFriendsCount = "mutual_friends_count" + case currentStatus = "current_status" + } +} + diff --git a/VITTY/VITTY/Connect/Search/Views/AddFriendCardSearch.swift b/VITTY/VITTY/Connect/Search/Views/AddFriendCardSearch.swift index 3ca0679..396b0d4 100644 --- a/VITTY/VITTY/Connect/Search/Views/AddFriendCardSearch.swift +++ b/VITTY/VITTY/Connect/Search/Views/AddFriendCardSearch.swift @@ -5,75 +5,133 @@ // Created by Chandram Dutta on 05/01/24. // + + import OSLog import SwiftUI struct AddFriendCardSearch: View { - - @Environment(AuthViewModel.self) private var authViewModel - @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel - - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: AddFriendCard.self - ) - ) - - @Binding var friend: Friend - let search: String? - var body: some View { - HStack { - UserImage(url: friend.picture, height: 48, width: 48) - VStack(alignment: .leading) { - Text(friend.name) - .font(Font.custom("Poppins-SemiBold", size: 15)) - .foregroundColor(Color.white) - Text(friend.username) - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) - } - Spacer() - if friend.friendStatus != "sent" && friend.friendStatus != "friends" { - Button("Send Request") { - - Task { - let url = URL( - string: - "\(APIConstants.base_url)/api/v2/requests/\(friend.username)/send" - )! - print("\(APIConstants.base_url)/api/v2/requests/\(friend.username)/send") - var request = URLRequest(url: url) - - request.httpMethod = "POST" - request.addValue( - "Bearer \(authViewModel.loggedInBackendUser?.token ?? "")", - forHTTPHeaderField: "Authorization" - ) - do { - let (_, _) = try await URLSession.shared.data(for: request) - suggestedFriendsViewModel.fetchData( - from: "\(APIConstants.base_url)/api/v2/users/suggested/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: false - ) - if search != nil { - friend.friendStatus = "sent" - } - } - catch { - return - } - } - - } - .buttonStyle(.bordered) - .font(.caption) - } - else { - Image(systemName: "person.fill.checkmark") - } - } - .padding(.bottom) - } + + @Environment(AuthViewModel.self) private var authViewModel + @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: AddFriendCardSearch.self) + ) + + @Binding var friend: SearchFriend + let search: String? + @State private var isLoading = false + + var body: some View { + HStack { + UserImage(url: friend.picture, height: 48, width: 48) + VStack(alignment: .leading) { + Text(friend.name) + .font(Font.custom("Poppins-SemiBold", size: 15)) + .foregroundColor(Color.white) + Text(friend.username) + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + Spacer() + + if friend.friendStatus != "sent" && friend.friendStatus != "friends" { + Button(action: { + sendFriendRequest() + }) { + if isLoading { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Sending...") + .font(.caption) + } + } else { + Text("Send Request") + .font(.caption) + } + } + .buttonStyle(.bordered) + .disabled(isLoading) + } else { + Image(systemName: "person.fill.checkmark") + .foregroundColor(Color("Accent")) + } + } + .padding(.bottom) + } + + private func sendFriendRequest() { + guard !isLoading else { return } + + isLoading = true + + Task { + do { + + let urlString = "\(APIConstants.base_url)requests/\(friend.username)/send" + guard let url = URL(string: urlString) else { + logger.error("Invalid URL: \(urlString)") + await MainActor.run { + isLoading = false + } + return + } + + logger.info("Sending friend request to: \(urlString)") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue( + "Bearer \(authViewModel.loggedInBackendUser?.token ?? "")", + forHTTPHeaderField: "Authorization" + ) + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + logger.info("Response status code: \(httpResponse.statusCode)") + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + // Success - update the friend status + await MainActor.run { + friend.friendStatus = "sent" + isLoading = false + } + + logger.info("Friend request sent successfully") + + // Refresh the suggested friends list + suggestedFriendsViewModel.fetchData( + from: "\(APIConstants.base_url)users/suggested/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: false + ) + } else { + // Handle error response + if let responseString = String(data: data, encoding: .utf8) { + logger.error("Error response: \(responseString)") + } + await MainActor.run { + isLoading = false + } + } + } else { + logger.error("Invalid response type") + await MainActor.run { + isLoading = false + } + } + + } catch { + logger.error("Failed to send friend request: \(error.localizedDescription)") + await MainActor.run { + isLoading = false + } + } + } + } } diff --git a/VITTY/VITTY/Connect/Search/Views/SearchView.swift b/VITTY/VITTY/Connect/Search/Views/SearchView.swift index ade68f8..0e3279b 100644 --- a/VITTY/VITTY/Connect/Search/Views/SearchView.swift +++ b/VITTY/VITTY/Connect/Search/Views/SearchView.swift @@ -7,130 +7,371 @@ import OSLog import SwiftUI +import Alamofire struct SearchView: View { - @State private var searchText = "" - @State private var searchedFriends = [Friend]() - @State private var loading = false - - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: SearchView.self - ) - ) - - @Environment(AuthViewModel.self) private var authViewModel - @Environment(\.dismiss) var dismiss - var body: some View { - NavigationStack { - ZStack { - BackgroundView() - VStack(alignment: .leading) { - + @State private var searchText = "" + @State private var searchedFriends = [SearchFriend]() + @State private var loading = false + @State private var hasSearched = false + @State private var searchDebouncer: Timer? + @State private var rotationAngle: Double = 0 + @State private var currentSearchTask: DataRequest? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: SearchView.self) + ) + + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) var dismiss + + + var body: some View { + NavigationStack { + ZStack { + BackgroundView() + VStack(alignment: .leading, spacing: 0) { headerView - RoundedRectangle(cornerRadius: 20) - .foregroundColor(Color("Secondary")) - .frame(maxWidth: .infinity) - .frame(height: 64) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke(Color("Accent"), lineWidth: 1) - .frame(maxWidth: .infinity) - .frame(height: 64) - .padding() - .overlay(alignment: .leading) { - TextField(text: $searchText) { - Text("Search Friends") - .foregroundColor(Color("Accent")) - } - .onChange(of: searchText) { - search() - } - .padding(.horizontal, 42) - .foregroundColor(.white) - .foregroundColor(Color("Secondary")) - } - ) - if loading { - Spacer() - ProgressView() - } - else { - List($searchedFriends, id: \.username) { friend in - - AddFriendCardSearch(friend: friend, search: searchText) - - - .listRowBackground( - RoundedRectangle(cornerRadius: 15) - .fill(Color("Secondary")) - .padding(.bottom) - ) - .listRowSeparator(.hidden) - - } - - .scrollContentBackground(.hidden) - } - - Spacer() - } - }.navigationBarBackButtonHidden(true) - - } - } + + searchBar + + if loading && !searchText.isEmpty { + VStack(spacing: 20) { + Spacer() + + ZStack { + Circle() + .stroke(Color("Accent").opacity(0.2), lineWidth: 2) + .frame(width: 20, height: 20) + + Circle() + .trim(from: 0, to: 0.7) + .stroke(Color("Accent"), style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .frame(width: 20, height: 20) + .rotationEffect(.degrees(rotationAngle)) + .onAppear { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + rotationAngle = 360 + } + } + } + + Text("Searching for '\(searchText)'...") + .font(Font.custom("Poppins-Regular", size: 16)) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + + Button(action: { + cancelSearch() + }) { + HStack(spacing: 8) { + Image(systemName: "xmark.circle.fill") + Text("Cancel") + .font(Font.custom("Poppins-Medium", size: 14)) + } + .foregroundColor(Color("Accent")) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .stroke(Color("Accent"), lineWidth: 1) + .background(Color("Secondary")) + ) + } + + Spacer() + } + .frame(maxWidth: .infinity) + } else if !hasSearched { + + VStack(spacing: 20) { + Spacer() + + Image(systemName: "magnifyingglass.circle") + .font(.system(size: 60)) + .foregroundColor(Color("Accent")) + + Text("Search for Friends") + .font(Font.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(Color.white) + + Text("Enter a username or name to find friends on VITTY") + .multilineTextAlignment(.center) + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color.white.opacity(0.8)) + .padding(.horizontal, 40) + + Spacer() + } + } else if searchedFriends.isEmpty && !searchText.isEmpty { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 60)) + .foregroundColor(Color("Accent")) + + Text("No Results Found") + .font(Font.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(Color.white) + + Text("No users found for '\(searchText)'. Try a different search term.") + .multilineTextAlignment(.center) + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color.white.opacity(0.8)) + .padding(.horizontal, 40) + + Spacer() + } + } else { + List($searchedFriends, id: \.username) { searchfriend in + AddFriendCardSearch(friend: searchfriend , search: searchText) + .listRowBackground( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + .padding(.bottom, 4) + ) + .listRowSeparator(.hidden) + } + .scrollContentBackground(.hidden) + .padding(.top, 8) + } + } + } + .navigationBarBackButtonHidden(true) + } + } + private var headerView: some View { HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")).font(.title2) + .foregroundColor(Color("Accent")) + .font(.title2) } Spacer() Text("Search") .foregroundColor(.white) .font(.system(size: 22, weight: .bold)) Spacer() - + + +// if !searchText.isEmpty && !loading { +// Button(action: { +// clearSearch() +// }) { +// Image(systemName: "xmark.circle.fill") +// .foregroundColor(Color("Accent")) +// .font(.title3) +// } +// } else { +// +// Image(systemName: "xmark.circle.fill") +// .foregroundColor(.clear) +// .font(.title3) +// } } .padding() } - func search() { - loading = true - let url = URL(string: "\(APIConstants.base_url)/api/v2/users/search?query=\(searchText)")! - var request = URLRequest(url: url) - let session = URLSession.shared - request.httpMethod = "GET" - request.addValue( - "Bearer \(authViewModel.loggedInBackendUser?.token ?? "")", - forHTTPHeaderField: "Authorization" - ) - if searchText.isEmpty { - searchedFriends = [] - } - else { - let task = session.dataTask(with: request) { (data, response, error) in - guard let data = data else { - logger.warning("No data received") - return - } - do { - // Decode the JSON data into an array of UserInfo structs - let users = try JSONDecoder().decode([Friend].self, from: data) - .filter { $0.username != authViewModel.loggedInBackendUser?.username ?? "" } - searchedFriends = users - } - catch { - logger.error("Error decoding JSON: \(error)") - } - } - task.resume() - } - loading = false - } + + private var searchBar: some View { + HStack { + RoundedRectangle(cornerRadius: 20) + .foregroundColor(Color("Secondary")) + .frame(maxWidth: .infinity) + .frame(height: 64) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(searchText.isEmpty ? Color("Accent").opacity(0.3) : Color("Accent"), lineWidth: 1) + ) + .overlay(alignment: .leading) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(Color("Accent")) + .padding(.leading, 16) + + TextField("Search friends...", text: $searchText) + .foregroundColor(.white) + .font(Font.custom("Poppins-Regular", size: 16)) + .onChange(of: searchText) { _, newValue in + debouncedSearch(newValue) + } + .submitLabel(.search) + .onSubmit { + search() + } + + if !searchText.isEmpty && !loading { + Button(action: { + clearSearch() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color("Accent").opacity(0.6)) + .padding(.trailing, 16) + } + } + } + } + } + .padding(.horizontal) + .padding(.bottom, 8) + } + + func clearSearch() { + + cancelSearch() + + // Reset all states + searchText = "" + searchedFriends = [] + hasSearched = false + loading = false + rotationAngle = 0 + } + + func debouncedSearch(_ query: String) { + searchDebouncer?.invalidate() + + guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + cancelSearch() + searchedFriends = [] + hasSearched = false + loading = false + return + } + + searchDebouncer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + search() + } + } + + func search() { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + + + guard !query.isEmpty else { + logger.warning("Search query is empty, skipping search") + return + } + + + cancelSearch() + + + loading = true + hasSearched = true + + + logger.info("Starting search for query: \(query)") + + + let baseURL = "\(APIConstants.base_url)users/search" + let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let urlString = "\(baseURL)?query=\(encodedQuery)" + + let token = authViewModel.loggedInBackendUser?.token ?? "" + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(token)", + "Content-Type": "application/json" + ] + + + currentSearchTask = AF.request( + urlString, + method: .get, + headers: headers + ) + .validate(statusCode: 200..<300) + .responseDecodable(of: [SearchUserResponse].self) { response in + Task { @MainActor in + + self.loading = false + self.currentSearchTask = nil + + switch response.result { + case .success(let searchResults): + self.logger.info("Search successful, found \(searchResults.count) results") + + + self.searchedFriends = searchResults.map { searchResult in + SearchFriend( + username: searchResult.username, + name: searchResult.name, + picture: searchResult.picture, + friendStatus: searchResult.friendStatus, + currentStatus: searchResult.currentStatus.status, + friendsCount: searchResult.friendsCount, + mutualFriendsCount: searchResult.mutualFriendsCount + ) + } + + case .failure(let error): + self.logger.error("Search failed with error: \(error.localizedDescription)") + + + if let afError = error.asAFError { + switch afError { + case .responseValidationFailed(reason: .unacceptableStatusCode(code: let statusCode)): + if statusCode == 404 { + + self.searchedFriends = [] + } else { + self.logger.error("API returned status code: \(statusCode)") + self.searchedFriends = [] + } + default: + self.logger.error("Network error: \(afError.localizedDescription)") + self.searchedFriends = [] + } + } else { + self.logger.error("Unknown error occurred during search") + self.searchedFriends = [] + } + } + } + } + } + + // Update the cancelSearch function to work with Alamofire + func cancelSearch() { + currentSearchTask?.cancel() + currentSearchTask = nil + loading = false + rotationAngle = 0 + searchDebouncer?.invalidate() + } +} + +// MARK: - Search Response Models +struct SearchUserResponse: Codable { + let currentStatus: SearchCurrentStatus + let friendStatus: String + let friendsCount: Int + let mutualFriendsCount: Int + let name: String + let picture: String + let username: String + + enum CodingKeys: String, CodingKey { + case currentStatus = "current_status" + case friendStatus = "friend_status" + case friendsCount = "friends_count" + case mutualFriendsCount = "mutual_friends_count" + case name, picture, username + } +} +struct SearchCurrentStatus: Codable { + let status: String } -#Preview { - SearchView() +struct SearchFriend:Codable { + var username: String + var name: String + var picture: String + var friendStatus: String + var currentStatus: String + var friendsCount: Int + var mutualFriendsCount: Int } diff --git a/VITTY/VITTY/Connect/SuggestedFriends/Views/Components/AddFriendCard.swift b/VITTY/VITTY/Connect/SuggestedFriends/Views/Components/AddFriendCard.swift index 2c6a466..28f1bcd 100644 --- a/VITTY/VITTY/Connect/SuggestedFriends/Views/Components/AddFriendCard.swift +++ b/VITTY/VITTY/Connect/SuggestedFriends/Views/Components/AddFriendCard.swift @@ -9,66 +9,131 @@ import OSLog import SwiftUI struct AddFriendCard: View { - - @Environment(AuthViewModel.self) private var authViewModel - @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel - - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: AddFriendCard.self - ) - ) - - let friend: Friend - var body: some View { - HStack { - UserImage(url: friend.picture, height: 48, width: 48) - VStack(alignment: .leading) { - Text(friend.name) - .font(Font.custom("Poppins-SemiBold", size: 15)) - .foregroundColor(Color.white) - Text(friend.username) - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) - } - Spacer() - if friend.friendStatus != "sent" && friend.friendStatus != "friends" { - Button("Send Request") { - - Task { - let url = URL( - string: - "\(APIConstants.base_url)/api/v2/requests/\(friend.username)/send" - )! - print("\(APIConstants.base_url)/api/v2/requests/\(friend.username)/send") - var request = URLRequest(url: url) - - request.httpMethod = "POST" - request.addValue( - "Bearer \(authViewModel.loggedInBackendUser?.token ?? "")", - forHTTPHeaderField: "Authorization" - ) - do { - let (_, _) = try await URLSession.shared.data(for: request) - suggestedFriendsViewModel.fetchData( - from: "\(APIConstants.base_url)/api/v2/users/suggested/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: false - ) - } - catch { - return - } - } - - } - .buttonStyle(.bordered) - .font(.caption) - } - else { - Image(systemName: "person.fill.checkmark") - } - } - } + + @Environment(AuthViewModel.self) private var authViewModel + @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: AddFriendCard.self) + ) + + let friend: Friend + @State private var isLoading = false + @State private var localFriendStatus: String + + init(friend: Friend) { + self.friend = friend + self._localFriendStatus = State(initialValue: friend.friendStatus) + } + + var body: some View { + HStack { + UserImage(url: friend.picture, height: 48, width: 48) + VStack(alignment: .leading) { + Text(friend.name) + .font(Font.custom("Poppins-SemiBold", size: 15)) + .foregroundColor(Color.white) + Text(friend.username) + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + Spacer() + + if localFriendStatus != "sent" && localFriendStatus != "friends" { + Button(action: { + sendFriendRequest() + }) { + if isLoading { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Sending...") + .font(.caption) + } + } else { + Text("Send Request") + .font(.caption) + } + } + .buttonStyle(.bordered) + .disabled(isLoading) + } else { + Image(systemName: "person.fill.checkmark") + .foregroundColor(Color("Accent")) + } + } + } + + private func sendFriendRequest() { + guard !isLoading else { return } + + isLoading = true + + Task { + do { + + let urlString = "\(APIConstants.base_url)requests/\(friend.username)/send" + guard let url = URL(string: urlString) else { + logger.error("Invalid URL: \(urlString)") + await MainActor.run { + isLoading = false + } + return + } + + logger.info("Sending friend request to: \(urlString)") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue( + "Bearer \(authViewModel.loggedInBackendUser?.token ?? "")", + forHTTPHeaderField: "Authorization" + ) + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + logger.info("Response status code: \(httpResponse.statusCode)") + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + // Success + await MainActor.run { + localFriendStatus = "sent" + isLoading = false + } + + logger.info("Friend request sent successfully") + + // Refresh the suggested friends list + suggestedFriendsViewModel.fetchData( + from: "\(APIConstants.base_url)users/suggested/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: false + ) + } else { + // Handle error response + if let responseString = String(data: data, encoding: .utf8) { + logger.error("Error response: \(responseString)") + } + await MainActor.run { + isLoading = false + } + } + } else { + logger.error("Invalid response type") + await MainActor.run { + isLoading = false + } + } + + } catch { + logger.error("Failed to send friend request: \(error.localizedDescription)") + await MainActor.run { + isLoading = false + } + } + } + } } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift index e1881e8..6808ae4 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift @@ -36,7 +36,11 @@ struct CirclesRow: View { var body: some View { HStack { - UserImage(url: "https://picsum.photos/200/300", height: 48, width: 48) + + //TODO: left to add a circle image right now its a picsum image + + CircleImageView(imageURL: "https://picsum.photos/200/300", size: 48) + Spacer().frame(width: 20) VStack(alignment: .leading) { @@ -95,8 +99,11 @@ struct CirclesRow: View { circleID: circle.circleID ) } + + } + func cleanName(_ fullName: String) -> String { let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" let regex = try? NSRegularExpression(pattern: pattern, options: []) @@ -107,3 +114,26 @@ struct CirclesRow: View { return cleanedName } } +struct CircleImageView: View { + let imageURL: String + let size: CGFloat + + var body: some View { + AsyncImage(url: URL(string: imageURL)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size) + .clipShape(Circle()) + } placeholder: { + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: size, height: size) + .overlay( + Image(systemName: "person.circle.fill") + .font(.system(size: size * 0.5)) + .foregroundColor(.gray) + ) + } + } +} diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift index 44ab789..61567ce 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -1,8 +1,9 @@ // -// Freinds.swift +// CreateGroup.swift // VITTY // // Created by Rujin Devkota on 2/27/25. + import SwiftUI import Alamofire @@ -19,9 +20,11 @@ struct CreateGroup: View { @State private var isCreatingGroup = false @State private var showAlert = false @State private var alertMessage = "" + @State private var circle_ID = "" @Environment(CommunityPageViewModel.self) private var viewModel let token: String + let username: String @Environment(\.dismiss) private var dismiss @@ -34,7 +37,7 @@ struct CreateGroup: View { .padding(.top, 10) Text("Create Group") - .font(.system(size: 23, weight: .bold)) + .font(.system(size: 23, weight: .semibold)) .foregroundColor(.white) Spacer().frame(height: 20) @@ -64,10 +67,10 @@ struct CreateGroup: View { } } .sheet(isPresented: $showImagePicker) { - + // ImagePicker implementation would go here } - + VStack(alignment: .leading, spacing: 10) { Text("Enter group name") .font(.system(size: 18, weight: .bold)) @@ -82,10 +85,31 @@ struct CreateGroup: View { RoundedRectangle(cornerRadius: 8) .stroke(Color.gray.opacity(0.5), lineWidth: 1) ) + .onChange(of: groupName) { oldValue, newValue in + + let filtered = newValue.replacingOccurrences(of: " ", with: "") + if filtered != newValue { + groupName = filtered + } + + + if groupName.count > 20 { + groupName = String(groupName.prefix(20)) + } + } + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + + + Text("No spaces allowed • Max 20 characters") + .font(.system(size: 12)) + .foregroundColor(.gray) + .padding(.leading, 5) } .padding(.horizontal, 20) + + - HStack { Text("Add Friends") .font(.system(size: 18, weight: .bold)) @@ -104,7 +128,7 @@ struct CreateGroup: View { .padding(.trailing, 20) } - + if selectedFriends.isEmpty { VStack { @@ -194,7 +218,7 @@ struct CreateGroup: View { Spacer() - + HStack { Spacer() Button(action: { @@ -207,10 +231,10 @@ struct CreateGroup: View { .progressViewStyle(CircularProgressViewStyle(tint: .black)) } Text(isCreatingGroup ? "Creating..." : "Create") - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(Color.black) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) } - .frame(width: 120, height: 40) + .frame(width: 100, height: 35) .background(groupName.isEmpty ? Color.gray : Color("Accent")) .cornerRadius(10) } @@ -240,63 +264,72 @@ struct CreateGroup: View { } } + // MARK: - Group Creation using ViewModel (Fixed Version) + private func createGroup() { guard !groupName.isEmpty else { return } isCreatingGroup = true - - let createURL = "\(APIConstants.base_url)circles/create/\(groupName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? groupName)" - - AF.request(createURL, method: .post, headers: ["Authorization": "Token \(token)"]) - .validate() - .responseDecodable(of: CreateCircleResponse.self) { response in - DispatchQueue.main.async { - switch response.result { - case .success(let data): - - self.sendInvitations(circleId: data.circleId) + viewModel.createCircle(name: groupName, token: token) { result in + switch result { + case .success(let circleId): + print("Successfully created circle with ID: \(circleId)") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if let circle = self.viewModel.circles.first(where: { $0.circleName == self.groupName }) { + + print("Found circle ID: \(circle.circleID) for name: \(self.groupName)") + + self.circle_ID = circle.circleID - case .failure(let error): + + if self.selectedFriends.isEmpty { + self.isCreatingGroup = false + self.alertMessage = "Group created successfully!" + self.showAlert = true + } else { + + self.sendInvitationsUsingViewModel(circleId: circle.circleID) + } + + } else { + let error = NSError(domain: "CreateCircleError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not find created circle in local data"]) + + self.isCreatingGroup = false - self.alertMessage = "Failed to create group: \(error.localizedDescription)" + self.alertMessage = "Failed to find created group in local data" self.showAlert = true } } + + case .failure(let error): + self.isCreatingGroup = false + self.alertMessage = "Failed to create group: \(error.localizedDescription)" + self.showAlert = true } + } } - - private func sendInvitations(circleId: String) { + + private func sendInvitationsUsingViewModel(circleId: String) { guard !selectedFriends.isEmpty else { - self.isCreatingGroup = false self.alertMessage = "Group created successfully!" self.showAlert = true return } - let dispatchGroup = DispatchGroup() - var invitationResults: [String: Bool] = [:] + // Extract usernames from selected friends + let usernames = selectedFriends.map { $0.username } - for friend in selectedFriends { - dispatchGroup.enter() - - let inviteURL = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(friend.username)" - - AF.request(inviteURL, method: .post, headers: ["Authorization": "Token \(token)"]) - .validate() - .response { response in - DispatchQueue.main.async { - invitationResults[friend.username] = response.error == nil - dispatchGroup.leave() - } - } - } + print("Sending invitations for circle ID: \(circleId)") + print("Usernames: \(usernames)") - dispatchGroup.notify(queue: .main) { + // Use the view model's sendMultipleInvitations function with correct circle ID + viewModel.sendMultipleInvitations(circleId: circleId, usernames: usernames, token: token) { results in self.isCreatingGroup = false - let successCount = invitationResults.values.filter { $0 }.count + let successCount = results.values.filter { $0 }.count let totalCount = self.selectedFriends.count if successCount == totalCount { @@ -310,166 +343,155 @@ struct CreateGroup: View { self.showAlert = true } } -} - - -struct FriendSelectorView: View { - let friends: [Friend] - @Binding var selectedFriends: [Friend] - let loadingFriends: Bool - - @Environment(\.dismiss) private var dismiss - var body: some View { - NavigationView { - VStack { - if loadingFriends { - ProgressView("Loading friends...") + struct FriendSelectorView: View { + let friends: [Friend] + @Binding var selectedFriends: [Friend] + let loadingFriends: Bool + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack { + if loadingFriends { + ProgressView("Loading friends...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.white) + } else if friends.isEmpty { + VStack { + Image(systemName: "person.2.slash") + .font(.system(size: 50)) + .foregroundColor(.gray) + Text("No friends found") + .font(.title2) + .foregroundColor(.gray) + } .frame(maxWidth: .infinity, maxHeight: .infinity) - .foregroundColor(.white) - } else if friends.isEmpty { - VStack { - Image(systemName: "person.2.slash") - .font(.system(size: 50)) - .foregroundColor(.gray) - Text("No friends found") - .font(.title2) - .foregroundColor(.gray) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVStack(spacing: 12) { - ForEach(friends, id: \.username) { friend in - FriendRowView( - friend: friend, - isSelected: selectedFriends.contains { $0.username == friend.username } - ) { isSelected in - if isSelected { - selectedFriends.append(friend) - } else { - selectedFriends.removeAll { $0.username == friend.username } + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(friends, id: \.username) { friend in + FriendRowView( + friend: friend, + isSelected: selectedFriends.contains { $0.username == friend.username } + ) { isSelected in + if isSelected { + selectedFriends.append(friend) + } else { + selectedFriends.removeAll { $0.username == friend.username } + } } } } + .padding(.horizontal, 16) + .padding(.top, 8) } - .padding(.horizontal, 16) - .padding(.top, 8) } } + .background(Color("Background")) + .navigationTitle("Select Friends") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + leading: Button(action: { + dismiss() + }) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + }, + trailing: Button(action: { + dismiss() + }) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + } + ) } .background(Color("Background")) - .navigationTitle("Select Friends") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems( - leading: Button(action: { - dismiss() - }) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - }, - trailing: Button(action: { - dismiss() - }) { - Image(systemName: "checkmark") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - } - ) } - .background(Color("Background")) } -} - - -struct FriendRowView: View { - let friend: Friend - let isSelected: Bool - let onToggle: (Bool) -> Void - var body: some View { - HStack { - - AsyncImage(url: URL(string: friend.picture)) { image in - image - .resizable() - .scaledToFill() - } placeholder: { - Circle() - .fill(Color.blue.opacity(0.3)) - .overlay( - Text(String(friend.name.prefix(1)).uppercased()) - .foregroundColor(.white) - .font(Font.custom("Poppins-SemiBold", size: 16)) - ) - } - .frame(width: 48, height: 48) - .clipShape(Circle()) - - Spacer().frame(width: 20) - - - VStack(alignment: .leading, spacing: 4) { - Text(cleanName(friend.name)) - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white) + struct FriendRowView: View { + let friend: Friend + let isSelected: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack { - if friend.currentStatus.status == "free" { - HStack { - Image("available") - .resizable() - .frame(width: 20, height: 20) - Text("Available") - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundStyle(Color("Accent")) - } - } else { - HStack { - Image("inclass") - .resizable() - .frame(width: 20, height: 20) - Text(friend.currentStatus.venue ?? "In Class") - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.blue.opacity(0.3)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(Font.custom("Poppins-SemiBold", size: 16)) + ) + } + .frame(width: 48, height: 48) + .clipShape(Circle()) + + Spacer().frame(width: 20) + + + VStack(alignment: .leading, spacing: 4) { + Text(friend.name) + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(Color.white) + + if friend.currentStatus.status == "free" { + HStack { + Image("available") + .resizable() + .frame(width: 20, height: 20) + Text("Available") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + Image("inclass") + .resizable() + .frame(width: 20, height: 20) + Text(friend.currentStatus.venue ?? "In Class") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } } } + + Spacer() + + + Button(action: { + onToggle(!isSelected) + }) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? Color("Accent") : .gray) + .font(.system(size: 24)) + } } - - Spacer() - - - Button(action: { + .padding() + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + ) + .contentShape(Rectangle()) + .onTapGesture { onToggle(!isSelected) - }) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundColor(isSelected ? Color("Accent") : .gray) - .font(.system(size: 24)) } } - .padding() - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 15) - .fill(Color("Secondary")) - ) - .contentShape(Rectangle()) - .onTapGesture { - onToggle(!isSelected) - } - } - - func cleanName(_ fullName: String) -> String { - let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" - let regex = try? NSRegularExpression(pattern: pattern, options: []) - - let range = NSRange(location: 0, length: fullName.utf16.count) - let cleanedName = regex?.stringByReplacingMatches(in: fullName, options: [], range: range, withTemplate: "").trimmingCharacters(in: .whitespaces) ?? fullName - - return cleanedName } } +// MARK: - Response Models (if not already defined elsewhere) struct CreateCircleResponse: Decodable { let circleId: String @@ -480,5 +502,3 @@ struct CreateCircleResponse: Decodable { case message } } - - diff --git a/VITTY/VITTY/Connect/View/Circles/Components/InsideCircleCards.swift b/VITTY/VITTY/Connect/View/Circles/Components/InsideCircleCards.swift index 5cfa6b6..b98dbce 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/InsideCircleCards.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/InsideCircleCards.swift @@ -23,7 +23,7 @@ struct InsideCircleRow: View { .font(Font.custom("Poppins-SemiBold", size: 18)) .foregroundColor(Color.white) - if status == "free" { + if status == "free" || status == "Available" || status == "Free" { HStack { Image("available").resizable().frame(width: 20, height: 20) Text("Available").foregroundStyle(Color("Accent")) @@ -38,6 +38,8 @@ struct InsideCircleRow: View { } } Spacer() + }.onAppear{ + print("Status is \(status)") } .padding().frame(maxWidth: .infinity) .background( diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index e1d65c5..00a4f94 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -33,7 +33,8 @@ struct JoinGroup: View { .fill(Color.gray.opacity(0.5)) .frame(width: 50, height: 5) .padding(.top, 10) - + + Spacer().frame(height: 7) Text("Join Circle") .font(.system(size: 21, weight: .bold)) .foregroundColor(.white) @@ -114,7 +115,7 @@ struct JoinGroup: View { .disabled(isJoining) Spacer() - + HStack { Spacer() Button(action: { @@ -124,23 +125,19 @@ struct JoinGroup: View { if isJoining { ProgressView() .scaleEffect(0.8) - .foregroundColor(.white) + .progressViewStyle(CircularProgressViewStyle(tint: .black)) } Text(isJoining ? "JOINING..." : "JOIN") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(isJoining ? .white : Color("Accent")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) } - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isJoining ? Color.gray.opacity(0.5) : Color.clear) - ) + .frame(width: 100, height: 35) + .background(localGroupCode.isEmpty ? Color.gray : Color("Accent")) + .cornerRadius(10) } .disabled(isJoining || localGroupCode.isEmpty) .padding(.trailing, 20) } - .padding(.leading, 20) .padding(.bottom, 20) } .presentationDetents([.height(screenHeight * 0.65)]) @@ -159,6 +156,19 @@ struct JoinGroup: View { } .transition(.move(edge: .bottom).combined(with: .opacity)) } + }.onReceive(NotificationCenter.default.publisher(for: Notification.Name("JoinCircleFromDeepLink"))) { notification in + if let userInfo = notification.userInfo, + let circleId = userInfo["circleId"] as? String, + let circleName = userInfo["circleName"] as? String { + + + localGroupCode = circleId + groupCode = circleId + self.circleName = circleName + + + joinCircle() + } } .alert("Join Circle", isPresented: $showingAlert) { Button("OK") { @@ -234,6 +244,7 @@ struct JoinGroup: View { } // MARK: - Join Circle + private func joinCircle() { guard !localGroupCode.isEmpty, let username = authViewModel.loggedInBackendUser?.username, @@ -250,7 +261,8 @@ struct JoinGroup: View { isJoining = true UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - let urlString = "\(APIConstants.base_url)circles/sendRequest/\(localGroupCode)/\(username)" + + let urlString = "\(APIConstants.base_url)circles/join?code=\(localGroupCode)" guard let url = URL(string: urlString) else { showToast(message: "Error: Invalid URL", isError: true) isJoining = false @@ -277,11 +289,12 @@ struct JoinGroup: View { } if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { - showToast(message: "Circle join request sent successfully! 🎉", isError: false) + showToast(message: "Successfully joined the circle! 🎉", isError: false) let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() + communityPageViewModel.fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, @@ -306,7 +319,9 @@ struct JoinGroup: View { case 404: showToast(message: "Error: Circle not found", isError: true) case 409: - showToast(message: "Error: Already a member or request pending", isError: true) + showToast(message: "Error: Already a member of this circle", isError: true) + case 403: + showToast(message: "Error: Not authorized to join this circle", isError: true) default: showToast(message: "Error: Failed to join circle (Code: \(httpResponse.statusCode))", isError: true) } diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift index 2fbcf4a..09bbc11 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift @@ -26,7 +26,7 @@ struct CircleRequestRow: View { .font(.custom("Poppins-SemiBold", size: 16)) .foregroundColor(.white) - Text("wants to join \(request.circle_name)") + Text("wants you to join \(request.circle_name)") .font(.custom("Poppins-Regular", size: 14)) .foregroundColor(Color("Accent")) .lineLimit(2) @@ -235,7 +235,7 @@ struct CircleRequestsView: View { communityPageViewModel.acceptCircleRequest(circleId: request.circle_id, token: token) { success in if success { - alertMessage = "@\(request.from_username) has been added to \(request.circle_name)" + alertMessage = "you have been added to \(request.circle_name)" showSuccessAlert = true diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index 16c4279..3056419 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -58,10 +58,150 @@ struct LeaveCircleAlert: View { } } +struct DeleteCircleAlert: View { + let circleName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete circle?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete \(circleName)? This action cannot be undone and will remove all members from the circle.") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 180) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + +struct GenerateJoinCodeModal: View { + let circleName: String + let joinCode: String + let isLoading: Bool + let onGenerate: () -> Void + let onDismiss: () -> Void + let onCopyCode: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 20) { + Text("Join Code") + .font(.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(.white) + + Text("Share this code with friends to join \(circleName)") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color("Accent"))) + .padding() + } else if !joinCode.isEmpty { + VStack(spacing: 12) { + Text(joinCode) + .font(.custom("Poppins-SemiBold", size: 24)) + .foregroundColor(Color("Accent")) + .padding() + .background(Color("Secondary")) + .cornerRadius(12) + + Button(action: onCopyCode) { + HStack { + Image(systemName: "doc.on.doc") + Text("Copy Code") + } + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color("Secondary")) + .cornerRadius(8) + } + } + } + + HStack(spacing: 10) { + Button(action: onDismiss) { + Text("Close") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + if joinCode.isEmpty && !isLoading { + Button(action: onGenerate) { + Text("Generate Code") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + + .background(Color("Accent")) + .foregroundColor(.black) + .cornerRadius(8) + } + } + } + } + .frame(minHeight: 200) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + struct CircleMenuView: View { let circleName: String let onLeaveGroup: () -> Void + let onDeleteGroup: () -> Void let onGroupRequests: () -> Void + let onGenerateJoinCode: () -> Void let onCancel: () -> Void var body: some View { @@ -87,9 +227,24 @@ struct CircleMenuView: View { Divider() .background(Color.gray.opacity(0.3)) - - + Button(action: { + onCancel() + onDeleteGroup() + }) { + HStack { + Image(systemName: "trash") + .foregroundColor(.red) + Text("Delete Circle") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.red) + Spacer() + } + .padding() + .background(Color("Background")) + } + Divider() + .background(Color.gray.opacity(0.3)) Button(action: onCancel) { Text("Cancel") @@ -110,29 +265,113 @@ struct CircleMenuView: View { } } + +struct DualIconMenu: View { + let onQRCode: () -> Void + let onGenerateCode: () -> Void + + var body: some View { + HStack(spacing: 6) { + + Button(action: onQRCode) { + Image(systemName: "qrcode") + .foregroundColor(Color("Accent")) + .font(.system(size: 16, weight: .medium)) + .frame(width: 32, height: 32) + .background(Color("Secondary")) + .cornerRadius(8) + } + + Button(action: onGenerateCode) { + Image(systemName: "link") + .foregroundColor(Color("Accent")) + .font(.system(size: 16, weight: .medium)) + .frame(width: 32, height: 32) + .background(Color("Secondary")) + .cornerRadius(8) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color("Secondary").opacity(0.3)) + .cornerRadius(12) + } +} + struct InsideCircle: View { var circleName : String var groupCode: String @State var searchText: String = "" @State var showLeaveAlert: Bool = false + @State var showDeleteAlert: Bool = false @State var showCircleMenu: Bool = false - @State var showGroupRequests : Bool = false + @State var showGroupRequests : Bool = false + @State var showGenerateJoinCode: Bool = false + @State var generatedJoinCode: String = "" + @State var isGeneratingCode: Bool = false @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(AuthViewModel.self) private var authViewModel @Environment(\.presentationMode) var presentationMode @State var showQRCode: Bool = false - + + private func isUserBusy(_ member: CircleUserTemp) -> Bool { + let status = member.status + + return status != "free" && !status.isEmpty + } + + private func isUserAvailable(_ member: CircleUserTemp) -> Bool { + let status = member.status + + return status == "free" || status.isEmpty || member.currentStatus == nil + } + private var busyCount: Int { - communityPageViewModel.circleMembers.filter { - $0.status != nil && $0.status != "available" && $0.status != "free" - }.count + communityPageViewModel.circleMembers.filter { isUserBusy($0) }.count } private var availableCount: Int { - communityPageViewModel.circleMembers.filter { - $0.status == nil || $0.status == "available" || $0.status == "free" - }.count + communityPageViewModel.circleMembers.filter { isUserAvailable($0) }.count + } + + // MARK: - Filtered members for search + private var filteredMembers: [CircleUserTemp] { + if searchText.isEmpty { + return communityPageViewModel.circleMembers + } else { + return communityPageViewModel.circleMembers.filter { member in + member.name.localizedCaseInsensitiveContains(searchText) || + member.username.localizedCaseInsensitiveContains(searchText) + } + } + } + + // MARK: - Generate Join Code Function + private func generateJoinCode() { + isGeneratingCode = true + + let token = authViewModel.loggedInBackendUser?.token ?? "" + + communityPageViewModel.generateJoinCode(circleId: groupCode, token: token) { result in + DispatchQueue.main.async { + self.isGeneratingCode = false + + switch result { + case .success(let joinCode): + self.generatedJoinCode = joinCode + case .failure(let error): + print("Error generating join code: \(error)") + + } + } + } + } + + // MARK: - Copy Join Code Function + private func copyJoinCode() { + UIPasteboard.general.string = generatedJoinCode + // You might want to show a toast or feedback that the code was copied } var body: some View { @@ -173,7 +412,7 @@ struct InsideCircle: View { } Spacer().frame(height: 5) HStack { - // Dynamic busy count + if busyCount > 0 { HStack { Image("inclass").resizable().frame(width: 18, height: 18) @@ -188,7 +427,7 @@ struct InsideCircle: View { Spacer().frame(width: 10) } - // Dynamic available count + if availableCount > 0 { HStack { Image("available").resizable().frame(width: 18, height: 18) @@ -203,18 +442,17 @@ struct InsideCircle: View { Spacer() - - Button(action: { - showQRCode = true - print("QR Code tapped") - }) { - Image(systemName: "qrcode") - .foregroundColor(Color("Accent")) - .font(.system(size: 20)) - .padding(8) - .background(Color("Secondary")) - .cornerRadius(8) - } + + DualIconMenu( + onQRCode: { + showQRCode = true + print("QR Code tapped") + }, + onGenerateCode: { + showGenerateJoinCode = true + print("Generate Code tapped") + } + ) } } .padding() @@ -222,18 +460,20 @@ struct InsideCircle: View { if communityPageViewModel.loadingCircleMembers { ProgressView("Loading...") .padding() + .foregroundColor(.white) } else if communityPageViewModel.errorCircleMembers { Text("Failed to load members.") .foregroundColor(.red) + .padding() } else { ScrollView { VStack(spacing: 10) { - ForEach(communityPageViewModel.circleMembers, id: \.username) { member in + ForEach(filteredMembers, id: \.username) { member in InsideCircleRow( picture: member.picture, name: member.name, - status: member.status ?? "free", - venue: member.venue ?? "available" + status: getDisplayStatus(for: member), + venue: getDisplayVenue(for: member) ) .padding(.horizontal) } @@ -243,7 +483,8 @@ struct InsideCircle: View { } Spacer() } - .background(Color("Background").edgesIgnoringSafeArea(.all)).sheet(isPresented: $showGroupRequests, content: { + .background(Color("Background").edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $showGroupRequests, content: { CircleRequestsView() }) .onAppear { @@ -271,33 +512,109 @@ struct InsideCircle: View { }) } + if showDeleteAlert { + DeleteCircleAlert(circleName: "\(circleName)", onCancel: { + showDeleteAlert = false + }, onDelete: { + let url = "\(APIConstants.base_url)circles/\(groupCode)" + let token = authViewModel.loggedInBackendUser?.token ?? "" + + communityPageViewModel.deleteCircle(from: url, token: token) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + showDeleteAlert = false + presentationMode.wrappedValue.dismiss() + } + }) + } + + if showGenerateJoinCode { + GenerateJoinCodeModal( + circleName: circleName, + joinCode: generatedJoinCode, + isLoading: isGeneratingCode, + onGenerate: { + generateJoinCode() + }, + onDismiss: { + showGenerateJoinCode = false + generatedJoinCode = "" + }, + onCopyCode: { + copyJoinCode() + } + ) + } + if showCircleMenu { CircleMenuView( circleName: circleName, onLeaveGroup: { showLeaveAlert = true }, + onDeleteGroup: { + showDeleteAlert = true + }, onGroupRequests: { showGroupRequests = true print("Navigate to Circle Requests") }, + onGenerateJoinCode: { + showGenerateJoinCode = true + }, onCancel: { showCircleMenu = false } ) } + if showQRCode { - QRCodeModalView( - groupCode: groupCode, - circleName: circleName, - onDismiss: { - showQRCode = false - } - ) - } + QRCodeModalView( + groupCode: groupCode, + circleName: circleName, + onDismiss: { + showQRCode = false + } + ) + } } ) .navigationBarHidden(true) .navigationBarBackButtonHidden(true) } + + // MARK: - Helper functions for display + private func getDisplayStatus(for member: CircleUserTemp) -> String { + let status = member.status + + + if let currentStatus = member.currentStatus { + switch currentStatus.status { + case "class": + return "In Class" + case "free": + return "Free" + default: + return currentStatus.status.capitalized + } + } + + + return "Free" + } + + private func getDisplayVenue(for member: CircleUserTemp) -> String { + + if let venue = member.venue, !venue.isEmpty { + return venue + } + + + if let className = member.className, !className.isEmpty { + return className + } + + + return "Available" + } } diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 3655743..d04ac36 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -22,10 +22,13 @@ enum SheetType: Identifiable { } } + + struct ConnectPage: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(FriendRequestViewModel.self) private var friendRequestViewModel + @Environment(RequestsViewModel.self) private var requestsViewModel @State private var isShowingRequestView = false @State var isCircleView = false @State private var activeSheet: SheetType? @@ -69,8 +72,27 @@ struct ConnectPage: View { Button(action: { isShowingRequestView.toggle() }) { - Image(systemName: "person.fill.badge.plus") - .foregroundColor(.white) + ZStack { + + Image(systemName: requestsViewModel.friendRequests.isEmpty ? "person.fill.badge.plus" : "person.fill") + .foregroundColor(.white) + .font(.system(size: 18)) + + + if !requestsViewModel.friendRequests.isEmpty { + ZStack { + Circle() + .fill(Color.red) + .frame(width: 20, height: 20) + + Text("\(min(requestsViewModel.friendRequests.count, 99))") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + } + .offset(x: 12, y: -12) + } + } } .navigationDestination( isPresented: $isShowingRequestView, @@ -115,7 +137,7 @@ struct ConnectPage: View { case .addCircleOptions: AddCircleOptionsView(activeSheet: $activeSheet) case .createGroup: - CreateGroup(groupCode: .constant(""), token:authViewModel.loggedInBackendUser?.token ?? "" ) + CreateGroup(groupCode: .constant(""), token:authViewModel.loggedInBackendUser?.token ?? "",username: authViewModel.loggedInBackendUser?.username ?? "" ) case .joinGroup: JoinGroup(groupCode: .constant("")) case .groupRequests: @@ -123,9 +145,13 @@ struct ConnectPage: View { } } .onAppear { - let shouldShowLoading = !hasLoadedInitialData + + requestsViewModel.fetchFriendRequests( + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) if communityPageViewModel.friends.isEmpty || !hasLoadedInitialData { communityPageViewModel.fetchFriendsData( @@ -155,7 +181,6 @@ struct ConnectPage: View { } } } - struct ConnectCircleMenuView: View { let onCreateGroup: () -> Void let onJoinGroup: () -> Void diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index 75b76d9..f477d6a 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -111,6 +111,7 @@ struct FriendsView: View { } } .refreshable { + communityPageViewModel.fetchFriendsData( from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", token: authViewModel.loggedInBackendUser?.token ?? "", diff --git a/VITTY/VITTY/Connect/View/Freinds/View/FriendCard.swift b/VITTY/VITTY/Connect/View/Freinds/View/FriendCard.swift index 32212be..c546b2a 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/FriendCard.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/FriendCard.swift @@ -46,9 +46,3 @@ struct FriendCard: View { } } -#Preview { - FriendCard( - friend: Friend.sampleFriend - ) - // .background(Color.theme.secondaryBlue) -} diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index e09862e..8ed78a1 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -45,9 +45,13 @@ class CommunityPageViewModel { self.errorFreinds = false + print("This is the token used in the app \(token)") + print("this is the url used for the endpoint \(url)") AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: FriendRaw.self) { response in DispatchQueue.main.async { self.loadingFreinds = false @@ -151,22 +155,53 @@ class CommunityPageViewModel { func acceptCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { self.loadingRequestAction = true + let url = "\(APIConstants.base_url)circles/acceptRequest/\(circleId)" + // Debug logging to see the actual URL being called + logger.info("Attempting to accept circle request with URL: \(url)") + logger.info("Circle ID: \(circleId)") + logger.info("Token: \(token.prefix(10))...") + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() - .response { response in + .responseData { response in // Changed to responseData to get more details DispatchQueue.main.async { self.loadingRequestAction = false switch response.result { - case .success: + case .success(let data): self.logger.info("Successfully accepted circle request for circle: \(circleId)") + + // Log the response for debugging + if let responseString = String(data: data, encoding: .utf8) { + self.logger.info("Response: \(responseString)") + } + + // Remove the accepted request from the list self.circleRequests.removeAll { $0.circle_id == circleId } + + // Refresh circles data to show the newly joined circle + self.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + completion(true) case .failure(let error): self.logger.error("Error accepting circle request: \(error)") + + // Log more details about the error + if let data = response.data, let errorString = String(data: data, encoding: .utf8) { + self.logger.error("Error response: \(errorString)") + } + + if let httpResponse = response.response { + self.logger.error("HTTP Status Code: \(httpResponse.statusCode)") + } + completion(false) } } @@ -292,6 +327,42 @@ class CommunityPageViewModel { } } + //MARK: Delete Circle + + func deleteCircle(from url: String, token: String) { + self.loadingCircleMembers = true + + AF.request(url, method: .delete, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + self.loadingCircleMembers = false + + switch response.result { + case .success(let value): + if let json = value as? [String: Any], let detail = json["detail"] as? String { + self.logger.info("Successfully deleted circle: \(detail)") + } else { + self.logger.info("Successfully deleted circle") + } + + + self.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + + + + case .failure(let error): + self.logger.error("Error deleting circle: \(error)") + self.errorCircleMembers = true + } + } + } + } + // MARK: Helper methods for circle members func circleMembers(for circleID: String) -> [CircleUserTemp] { @@ -310,7 +381,13 @@ class CommunityPageViewModel { // MARK: - Group Creation func createCircle(name: String, token: String, completion: @escaping (Result<String, Error>) -> Void) { - let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name + + guard let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid circle name"]) + completion(.failure(error)) + return + } + let url = "\(APIConstants.base_url)circles/create/\(encodedName)" AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) @@ -320,22 +397,31 @@ class CommunityPageViewModel { switch response.result { case .success(let data): if let json = data as? [String: Any], - let circleId = json["circle_id"] as? String { - self.logger.info("Successfully created circle: \(circleId)") - completion(.success(circleId)) - } else { - - if let json = data as? [String: Any], - let dataDict = json["data"] as? [String: Any], - let circleId = dataDict["id"] as? String { - self.logger.info("Successfully created circle: \(circleId)") - completion(.success(circleId)) + let detail = json["detail"] as? String { + + + if detail.lowercased().contains("successfully") { + self.logger.info("Successfully created circle: \(name)") + + completion(.success(name)) } else { - let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + + let error = NSError(domain: "CreateCircleError", code: 1, userInfo: [NSLocalizedDescriptionKey: detail]) + self.logger.error("Error creating circle: \(detail)") completion(.failure(error)) } + } else { + let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + completion(.failure(error)) } + + self.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + case .failure(let error): self.logger.error("Error creating circle: \(error)") completion(.failure(error)) @@ -345,8 +431,9 @@ class CommunityPageViewModel { } func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { - let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" + let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" + print("this is the endpoint \(url)") AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() .response { response in @@ -402,4 +489,46 @@ class CommunityPageViewModel { fetchCircleRequests(token: token, loading: false) } + + func generateJoinCode(circleId: String, token: String, completion: @escaping (Result<String, Error>) -> Void) { + let url = "\(APIConstants.base_url)circles/\(circleId)/generateJoinCode" + + print("Generating join code for circle: \(circleId)") + print("Request URL: \(url)") + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseJSON { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + if let json = data as? [String: Any] { + if let joinCode = json["joinCode"] as? String { + print("Successfully generated join code: \(joinCode)") + completion(.success(joinCode)) + } else if let detail = json["detail"] as? String { + // Handle error case where detail contains error message + print("Error generating join code: \(detail)") + let error = NSError(domain: "GenerateJoinCodeError", code: 1, userInfo: [NSLocalizedDescriptionKey: detail]) + completion(.failure(error)) + } else { + // Handle unexpected response format + print("Unexpected response format") + let error = NSError(domain: "GenerateJoinCodeError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + completion(.failure(error)) + } + } else { + let error = NSError(domain: "GenerateJoinCodeError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + completion(.failure(error)) + } + + case .failure(let error): + print("Network error generating join code: \(error)") + completion(.failure(error)) + } + } + } + } } + + diff --git a/VITTY/VITTY/Connect/ViewModel/FreindRequestViewModel.swift b/VITTY/VITTY/Connect/ViewModel/FreindRequestViewModel.swift new file mode 100644 index 0000000..ecadfe4 --- /dev/null +++ b/VITTY/VITTY/Connect/ViewModel/FreindRequestViewModel.swift @@ -0,0 +1,200 @@ +// +// FreindRequestModel.swift +// VITTY +// +// Created by Rujin Devkota on 7/4/25. +// + +import Foundation +import SwiftUI +import OSLog + +// MARK: new implementation for freindrequests ,new view model need to optimize the code removing the old one + + +@Observable +class RequestsViewModel { + var friendRequests: [FriendRequest] = [] + var isLoading = false + var errorMessage: String? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: RequestsViewModel.self) + ) + + + func fetchFriendRequests(token: String, loading: Bool = true) { + guard !token.isEmpty else { + logger.error("No token provided") + return + } + + if loading { + isLoading = true + } + errorMessage = nil + + Task { + do { + let urlString = "\(APIConstants.base_url)requests/" + guard let url = URL(string: urlString) else { + logger.error("Invalid URL: \(urlString)") + await MainActor.run { + self.isLoading = false + self.errorMessage = "Invalid URL" + } + return + } + + logger.info("Fetching friend requests from: \(urlString)") + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + logger.info("Response status code: \(httpResponse.statusCode)") + + if httpResponse.statusCode == 200 { + + if let responseString = String(data: data, encoding: .utf8) { + logger.info("Response: \(responseString)") + + if responseString.trimmingCharacters(in: .whitespacesAndNewlines) == "null" || + responseString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + await MainActor.run { + self.friendRequests = [] + self.isLoading = false + } + return + } + } + + let decoder = JSONDecoder() + let requests = try decoder.decode([FriendRequest].self, from: data) + + await MainActor.run { + self.friendRequests = requests + self.isLoading = false + } + + logger.info("Successfully fetched \(requests.count) friend requests") + } else { + let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error" + logger.error("Error fetching friend requests: \(errorResponse)") + await MainActor.run { + self.isLoading = false + self.errorMessage = "Failed to fetch friend requests" + } + } + } + } catch { + logger.error("Failed to fetch friend requests: \(error.localizedDescription)") + await MainActor.run { + self.isLoading = false + self.errorMessage = error.localizedDescription + } + } + } + } + + // MARK: - Accept Friend Request + func acceptFriendRequest(username: String, token: String) async -> Bool { + guard !token.isEmpty else { + logger.error("No token provided") + return false + } + + do { + let urlString = "\(APIConstants.base_url)requests/\(username)/accept/" + guard let url = URL(string: urlString) else { + logger.error("Invalid URL: \(urlString)") + return false + } + + logger.info("Accepting friend request for: \(username)") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + logger.info("Accept request response status: \(httpResponse.statusCode)") + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + logger.info("Friend request accepted successfully") + + + await MainActor.run { + self.friendRequests.removeAll { $0.from.username == username } + } + + return true + } else { + let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error" + logger.error("Failed to accept friend request: \(errorResponse)") + return false + } + } + } catch { + logger.error("Failed to accept friend request: \(error.localizedDescription)") + } + + return false + } + + // MARK: - Decline Friend Request + func declineFriendRequest(username: String, token: String) async -> Bool { + guard !token.isEmpty else { + logger.error("No token provided") + return false + } + + do { + let urlString = "\(APIConstants.base_url)requests/\(username)/decline/" + guard let url = URL(string: urlString) else { + logger.error("Invalid URL: \(urlString)") + return false + } + + logger.info("Declining friend request for: \(username)") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + logger.info("Decline request response status: \(httpResponse.statusCode)") + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + logger.info("Friend request declined successfully") + + + await MainActor.run { + self.friendRequests.removeAll { $0.from.username == username } + } + + return true + } else { + let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error" + logger.error("Failed to decline friend request: \(errorResponse)") + return false + } + } + } catch { + logger.error("Failed to decline friend request: \(error.localizedDescription)") + } + + return false + } +} diff --git a/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift b/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift index 4f4efda..203041c 100644 --- a/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift +++ b/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift @@ -13,7 +13,7 @@ class EmptyClassRoomAPIService { slot: String, authToken: String ) async throws -> [String] { - let url = URL(string: "\(APIConstants.base_url)timetable/emptyClassRooms?slot=\(slot)")! + let url = URL(string: "\(APIConstants.base_url)users/emptyClassRooms?slot=\(slot)")! var request = URLRequest(url: url) request.httpMethod = "GET" print(authToken) @@ -28,10 +28,28 @@ class EmptyClassRoomAPIService { if httpResponse.statusCode != 200 { - let errorMessage = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - let detailMessage = errorMessage?["detail"] as? String ?? "Unknown error" - print("API Error: \(detailMessage)") - throw NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: detailMessage]) + // Try to parse error response + do { + let errorResponse = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + + // Check for the specific "error" field first + if let errorMessage = errorResponse?["error"] as? String { + print("API Error: \(errorMessage)") + throw NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorMessage]) + } + + // Fallback to "detail" field + if let detailMessage = errorResponse?["detail"] as? String { + print("API Error: \(detailMessage)") + throw NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: detailMessage]) + } + } catch { + // If JSON parsing fails, create a generic error message + print("Failed to parse error response") + } + + // Generic error if no specific message found + throw NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Server error (Status: \(httpResponse.statusCode))"]) } let decoder = JSONDecoder() diff --git a/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift b/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift index 5702c81..79a5a25 100644 --- a/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift +++ b/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift @@ -4,18 +4,30 @@ struct EmptyClassRoom: View { @Environment(AuthViewModel.self) private var authViewModel @StateObject private var viewModel = EmptyClassroomViewModel() @State private var selectedSlot: String = "A1" + @State private var searchText: String = "" @Environment(\.dismiss) private var dismiss let slots = ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "A2", "B2", "C2", "D2", "E2", "F2", "G2"] + // Computed property for filtered classrooms + private var filteredClassrooms: [String] { + if searchText.isEmpty { + return viewModel.emptyClassrooms + } else { + return viewModel.emptyClassrooms.filter { room in + room.localizedCaseInsensitiveContains(searchText) + } + } + } + var body: some View { NavigationStack { ZStack { BackgroundView() - VStack { + VStack(spacing: 0) { headerView - EmptyClassSearchBar() + searchBarView slotsScrollView contentView Spacer() @@ -48,6 +60,12 @@ struct EmptyClassRoom: View { Spacer() } .padding(.horizontal) + .padding(.top, 10) + } + + private var searchBarView: some View { + EmptyClassSearchBar(searchText: $searchText) + .padding(.top, 16) } private var slotsScrollView: some View { @@ -64,23 +82,85 @@ struct EmptyClassRoom: View { .padding(.horizontal) .padding(.vertical, 5) } + .padding(.top, 12) } private var contentView: some View { Group { if viewModel.isLoading { - ProgressView("Loading...") - .foregroundColor(.white) - .padding() + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + .tint(.white) + Text("Loading classrooms...") + .foregroundColor(.white.opacity(0.8)) + .font(.subheadline) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() } else if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - .foregroundColor(.red) - .padding() + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.red.opacity(0.8)) + Text("Error") + .font(.headline) + .foregroundColor(.white) + Text(errorMessage) + .foregroundColor(.red.opacity(0.8)) + .multilineTextAlignment(.center) + .font(.subheadline) + Button("Retry") { + Task { + await viewModel.fetchEmptyClassrooms(slot: selectedSlot, authToken: authViewModel.loggedInBackendUser?.token ?? "") + } + } + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.7)) + .cornerRadius(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() } else if viewModel.emptyClassrooms.isEmpty { - Text("No classrooms available for this slot.") + VStack(spacing: 16) { + Image(systemName: "building.2") + .font(.system(size: 40)) + .foregroundColor(.white.opacity(0.6)) + Text("No Classrooms Available") + .font(.headline) + .foregroundColor(.white) + Text("There are no empty classrooms for slot \(selectedSlot) at this time.") + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .font(.subheadline) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else if filteredClassrooms.isEmpty && !searchText.isEmpty { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 40)) + .foregroundColor(.white.opacity(0.6)) + Text("No Results Found") + .font(.headline) + .foregroundColor(.white) + Text("No classrooms match '\(searchText)'") + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .font(.subheadline) + Button("Clear Search") { + searchText = "" + } .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.7)) + .cornerRadius(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() } else { classroomsGrid } @@ -90,11 +170,16 @@ struct EmptyClassRoom: View { private var classroomsGrid: some View { ScrollView { LazyVGrid(columns: gridColumns, spacing: 16) { - ForEach(viewModel.emptyClassrooms, id: \.self) { room in + ForEach(filteredClassrooms, id: \.self) { room in ClassRoomCard(room: room) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) } } .padding() + .animation(.easeInOut(duration: 0.3), value: filteredClassrooms) } } @@ -107,38 +192,76 @@ struct EmptyClassRoom: View { private func handleSlotSelection(_ slot: String) { guard selectedSlot != slot else { return } selectedSlot = slot + searchText = "" // Clear search when changing slots Task { await viewModel.fetchEmptyClassrooms(slot: slot, authToken: authViewModel.loggedInBackendUser?.token ?? "") } } } - struct ClassRoomCard: View { let room: String var body: some View { - VStack { + VStack(spacing: 8) { + Image(systemName: "building.2") + .font(.system(size: 24)) + .foregroundColor(.white.opacity(0.8)) + Text(room) .font(.headline) + .fontWeight(.semibold) .foregroundColor(.white) } .padding() - .frame(maxWidth: .infinity, minHeight: 100) - .background(Color("Secondary")) - .cornerRadius(10) + .frame(maxWidth: .infinity, minHeight: 120) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color("Secondary")) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + ) } } struct EmptyClassSearchBar: View { - @State private var searchText = "" + @Binding var searchText: String + @FocusState private var isSearchFocused: Bool var body: some View { - TextField("Search", text: $searchText) - .padding(10) - .background(Color.secondary.opacity(0.3)) - .cornerRadius(10) - .padding(.horizontal) + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.white.opacity(0.6)) + .font(.system(size: 16)) + + TextField("Search classrooms...", text: $searchText) + .focused($isSearchFocused) + .foregroundColor(.white) + .tint(.white) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + if !searchText.isEmpty { + Button(action: { + searchText = "" + isSearchFocused = false + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.white.opacity(0.6)) + .font(.system(size: 16)) + } + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.white.opacity(0.15)) + .stroke(isSearchFocused ? Color.blue.opacity(0.5) : Color.clear, lineWidth: 1) + ) + .padding(.horizontal) + .animation(.easeInOut(duration: 0.2), value: searchText.isEmpty) + .animation(.easeInOut(duration: 0.2), value: isSearchFocused) } } @@ -150,11 +273,18 @@ struct SlotFilterButton: View { var body: some View { Button(action: action) { Text(title) + .font(.subheadline) + .fontWeight(isSelected ? .semibold : .medium) .foregroundColor(.white) - .padding(.vertical, 8) + .padding(.vertical, 10) .padding(.horizontal, 16) - .background(isSelected ? Color.blue.opacity(0.7) : Color("Secondary")) - .cornerRadius(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.blue.opacity(0.7) : Color("Secondary")) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + ) + .scaleEffect(isSelected ? 1.05 : 1.0) } + .animation(.easeInOut(duration: 0.2), value: isSelected) } } diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 2b15a06..8c48c1a 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI struct HomeView: View { @@ -5,6 +6,7 @@ struct HomeView: View { @State private var selectedPage = 1 @State private var showProfileSidebar: Bool = false @State private var isCreatingGroup = false + @StateObject private var tipManager = CustomTipManager() var body: some View { NavigationStack { @@ -12,87 +14,131 @@ struct HomeView: View { BackgroundView() VStack(spacing: 0) { - // Top Bar - HStack { - Text( - selectedPage == 3 ? "Academics" : - selectedPage == 2 ? "Connects" : - "Schedule" - ) - .font(Font.custom("Poppins-Bold", size: 26)) - - Spacer() - - if selectedPage != 2 { - ZStack { - if !showProfileSidebar { - Button { - withAnimation(.easeInOut(duration: 0.8 - - )) { - showProfileSidebar = true - } - } label: { - UserImage( - url: authViewModel.loggedInBackendUser?.picture ?? "", - height: 30, - width: 40 - ) - .transition(.scale.combined(with: .opacity)) - } - } - } - } - } - .padding(.horizontal) - .padding(.top, 20) - .padding(.bottom, 8) - - // Main Content - ZStack { - switch selectedPage { - case 1: - TimeTableView(friend: nil,isFriendsTimeTable: false) - case 2: - ConnectPage(isCreatingGroup: $isCreatingGroup) - case 3: - Academics() - default: - Text("Error") - } - } - .padding(.top, 4) - + + topBar + + + mainContent + Spacer() - // Bottom Navigation Bar BottomBarView(presentTab: $selectedPage) .padding(.bottom, 24) } - if showProfileSidebar { - - Color.black.opacity(0.3) - .ignoresSafeArea() - .transition(.opacity) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.8)) { - showProfileSidebar = false - } - } + profileSidebar + + + CustomTipOverlay(tipManager: tipManager, selectedTab: $selectedPage) + } + .ignoresSafeArea(edges: .bottom) + .onAppear { + setupOnboarding() + } + .onChange(of: selectedPage) { _, newValue in + handleTabChange(newValue) + } + } + } + + // MARK: - Top Bar + private var topBar: some View { + HStack { + Text(pageTitle) + .font(Font.custom("Poppins-Bold", size: 26)) + + Spacer() - // Sidebar - HStack { - Spacer() - UserProfileSidebar(isPresented: $showProfileSidebar) - .frame(width: UIScreen.main.bounds.width * 0.75) - .transition(.move(edge: .trailing)) + if selectedPage != 2 { + profileButton + } + } + .padding(.horizontal) + .padding(.top, 20) + .padding(.bottom, 8) + } + + // MARK: - Page Title + private var pageTitle: String { + switch selectedPage { + case 3: return "Academics" + case 2: return "Connects" + default: return "Schedule" + } + } + + // MARK: - Profile Button + private var profileButton: some View { + ZStack { + if !showProfileSidebar { + Button { + withAnimation(.easeInOut(duration: 0.8)) { + showProfileSidebar = true } + } label: { + UserImage( + url: authViewModel.loggedInBackendUser?.picture ?? "", + height: 30, + width: 40 + ) + .transition(.scale.combined(with: .opacity)) } } - .ignoresSafeArea(edges: .bottom) } } -} + + // MARK: - Main Content + private var mainContent: some View { + ZStack { + switch selectedPage { + case 1: + TimeTableView(friend: nil, isFriendsTimeTable: false) + case 2: + ConnectPage(isCreatingGroup: $isCreatingGroup) + case 3: + Academics() + default: + Text("Error") + } + } + .padding(.top, 4) + } + + // MARK: - Profile Sidebar + @ViewBuilder + private var profileSidebar: some View { + if showProfileSidebar { + Color.black.opacity(0.3) + .ignoresSafeArea() + .transition(.opacity) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.8)) { + showProfileSidebar = false + } + } + HStack { + Spacer() + UserProfileSidebar(isPresented: $showProfileSidebar) + .frame(width: UIScreen.main.bounds.width * 0.75) + .transition(.move(edge: .trailing)) + } + } + } + + // MARK: - Setup Functions + private func setupOnboarding() { + // Start onboarding if not completed + if !tipManager.hasCompletedOnboarding { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + tipManager.startOnboarding() + } + } + } + + private func handleTabChange(_ newTab: Int) { + + print("Switched to tab: \(newTab)") + } +} diff --git a/VITTY/VITTY/Home/View/ToolTip.swift b/VITTY/VITTY/Home/View/ToolTip.swift new file mode 100644 index 0000000..b1145a2 --- /dev/null +++ b/VITTY/VITTY/Home/View/ToolTip.swift @@ -0,0 +1,237 @@ +// +// ToolTip.swift +// VITTY +// +// Created by Rujin Devkota on 7/1/25. +// + +// MARK: - Tips Definition +import SwiftUI + +struct CustomTip { + let id: Int + let title: String + let message: String + let targetTab: Int + let isLast: Bool +} + +// MARK: - Custom Tip Manager +class CustomTipManager: ObservableObject { + @Published var currentTipIndex = 0 + @Published var showTips = false + @Published var hasCompletedOnboarding = false + + private var hasSeenOnboardingKey: String { "hasSeenOnboarding" } + + let tips: [CustomTip] = [ + CustomTip( + id: 1, + title: "Navigation Bar", + message: "This is your main dashboard, where you can access everything in one place — your courses and reminders in Academics, your timetable in Schedule, and your friends, groups, and rooms in Connect.", + targetTab: 1, + isLast: false + ), + CustomTip( + id: 2, + title: "Academics — Track Your Coursework", + message: "Academics keeps you organized with your courses and shows reminders for upcoming assignments, quizzes, and deadlines.", + targetTab: 3, + isLast: false + ), + CustomTip( + id: 3, + title: "Schedule — View Your Timetable", + message: "Schedule gives you a clear view of your classes, helping you plan your day or week with ease.", + targetTab: 1, + isLast: false + ), + CustomTip( + id: 4, + title: "Connect — Collaborate with Peers", + message: "Connect lets you see friends, manage groups, and join or create rooms to collaborate and stay connected.", + targetTab: 2, + isLast: true + ) + ] + + init() { + checkOnboardingStatus() + } + + var currentTip: CustomTip? { + guard currentTipIndex < tips.count else { return nil } + return tips[currentTipIndex] + } + + func checkOnboardingStatus() { + hasCompletedOnboarding = UserDefaults.standard.bool(forKey: hasSeenOnboardingKey) + } + + func startOnboarding() { + guard !hasCompletedOnboarding else { return } + currentTipIndex = 0 + showTips = true + } + + func nextTip() -> Int? { + if currentTipIndex < tips.count - 1 { + currentTipIndex += 1 + return tips[currentTipIndex].targetTab + } + return nil + } + + func finishOnboarding() { + showTips = false + currentTipIndex = 0 + saveOnboardingCompletion() + } + + private func saveOnboardingCompletion() { + UserDefaults.standard.set(true, forKey: hasSeenOnboardingKey) + hasCompletedOnboarding = true + } + + // MARK: - Debug/Testing Functions + func resetOnboarding() { + UserDefaults.standard.removeObject(forKey: hasSeenOnboardingKey) + hasCompletedOnboarding = false + currentTipIndex = 0 + showTips = false + } +} + +struct VisualEffectBlur: UIViewRepresentable { + var effect: UIBlurEffect.Style + + func makeUIView(context: Context) -> UIVisualEffectView { + return UIVisualEffectView(effect: UIBlurEffect(style: effect)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: effect) + } +} + +// MARK: - Custom Tip Overlay View +struct CustomTipOverlay: View { + @ObservedObject var tipManager: CustomTipManager + @Binding var selectedTab: Int + + var body: some View { + if tipManager.showTips, let tip = tipManager.currentTip { + ZStack { + Color.black + .opacity(0.4) + .ignoresSafeArea() + .blur(radius: 1.5) + + VStack { + Spacer() + + VStack(spacing: 0) { + VStack(spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text(tip.message) + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white.opacity(0.9)) + .lineLimit(nil) + .multilineTextAlignment(.leading) + } + + Spacer() + + Button { + handleTipAction() + } label: { + Image(systemName: "xmark") + .foregroundColor(.white.opacity(0.7)) + .font(.system(size: 16)) + } + } + + HStack { + Spacer() + + Button { + handleContinueAction() + } label: { + Text(tip.isLast ? "Finish" : "Continue") + .font(.custom("Poppins-Medium", size: 12)) + .foregroundColor(.black) + .padding(.horizontal, 15) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color.white) + ) + } + } + } + .padding(20) + .background( + UnevenRoundedRectangle( + topLeadingRadius: 16, + bottomLeadingRadius: 0, + bottomTrailingRadius: 0, + topTrailingRadius: 16 + ) + .fill(Color("Background")) + ) + + + HStack { + HStack(spacing: 8) { + Text(tip.title) + .font(.custom("Poppins-Medium", size: 14)) + + Spacer() + + Text("\(tip.id)/\(tipManager.tips.count)") + .font(.custom("Poppins-Regular", size: 14)) + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .background( + UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 16, + bottomTrailingRadius: 16, + topTrailingRadius: 0 + ) + .fill(Color.white) + ) + .foregroundStyle(Color.black) + } + } + .padding(.horizontal, 20) + + Spacer() + .frame(height: 100) + } + } + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: tipManager.showTips) + } + } + + private func handleTipAction() { + withAnimation { + tipManager.finishOnboarding() + } + } + + private func handleContinueAction() { + withAnimation { + if tipManager.currentTip?.isLast == true { + tipManager.finishOnboarding() + } else { + if let nextTab = tipManager.nextTip() { + selectedTab = nextTab + } + } + } + } +} diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift index 64bf1f7..ae92f18 100644 --- a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -169,6 +169,7 @@ class SettingsViewModel : ObservableObject{ private func formatTime(time: String) -> String { var timeComponents = time.components(separatedBy: "T").last ?? "" timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm:ss" if let date = dateFormatter.date(from: timeComponents) { diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 1e3dafb..2f8ca1f 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -10,8 +10,15 @@ import Foundation class Constants { static let url = - "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" +// "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + + "http://localhost:80/api/v2/" + +// "https://f4df-2409-40e3-30a4-8539-6d49-631b-ddd8-60a3.ngrok-free.app/api/v2/" + +// "https://c6eb-2409-40e3-1fc-541e-dd7b-b7a5-32c0-c3c8.ngrok-free.app/api/v2/" // "https://vitty-api.dscvit.com/api/v2/" + } diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index 4c933d3..da1201d 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -272,6 +272,7 @@ extension TimeTable { private func formatTime(time: String) -> String { var timeComponents = time.components(separatedBy: "T").last ?? "" timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm:ss" diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index b66bc69..e86dcac 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -5,6 +5,7 @@ // Created by Chandram Dutta on 09/02/24. // + import Foundation import OSLog import SwiftData @@ -15,7 +16,6 @@ public enum Stage { case data } - extension TimeTableView { @Observable class TimeTableViewModel { @@ -27,6 +27,7 @@ extension TimeTableView { private var hasSyncedThisSession = false private var isSyncing = false + private var currentContext: ModelContext? private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, @@ -65,6 +66,9 @@ extension TimeTableView { ) async { logger.info("Starting timetable loading process") + // Store context for later use + currentContext = context + if let existing = existingTimeTable { logger.debug("Using existing local timetable") timeTable = existing @@ -72,15 +76,16 @@ extension TimeTableView { stage = .data print("\(existing)") + // Start background sync if not already done if !hasSyncedThisSession && !isSyncing { Task { await backgroundSync( localTimeTable: existing, username: username, - authToken: authToken + authToken: authToken, + context: context ) } - } } else { logger.debug("No local timetable, fetching from API") @@ -95,7 +100,8 @@ extension TimeTableView { private func backgroundSync( localTimeTable: TimeTable, username: String, - authToken: String + authToken: String, + context: ModelContext ) async { guard !isSyncing else { return } @@ -114,7 +120,11 @@ extension TimeTableView { if shouldUpdateLocalTimeTable(local: localTimeTable, remote: remoteTimeTable) { logger.info("Background sync: Timetables differ, updating local data") - await updateLocalTimeTable(newTimeTable: remoteTimeTable) + await updateLocalTimeTableWithPersistence( + oldTimeTable: localTimeTable, + newTimeTable: remoteTimeTable, + context: context + ) } else { logger.info("Background sync: Timetables are identical, no update needed") } @@ -133,6 +143,7 @@ extension TimeTableView { (local.wednesday, remote.wednesday), (local.thursday, remote.thursday), (local.friday, remote.friday), + (local.saturday, remote.saturday), (local.sunday, remote.sunday) ] @@ -170,13 +181,36 @@ extension TimeTableView { local.endTime == remote.endTime } - - @MainActor - private func updateLocalTimeTable(newTimeTable: TimeTable) async { - timeTable = newTimeTable - changeDay() - logger.info("Timetable updated in memory, view will handle persistence") + private func updateLocalTimeTableWithPersistence( + oldTimeTable: TimeTable, + newTimeTable: TimeTable, + context: ModelContext + ) async { + logger.info("Updating local timetable with persistence") + + do { + // Delete the old timetable from persistent storage + context.delete(oldTimeTable) + + // Insert the new timetable + context.insert(newTimeTable) + + // Save the context to persist changes + try context.save() + + // Update the in-memory reference + timeTable = newTimeTable + changeDay() + + logger.info("Local timetable successfully updated and persisted") + + } catch { + logger.error("Failed to update local timetable: \(error)") + // Rollback: if save fails, re-insert the old timetable + context.insert(oldTimeTable) + try? context.save() + } } @MainActor @@ -201,6 +235,7 @@ extension TimeTableView { stage = .data context.insert(data) + try context.save() hasSyncedThisSession = true } catch { @@ -219,5 +254,3 @@ extension TimeTableView { } } } - - diff --git a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift index 8f16f2a..989713c 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift @@ -92,6 +92,7 @@ struct LectureDetailView: View { private func formatTime(time: String) -> String { var timeComponents = time.components(separatedBy: "T").last ?? "" timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm:ss" diff --git a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift index 206f806..83171b2 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift @@ -9,8 +9,48 @@ import SwiftUI struct LectureItemView: View { let lecture: Lecture + let selectedDayIndex: Int + let allLectures: [Lecture] var onTap: () -> Void + @State private var currentTime = Date() + @State private var timer: Timer? + + private var currentDayIndex: Int { + let calendar = Calendar.current + let today = calendar.component(.weekday, from: currentTime) + + switch today { + case 2: return 0 + case 3: return 1 + case 4: return 2 + case 5: return 3 + case 6: return 4 + case 7: return 5 + case 1: return 6 + default: return 0 + } + } + + private var isCurrentClass: Bool { + let calendar = Calendar.current + + guard selectedDayIndex == currentDayIndex else { + return false + } + + let currentHour = calendar.component(.hour, from: currentTime) + let currentMinute = calendar.component(.minute, from: currentTime) + let currentTimeInMinutes = currentHour * 60 + currentMinute + + guard let startTime = parseTime(lecture.startTime), + let endTime = parseTime(lecture.endTime) else { + return false + } + + return currentTimeInMinutes >= startTime && currentTimeInMinutes < endTime + } + var body: some View { VStack(alignment: .leading, spacing: 8) { Text(lecture.name) @@ -26,7 +66,6 @@ struct LectureItemView: View { Spacer() - if !lecture.venue.isEmpty { Button(action: onTap) { HStack { @@ -51,29 +90,176 @@ struct LectureItemView: View { .padding(.horizontal, 16) .padding(.bottom, 16) } - .frame(maxWidth:.infinity).frame(height: 128) + .frame(maxWidth: .infinity) + .frame(height: 128) .background( RoundedRectangle(cornerRadius: 16) .fill(Color("Secondary").opacity(0.9)) ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color("Accent"), lineWidth: isCurrentClass ? 1 : 0) + ) + .animation(.easeInOut(duration: 0.3), value: isCurrentClass) + .onAppear { + startSmartTimer() + } + .onDisappear { + stopTimer() + } } - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return (formattedTime) + // MARK: - Smart Timer Implementation + + private func startSmartTimer() { + currentTime = Date() + scheduleNextUpdate() + } + + private func scheduleNextUpdate() { + timer?.invalidate() + + guard let nextUpdateTime = calculateNextUpdateTime() else { + // No more updates needed today, schedule for tomorrow + scheduleEndOfDayUpdate() + return + } + + let timeInterval = nextUpdateTime.timeIntervalSince(currentTime) + + // Ensure we don't schedule negative or zero intervals + let safeInterval = max(timeInterval, 1.0) + + timer = Timer.scheduledTimer(withTimeInterval: safeInterval, repeats: false) { _ in + currentTime = Date() + scheduleNextUpdate() // Schedule the next update + } + } + + private func calculateNextUpdateTime() -> Date? { + let calendar = Calendar.current + let now = currentTime + + // Only calculate for current day + guard selectedDayIndex == currentDayIndex else { + return nil + } + + // Get all relevant times for today + var relevantTimes: [Date] = [] + + // Add start and end times for all lectures today + for lecture in allLectures { + if let startTime = parseTimeToDate(lecture.startTime) { + relevantTimes.append(startTime) } - else { - return ("Failed to parse the time string.") + + if let endTime = parseTimeToDate(lecture.endTime) { + // Add 10 minutes after end time for final update + let tenMinutesAfter = calendar.date(byAdding: .minute, value: 10, to: endTime) + if let finalTime = tenMinutesAfter { + relevantTimes.append(finalTime) + } } } -} - - + + // Sort times and find the next one after current time + let sortedTimes = relevantTimes.sorted() + + for time in sortedTimes { + if time > now { + return time + } + } + + return nil // No more updates needed today + } + + private func scheduleEndOfDayUpdate() { + // Schedule update for next day at midnight + 1 minute + let calendar = Calendar.current + let tomorrow = calendar.date(byAdding: .day, value: 1, to: currentTime)! + let nextMidnight = calendar.startOfDay(for: tomorrow) + let nextUpdate = calendar.date(byAdding: .minute, value: 1, to: nextMidnight)! + + let timeInterval = nextUpdate.timeIntervalSince(currentTime) + + if timeInterval > 0 { + timer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { _ in + currentTime = Date() + scheduleNextUpdate() + } + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + // MARK: - Helper Functions + + private func parseTime(_ timeString: String) -> Int? { + var timeComponents = timeString.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let hour = calendar.component(.hour, from: date) + let minute = calendar.component(.minute, from: date) + return hour * 60 + minute + } + + return nil + } + + private func parseTimeToDate(_ timeString: String) -> Date? { + var timeComponents = timeString.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + + if let time = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let now = Date() + + // Combine today's date with the parsed time + let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: time) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComponents.hour + combinedComponents.minute = timeComponents.minute + combinedComponents.second = timeComponents.second + + return calendar.date(from: combinedComponents) + } + + return nil + } + + private func formatTime(time: String) -> String { + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return formattedTime + } else { + return "Failed to parse the time string." + } + } +} diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 7fa9a78..2795344 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -96,9 +96,15 @@ struct TimeTableView: View { ScrollView { VStack(spacing: 12) { ForEach(viewModel.lectures.sorted()) { lecture in - LectureItemView(lecture: lecture) { + + LectureItemView( + lecture: lecture, + selectedDayIndex: viewModel.dayNo, + allLectures: viewModel.lectures + ) { selectedLecture = lecture } + } } .padding(.horizontal) @@ -136,5 +142,6 @@ struct TimeTableView: View { context: context ) } + print("this is users token is \(authViewModel.loggedInBackendUser?.token ?? "")") } } diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index a362897..f6fa929 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -1,4 +1,6 @@ import SwiftUI +import OSLog +import SwiftData @@ -8,6 +10,7 @@ struct UserProfileSidebar: View { @Binding var isPresented: Bool @State private var ghostMode: Bool = false @State private var isUpdatingGhostMode: Bool = false + @Environment(\.modelContext) private var modelContext var body: some View { ZStack(alignment: .topTrailing) { @@ -90,6 +93,15 @@ struct UserProfileSidebar: View { Button { authViewModel.signOut() + do{ + try modelContext.delete(model:TimeTable.self) + try modelContext.delete(model:Remainder.self) + try modelContext.delete(model:CreateNoteModel.self) + try modelContext.delete(model:UploadedFile.self) + try modelContext.save() + }catch{ + print("Failed to load data") + } } label: { HStack { Image(systemName: "rectangle.portrait.and.arrow.right") diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 8236c5a..f55add2 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -5,10 +5,12 @@ // Created by Prashanna Rajbhandari on 09/09/2023. // + import Foundation + struct APIConstants { - static let base_url = "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + static let base_url = "http://localhost:80/api/v2/" static let createCircle = "circles/create/" static let sendRequest = "circles/sendRequest/" static let acceptRequest = "circles/acceptRequest/" diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index af10f0d..cbfd107 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -9,6 +9,7 @@ import Firebase import OSLog import SwiftUI import SwiftData +import TipKit /** `NOTE FOR FUTURE/NEW DEVS:` @@ -38,47 +39,131 @@ import SwiftData - use // MARK: <title> when u create a function, it helps to navigate. */ - - - /// Empty classrooms testing /// empty sheet in reaminder view /// @main struct VITTYApp: App { - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: VITTYApp.self - ) - ) + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String( + describing: VITTYApp.self + ) + ) - init() { - setupFirebase() + @State private var deepLinkURL: URL? + @State private var showJoinCircleAlert = false + @State private var pendingCircleInvite: CircleInvite? + + init() { + setupFirebase() NotificationManager.shared.requestAuthorization() - } - - var body: some Scene { - WindowGroup { - ContentView() - .preferredColorScheme(.dark) - }.modelContainer(sharedModelContainer) - } + } + + var body: some Scene { + WindowGroup { + ContentView() + .preferredColorScheme(.dark) + .task { + try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)]) + } + .onOpenURL { url in + handleDeepLink(url) + } + .alert("Join Circle", isPresented: $showJoinCircleAlert) { + Button("Cancel", role: .cancel) { + pendingCircleInvite = nil + } + Button("Join") { + if let invite = pendingCircleInvite { + handleCircleInvite(invite) + } + } + } message: { + if let invite = pendingCircleInvite { + Text("Do you want to join '\(invite.circleName)'?") + } + } + } + .modelContainer(sharedModelContainer) + } + var sharedModelContainer: ModelContainer { - let schema = Schema([TimeTable.self,Remainder.self,CreateNoteModel.self,UploadedFile.self]) - let config = ModelConfiguration( - "group.com.gdscvit.vittyioswidget" + let schema = Schema([TimeTable.self, Remainder.self, CreateNoteModel.self, UploadedFile.self]) + let config = ModelConfiguration( + "group.com.gdscvit.vittyioswidget" + ) + return try! ModelContainer(for: schema, configurations: config) + } +} + + +extension VITTYApp { + + struct CircleInvite { + let circleId: String + let circleName: String + } + + private func handleDeepLink(_ url: URL) { + logger.info("Deep link received: \(url.absoluteString)") - ) - return try! ModelContainer(for: schema, configurations: config) + + if url.absoluteString.contains("vitty.app/invite") || + url.absoluteString.contains("circleId=") { + handleCircleInviteURL(url) + } else { + + logger.info("Unhandled deep link type: \(url.absoluteString)") } + } + + private func handleCircleInviteURL(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + logger.error("Failed to parse URL components") + return + } + + + guard let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value else { + logger.error("No circleId found in URL") + return + } + + + let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value ?? "Unknown Circle" + + + pendingCircleInvite = CircleInvite(circleId: circleId, circleName: circleName) + showJoinCircleAlert = true + + logger.info("Circle invite prepared: \(circleId) - \(circleName)") + } + + private func handleCircleInvite(_ invite: CircleInvite) { + + NotificationCenter.default.post( + name: Notification.Name("JoinCircleFromDeepLink"), + object: nil, + userInfo: [ + "circleId": invite.circleId, + "circleName": invite.circleName + ] + ) + + + pendingCircleInvite = nil + + logger.info("Circle invite notification posted for: \(invite.circleId)") + } } + extension VITTYApp { - private func setupFirebase() { - self.logger.info("Configuring Firebase Started") - FirebaseApp.configure() - self.logger.info("Configuring Firebase Ended") - } + private func setupFirebase() { + self.logger.info("Configuring Firebase Started") + FirebaseApp.configure() + self.logger.info("Configuring Firebase Ended") + } } diff --git a/VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/Contents.json b/VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/Contents.json new file mode 100644 index 0000000..9ab0810 --- /dev/null +++ b/VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "classellipse.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/classellipse.png b/VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/classellipse.png new file mode 100644 index 0000000000000000000000000000000000000000..4561de5e85a3868518fdc78fe0daa472f0da39f1 GIT binary patch literal 375 zcmeAS@N?(olHy`uVBq!ia0vp^AT~D#8<2F%laT;YoCO|{#XvD(5N2eUH3uZfSRCZ; z#IWw1%u67LCEd~2k%3`jKlh(RR-hb@r;B4q1!L~S{oYQ2BFFbX-|6Y;e7bp2l+>(y zaxJ`^ju)=PI97X_e1EK8klk6-J8Ka~NdWs%&l15St^pMae2bd9t8AXXsy%#p^PKm4 zpBJBB&)xFo{`0#&)AxR`jhy)P=R}V3GdChwqIj10$5in-G!{L2w5qy}eepwv+rQb( z+~Xc3pZZaw`lX%ez5e%%kW*^AUgkY~`1SG+PUV*ZkHeVl^H(x#n)!8M)RBWrm5=&9 zK6-zB!T${N*Tn}vh`d?dx9<3~123Y^^xY|MS+~k=zo%Yfd%E~B|K%DJd}PGu8bAHN zFL08W$?ozg90v?Hq+d%`Kh8BdHcmapi=F$rd?$l{(Z*y3w_GQWxx21h`pi>zI(pHs T5Q&|@aANRu^>bP0l+XkKQMR1E literal 0 HcmV?d00001 diff --git a/VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/Contents.json b/VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/Contents.json new file mode 100644 index 0000000..c9ed68a --- /dev/null +++ b/VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "currentellipse.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/currentellipse.png b/VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/currentellipse.png new file mode 100644 index 0000000000000000000000000000000000000000..61601a5e497ff83a2fc1b40a6845ca0119899e32 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4aTa()7Bet#3xhBt!>l<HKtaah zAa^H*b?0PW0y!+{j=qiz3>*8o|0J>k`JJ9Fjv*C{trHIN9x@Pde(Q5!YqWur<pHS; zp0hj`WH(GOVv2rpilyd{;@;4|b{2u3zC6ue^!j4Xm5H8j&ThE8SIS^M+r2;Y^UkOJ zWcG=;ULq4wkzXcQdvqJ0=LN})MpMr^^;D>D7r0X7`ORs~tQ1wb4$I81yI5CmdlXw# m`NFi4`LX_Y`=FQ2A6PxLv<(=excz~SV(@hJb6Mw<&;$UiA6NMR literal 0 HcmV?d00001 diff --git a/VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/Contents.json b/VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/Contents.json new file mode 100644 index 0000000..4f54ba9 --- /dev/null +++ b/VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "allclassesline.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/allclassesline.png b/VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/allclassesline.png new file mode 100644 index 0000000000000000000000000000000000000000..1a75638ce35a7646f99f774f8dabebaa7ba836b0 GIT binary patch literal 588 zcmeAS@N?(olHy`uVBq!ia0vp^(hLlYTpVmb)?-b*`#_4bz$3Dlfq`2Xgc%uT&5-~K zG8PB9J29*~C-V}>VM%xNb!1@J*w6hZkrgQS(9^{+q=ND7O<O-EM~Pz}%fr@~9Gey$ zk=@K1H6bALiq~Uzef0!OMs@?owMUK|JLG0?lPx>nF)_JkqoLKg1MkdsL<+_E9Qi+) zf!X>Y`)=dgHg1nfer<aa_v+Y*uC1Hi9GfV*^Nz+W?!c~({`|Gk|Ajb>pS3()l-a>n zc7Hp&pUwH1+ut4VQ1f}P;IHActFDTT)l-VncfQu(;=Cii_sKaaJqgw`r!Gr;RtoH} z@7<|=HlH!jLty<Tp9{I2ZP((APb%DFs!N^rtf!_X>g7-Qg&Ut3C$G)@-R`5<_$TQA zR*=T;?cK6OvV`@}ENy{jsmo>^yuEo@$@=JK=G9YmZ?0Y{JJ-uN!eiFK)oISV-iB2j z=oc58zN9MXbN*hblP#XJ4<6q=dt&q4AMyX*rft|$Y*AFOcGFxQ<v<U|HrLpb@w2~g z4}W=d`Q|;<Dpwz0J{YlDQbwnC`*Ej}E81&~cSM>j)M!Z);K2rR7G0QsasJbUFX>zh z?SnbXYYnh~4L1*Kyw-SKWN^x=HQ?IHmfdy|SiueZ!(8??-*kdcnLo*sH?Hr?vIIsx NgQu&X%Q~loCIC>7@>>7^ literal 0 HcmV?d00001 diff --git a/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift b/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift index 9aeb2f1..c2935b8 100644 --- a/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift +++ b/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift @@ -22,6 +22,8 @@ struct VittyWidgetEntryView: View { ScheduleSmallWidgetView(entry: entry) case .systemMedium: ScheduleMediumWidgetView(entry: entry) + case .systemLarge: + ScheduleLargeWidgetView(entry: entry) default: Text("Unsupported size") @@ -42,6 +44,6 @@ struct VittyWidget: Widget { } .configurationDisplayName("Vitty Widget") .description("Widget with different designs based on size.") - .supportedFamilies([.systemSmall, .systemMedium]) + .supportedFamilies([.systemSmall, .systemMedium,.systemLarge]) } } diff --git a/VITTY/VittyWidget/Control/VittyWidgetControl.swift b/VITTY/VittyWidget/Control/VittyWidgetControl.swift index f094835..e3f8b99 100644 --- a/VITTY/VittyWidget/Control/VittyWidgetControl.swift +++ b/VITTY/VittyWidget/Control/VittyWidgetControl.swift @@ -42,7 +42,7 @@ extension VittyWidgetControl { } func currentValue(configuration: TimerConfiguration) async throws -> Value { - let isRunning = true // Check if the timer is running + let isRunning = true return VittyWidgetControl.Value(isRunning: isRunning, name: configuration.timerName) } } @@ -71,7 +71,7 @@ struct StartTimerIntent: SetValueIntent { } func perform() async throws -> some IntentResult { - // Start the timer… + return .result() } } diff --git a/VITTY/VittyWidget/Providers/ScheduleProvider.swift b/VITTY/VittyWidget/Providers/ScheduleProvider.swift index 76183e9..bae6de7 100644 --- a/VITTY/VittyWidget/Providers/ScheduleProvider.swift +++ b/VITTY/VittyWidget/Providers/ScheduleProvider.swift @@ -4,6 +4,8 @@ // // Created by Rujin Devkota on 6/12/25. // +// + import SwiftUI import SwiftData import WidgetKit @@ -11,108 +13,207 @@ import WidgetKit struct Provider: TimelineProvider { private func getSharedContainer() -> ModelContainer? { - let appGroupContainerID = "group.com.gdscvit.vittyioswidget" - let config = ModelConfiguration( - appGroupContainerID) + let appGroupContainerID = "group.com.gdscvit.vittyioswidget" + let config = ModelConfiguration(appGroupContainerID) - return try? ModelContainer(for: TimeTable.self, configurations: config) - } + return try? ModelContainer(for: TimeTable.self, configurations: config) + } + + // MARK: - Time Parsing and Validation private func parseTimeString(_ timeString: String) -> Date? { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - - // Clean the time string (remove extra spaces, etc.) - let cleanedTime = timeString.trimmingCharacters(in: .whitespacesAndNewlines) - - if let time = formatter.date(from: cleanedTime) { - // Combine with today's date - let calendar = Calendar.current - let now = Date() - let timeComponents = calendar.dateComponents([.hour, .minute], from: time) - return calendar.date(bySettingHour: timeComponents.hour ?? 0, - minute: timeComponents.minute ?? 0, - second: 0, - of: now) - } - return nil - } - - private func fetchTodaysLectures() -> [Classes] { - guard let container = getSharedContainer() else { return [] } - let context = ModelContext(container) - - // Get current day let formatter = DateFormatter() formatter.dateFormat = "h:mm a" - _ = formatter.string(from: Date()) + formatter.locale = Locale(identifier: "en_US_POSIX") + + let cleanedTime = timeString.trimmingCharacters(in: .whitespacesAndNewlines) + + if let time = formatter.date(from: cleanedTime) { + let calendar = Calendar.current + let now = Date() + let timeComponents = calendar.dateComponents([.hour, .minute], from: time) + return calendar.date(bySettingHour: timeComponents.hour ?? 0, + minute: timeComponents.minute ?? 0, + second: 0, + of: now) + } + return nil + } + + private func parseClassTime(_ timeRange: String) -> (start: Date?, end: Date?) { + let components = timeRange.components(separatedBy: " - ") + guard components.count == 2 else { return (nil, nil) } + + let startTime = parseTimeString(components[0]) + let endTime = parseTimeString(components[1]) + + return (startTime, endTime) + } + + // MARK: - Class Status Determination + + private enum ClassStatus { + case upcoming + case current + case completed + } + + private func getClassStatus(_ classItem: Classes, at currentTime: Date = Date()) -> ClassStatus { + let (startTime, endTime) = parseClassTime(classItem.time) + + guard let start = startTime, let end = endTime else { + return .upcoming + } + + if currentTime < start { + return .upcoming + } else if currentTime >= start && currentTime <= end { + return .current + } else { + return .completed + } + } + + // MARK: - Data Fetching Methods + + private func fetchAllTodaysClasses() -> [Classes] { + guard let container = getSharedContainer() else { return [] } + let context = ModelContext(container) - // Fetch timetable let descriptor = FetchDescriptor<TimeTable>() guard let timetable = try? context.fetch(descriptor).first else { return [] } - return timetable.classesFor(date: Date()) + + return timetable.classesFor(date: Date()) + } + + private func fetchUpcomingClasses() -> [Classes] { + let allClasses = fetchAllTodaysClasses() + let currentTime = Date() + + return allClasses.filter { classItem in + getClassStatus(classItem, at: currentTime) == .upcoming + } + } + + private func fetchCurrentClass() -> Classes? { + let allClasses = fetchAllTodaysClasses() + let currentTime = Date() + + return allClasses.first { classItem in + getClassStatus(classItem, at: currentTime) == .current + } + } + + private func calculateCompletedClassesCount() -> Int { + let allClasses = fetchAllTodaysClasses() + let currentTime = Date() + + return allClasses.filter { classItem in + getClassStatus(classItem, at: currentTime) == .completed + }.count + } + + // MARK: - Widget Content Preparation + + private func prepareWidgetContent() -> (classes: [Classes], total: Int, completed: Int) { + let allClasses = fetchAllTodaysClasses() + let upcomingClasses = fetchUpcomingClasses() + let currentClass = fetchCurrentClass() + let completedCount = calculateCompletedClassesCount() + + var displayClasses: [Classes] = [] + + + if let current = currentClass { + displayClasses.append(current) + } + + displayClasses.append(contentsOf: upcomingClasses) + + return ( + classes: displayClasses, + total: allClasses.count, + completed: completedCount + ) } - + // MARK: - Timeline Provider Methods - func placeholder(in context: Context) -> ScheduleEntry { ScheduleEntry( date: Date(), total: 7, classes: [ Classes(title: "Software Engineering", time: "4:00 PM - 4:50 PM", slot: "A1 + TA1") - ], completed: 2 + ], + completed: 2 ) } + func getSnapshot(in context: Context, completion: @escaping (ScheduleEntry) -> ()) { - let lectures = fetchTodaysLectures() + let content = prepareWidgetContent() - completion(ScheduleEntry(date: Date(), total: lectures.count, classes: lectures, completed: 4)) + completion(ScheduleEntry( + date: Date(), + total: content.total, + classes: content.classes, + completed: content.completed + )) } - func getTimeline(in context: Context, completion: @escaping (Timeline<ScheduleEntry>) -> ()) { + let content = prepareWidgetContent() + let currentTime = Date() - let lectures = fetchTodaysLectures() - let completed = calculateCompletedClasses(lectures) - let entry = ScheduleEntry(date: Date(), total: lectures.count, classes: lectures,completed: completed) + let entry = ScheduleEntry( + date: currentTime, + total: content.total, + classes: content.classes, + completed: content.completed + ) - - let nextRefresh = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) - let timeline = Timeline(entries: [entry], policy: .after(nextRefresh ?? Date())) + let nextRefreshTime = calculateNextRefreshTime(currentTime: currentTime, classes: content.classes) + + let timeline = Timeline(entries: [entry], policy: .after(nextRefreshTime)) completion(timeline) } - private func calculateCompletedClasses(_ classes: [Classes]) -> Int { - let now = Date() - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "h:mm a" - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - - return classes.filter { classItem in - let timeComponents = classItem.time.components(separatedBy: " - ") - guard timeComponents.count == 2, - let endTime = dateFormatter.date(from: timeComponents[1]) else { - return false + + // MARK: - Smart Refresh Timing + + private func calculateNextRefreshTime(currentTime: Date, classes: [Classes]) -> Date { + let calendar = Calendar.current + + + var nextSignificantTime: Date? + + for classItem in classes { + let (startTime, endTime) = parseClassTime(classItem.time) + + + if let start = startTime, start > currentTime { + if nextSignificantTime == nil || start < nextSignificantTime! { + nextSignificantTime = start + } } - - // Set today's date with class end time - let calendar = Calendar.current - let endTimeToday = calendar.date( - bySettingHour: calendar.component(.hour, from: endTime), - minute: calendar.component(.minute, from: endTime), - second: 0, - of: now - ) - - guard let endTimeTodayUnwrapped = endTimeToday else { return false } - - return now > endTimeTodayUnwrapped - }.count + + + if let end = endTime, end > currentTime { + if nextSignificantTime == nil || end < nextSignificantTime! { + nextSignificantTime = end + } + } + } + + + if let significantTime = nextSignificantTime { + return significantTime + } + + + return calendar.date(byAdding: .minute, value: 15, to: currentTime) ?? currentTime } - } diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index 9857451..d59f08a 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -67,3 +67,185 @@ struct LargeDueWidgetView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } } + +struct ScheduleLargeWidgetView: View { + var entry: ScheduleEntry + + var body: some View { + HStack(alignment: .top) { + Spacer().frame(width: 2) + VStack(alignment: .leading, spacing: 15) { + Spacer().frame(height: 5) + WidgetTitle(title: "Today's Schedule", fontSize: 18) + Spacer().frame(height: 5) + + HStack(alignment: .top, spacing: 15) { + if entry.classes.isEmpty { + VStack { + Text("No classes today! Time to\n relax and recharge!") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + Spacer().frame(height: 30) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + else if entry.completed == entry.total { + // Center the CircleProgressView + VStack { + Spacer() + CircleProgressView( + progress: entry.completed, + total: entry.total, + circleSize: 60, + lineWidth: 12, + fontSize: 16 + ) + .frame(width: 70, height: 70) + Spacer() + } + .frame(width: 70) + + Image("allclassesline") + + VStack(alignment: .leading, spacing: 10) { + Spacer().frame(height: 15) + Text("You're all set for the day.") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + Text("Time to relax.") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } else { + // Center the CircleProgressView + VStack { + Spacer() + CircleProgressView( + progress: entry.completed, + total: entry.total, + circleSize: 60, + lineWidth: 12, + fontSize: 16 + ) + .frame(width: 70, height: 70) + Spacer() + } + .frame(width: 70) + + Image("fourclassesline") + + VStack(alignment: .leading, spacing: 20) { + let displayClasses = getDisplayClasses() + + ForEach(displayClasses, id: \.title) { classItem in + ScheduleItemView( + title: classItem.title, + time: "\(classItem.time) | \(classItem.slot ?? "")" + ) + } + + let remainingCount = entry.classes.count - displayClasses.count + if remainingCount > 0 { + Text("+\(remainingCount) More") + .foregroundColor(.white.opacity(0.6)) + .font(.system(size: 14)) + } + } + } + } + Spacer() + } + Spacer() + } + .padding(.horizontal, 4) + .padding(.vertical, 6) + } + + private func getDisplayClasses() -> [Classes] { + let currentTime = Date() + let calendar = Calendar.current + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm a" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + // Sort all classes by their start time + let sortedClasses = entry.classes.sorted { class1, class2 in + let time1Components = class1.time.components(separatedBy: " - ") + let time2Components = class2.time.components(separatedBy: " - ") + + guard time1Components.count == 2, time2Components.count == 2 else { + return false + } + + let startTime1Str = time1Components[0].trimmingCharacters(in: .whitespaces) + let startTime2Str = time2Components[0].trimmingCharacters(in: .whitespaces) + + guard let startTime1 = dateFormatter.date(from: startTime1Str), + let startTime2 = dateFormatter.date(from: startTime2Str) else { + return false + } + + return startTime1 < startTime2 + } + + // Find the next upcoming class or current class + var currentIndex = 0 + let now = Date() + + for (index, classItem) in sortedClasses.enumerated() { + let timeComponents = classItem.time.components(separatedBy: " - ") + guard timeComponents.count == 2 else { continue } + + let startTimeStr = timeComponents[0].trimmingCharacters(in: .whitespaces) + let endTimeStr = timeComponents[1].trimmingCharacters(in: .whitespaces) + + guard let startTime = dateFormatter.date(from: startTimeStr), + let endTime = dateFormatter.date(from: endTimeStr) else { continue } + + // Convert to today's date + let todayStart = calendar.date( + bySettingHour: calendar.component(.hour, from: startTime), + minute: calendar.component(.minute, from: startTime), + second: 0, + of: now + ) + + let todayEnd = calendar.date( + bySettingHour: calendar.component(.hour, from: endTime), + minute: calendar.component(.minute, from: endTime), + second: 0, + of: now + ) + + if let todayStart = todayStart, let todayEnd = todayEnd { + // If current time is before this class starts, or if we're currently in this class + if now <= todayEnd { + currentIndex = index + break + } + } + + // If we've passed all classes, start from the beginning for next day + if index == sortedClasses.count - 1 { + currentIndex = 0 + } + } + + // Get up to 4 classes starting from the current position + let maxDisplay = min(4, sortedClasses.count) + var displayClasses: [Classes] = [] + + for i in 0..<maxDisplay { + let classIndex = (currentIndex + i) % sortedClasses.count + displayClasses.append(sortedClasses[classIndex]) + } + + return displayClasses + } +} From f75e8a31db8390d8071364a9ad7aea69d5032c1d Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Sat, 5 Jul 2025 12:18:20 +0545 Subject: [PATCH 15/16] feat: url change --- VITTY/VITTY/Shared/Constants.swift | 2 +- VITTY/VITTY/Utilities/Constants/APIConstants.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 2f8ca1f..27d0a12 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -12,7 +12,7 @@ class Constants { // "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" - "http://localhost:80/api/v2/" + "https://9b66-2409-40e3-1ee-9039-75b8-20ad-89e9-248a.ngrok-free.app/api/v2/" // "https://f4df-2409-40e3-30a4-8539-6d49-631b-ddd8-60a3.ngrok-free.app/api/v2/" diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index f55add2..1cbadeb 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -10,7 +10,7 @@ import Foundation struct APIConstants { - static let base_url = "http://localhost:80/api/v2/" + static let base_url = "https://9b66-2409-40e3-1ee-9039-75b8-20ad-89e9-248a.ngrok-free.app/api/v2/" static let createCircle = "circles/create/" static let sendRequest = "circles/sendRequest/" static let acceptRequest = "circles/acceptRequest/" From 77a3e91b8b9605e6f672aac235bf43697386eb06 Mon Sep 17 00:00:00 2001 From: Dev Keshwani <dev.keshwani2022@vitstudent.ac.in> Date: Sat, 5 Jul 2025 15:04:03 +0530 Subject: [PATCH 16/16] feat: added bug issue template (#20) --- .github/ISSUE_TEMPLATE/bug_report.md | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6c95d90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behaviour** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone16] + - OS: [e.g. iOS18] + - App Version [e.g. 22] + +**Additional context** +Add any other context about the problem here.