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..431c6b2 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -27,11 +27,16 @@ 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 */; }; 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.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 */; }; @@ -41,6 +46,11 @@ 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 */; }; + 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 */; }; @@ -185,11 +195,16 @@ 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 = ""; }; + 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolTip.swift; sourceTree = ""; }; 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = ""; }; + 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestModel.swift; sourceTree = ""; }; + 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestViewModel.swift; sourceTree = ""; }; + 4B341C112E18031E0073906B /* FreindRequestCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestCard.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 = ""; }; + 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 = ""; }; @@ -197,6 +212,10 @@ 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 = ""; }; + 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 = ""; }; @@ -473,9 +492,19 @@ path = Components; sourceTree = ""; }; + 4B74D8752E0BF76B00B390E9 /* Components */ = { + isa = PBXGroup; + children = ( + 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */, + 4B74D8762E0BF77400B390E9 /* Alerts.swift */, + ); + path = Components; + sourceTree = ""; + }; 4B7DA5DD2D7094CA007354A3 /* Academics */ = { isa = PBXGroup; children = ( + 4B74D8752E0BF76B00B390E9 /* Components */, 4B74D8752E0BF76B00B390E9 /* Components */, 4BBB002F2D95510B003B8FE2 /* Model */, 4BBB002E2D955104003B8FE2 /* VIewModel */, @@ -497,21 +526,14 @@ isa = PBXGroup; children = ( 4B7DA5ED2D71E100007354A3 /* View */, - 4B7DA5EA2D71E0E2007354A3 /* Components */, ); path = Freinds; sourceTree = ""; }; - 4B7DA5EA2D71E0E2007354A3 /* Components */ = { - isa = PBXGroup; - children = ( - ); - path = Components; - sourceTree = ""; - }; 4B7DA5EB2D71E0F4007354A3 /* Components */ = { isa = PBXGroup; children = ( + 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */, 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */, 4BF0C79E2D94694000016202 /* InsideCircleCards.swift */, 4B7DA5E62D71AC51007354A3 /* CirclesRow.swift */, @@ -525,6 +547,7 @@ 4B7DA5EC2D71E0FB007354A3 /* View */ = { isa = PBXGroup; children = ( + 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B7DA5E42D70B2C8007354A3 /* Circles.swift */, 4BF0C79C2D94680A00016202 /* InsideCircle.swift */, @@ -553,6 +576,7 @@ 4BBB002D2D9550F8003B8FE2 /* View */ = { isa = PBXGroup; children = ( + 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, 4B183EE72D7C78B300C9D801 /* Courses.swift */, 4BF03C982D7819E00098C803 /* Notes.swift */, @@ -578,6 +602,7 @@ 4BBB002F2D95510B003B8FE2 /* Model */ = { isa = PBXGroup; children = ( + 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, 4B5977462DF97D5A009CC224 /* RemainderModel.swift */, 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */, @@ -672,6 +697,7 @@ 522B8BAB2B47296900EE686E /* ViewModel */ = { isa = PBXGroup; children = ( + 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */, 522B8BAC2B47297A00EE686E /* CommunityPageViewModel.swift */, ); path = ViewModel; @@ -680,6 +706,7 @@ 522B8BAE2B4732C200EE686E /* Models */ = { isa = PBXGroup; children = ( + 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */, 4BF0C77C2D932B8A00016202 /* CircleModel.swift */, 522B8BAF2B4732CC00EE686E /* Friend.swift */, ); @@ -740,6 +767,7 @@ 524B842D2B46EBAE006D18BD /* View */ = { isa = PBXGroup; children = ( + 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */, 524B842E2B46EBBD006D18BD /* HomeView.swift */, ); path = View; @@ -798,6 +826,7 @@ 524B843D2B46F705006D18BD /* Components */ = { isa = PBXGroup; children = ( + 4B341C112E18031E0073906B /* FreindRequestCard.swift */, 524B843B2B46F6FD006D18BD /* AddFriendsHeader.swift */, ); path = Components; @@ -1117,6 +1146,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 +1161,20 @@ 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 */, + 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 */, @@ -1148,12 +1182,15 @@ 521E1E8B2C21DF0D00E8C7D2 /* AddFriendCardSearch.swift in Sources */, 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */, 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.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 */, + 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */, 4BBB00312D955163003B8FE2 /* AcademicsViewModel.swift in Sources */, 4B37F1E42E02AA7800DCEE5F /* ReminderNotifcationManager.swift in Sources */, 314A409127383BEC0058082F /* ContentView.swift in Sources */, @@ -1179,6 +1216,7 @@ 4BF0C79F2D94694900016202 /* InsideCircleCards.swift in Sources */, 524B843C2B46F6FD006D18BD /* AddFriendsHeader.swift in Sources */, 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */, + 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */, 4BD63D7A2D70636400EEF5D7 /* EmptyClassRoomViewModel.swift in Sources */, 521562AC2B70B0FD0054F051 /* InstructionView.swift in Sources */, 522B8BAD2B47297A00EE686E /* CommunityPageViewModel.swift in Sources */, @@ -1189,6 +1227,7 @@ 4BD63D772D70610B00EEF5D7 /* EmptyClassAPIService.swift in Sources */, 528CF1732B769B18007298A0 /* TimeTable.swift in Sources */, 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */, + 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */, 521562AE2B710E730054F051 /* UsernameView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1200,6 +1239,7 @@ 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */, 4BC853C42DF6DA7A0092B2E2 /* TimeTable.swift in Sources */, 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, + 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 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..e43f3ab 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -5,9 +5,17 @@ // Created by Rujin Devkota on 2/27/25. +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + + import SwiftUI import SwiftData +struct OCourseRefs: View { struct OCourseRefs: View { var courseName: String var courseInstitution: String @@ -17,6 +25,7 @@ struct OCourseRefs: View { @State private var showBottomSheet = false @State private var showReminderSheet = false @State private var showNotes = false + @State private var showNotes = false @State private var navigateToNotesEditor = false @State var showCourseNotes: Bool = false @State private var selectedNote: CreateNoteModel? @@ -32,9 +41,11 @@ 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 + @Environment(\.modelContext) private var modelContext private let maxVisible = 4 @@ -42,6 +53,19 @@ struct OCourseRefs: View { @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" + } + } + } + @Query private var courseFiles: [UploadedFile] + enum ContentType: String, CaseIterable { case notes = "Notes" case files = "Files" @@ -69,6 +93,7 @@ struct OCourseRefs: View { let notesPredicate = #Predicate { $0.courseId == courseCode + $0.courseId == courseCode } _courseNotes = Query( FetchDescriptor(predicate: notesPredicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) @@ -93,6 +118,34 @@ struct OCourseRefs: View { } } + private var filteredFiles: [UploadedFile] { + if searchText.isEmpty { + return courseFiles + } else { + return courseFiles.filter { file in + file.fileName.localizedCaseInsensitiveContains(searchText) + } + } + + + let filesPredicate = #Predicate { + $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 @@ -127,6 +180,14 @@ struct OCourseRefs: View { Spacer() + if selectedContentType == .files && !courseFiles.isEmpty { + Button("View All") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) + + if selectedContentType == .files && !courseFiles.isEmpty { Button("View All") { showFileGallery = true @@ -139,6 +200,7 @@ struct OCourseRefs: View { HStack { Spacer() + TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) .padding(10) .frame(width: UIScreen.main.bounds.width * 0.85) @@ -146,12 +208,18 @@ struct OCourseRefs: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) .foregroundColor(.white) + .foregroundColor(.white) Spacer() } + Spacer().frame(height: 15) + + + + Spacer().frame(height: 15) Text("\(courseName) - \(courseInstitution)") @@ -177,6 +245,24 @@ struct OCourseRefs: View { } .padding(.horizontal) .padding(.top, 10) + + 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) { @@ -198,6 +284,16 @@ struct OCourseRefs: View { VStack(alignment: .leading, spacing: 15) { 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 + if selectedContentType == .notes { + if filteredNotes.isEmpty { EmptyStateView( icon: searchText.isEmpty ? "doc.text" : "magnifyingglass", @@ -243,6 +339,50 @@ struct OCourseRefs: View { } } + 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) + } + 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 @@ -259,25 +399,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 +516,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)]) @@ -349,9 +535,27 @@ struct OCourseRefs: View { .sheet(isPresented: $showFileGallery) { FileGalleryView(courseCode: courseCode) } + .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) } + .sheet(isPresented: $showNotes, content: { + NoteEditorView( + existingNote: selectedNote, + preloadedAttributedString: preloadedAttributedString, + courseCode: courseCode, + courseName: courseName, + courseIns: courseInstitution, + courseSlot: slot + ) + }) + NoteEditorView(courseCode: courseCode, courseName: courseName, courseIns: courseInstitution, courseSlot: slot) + } .sheet(isPresented: $showNotes, content: { NoteEditorView( existingNote: selectedNote, @@ -398,6 +602,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 { @@ -729,19 +963,343 @@ struct CompactFileCard: View { enum NoteLoadingError: Error { case invalidData case unarchiveFailed + 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 + } + + 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 BottomSheetButton: View { - var icon: String - var title: String - var action: (() -> Void)? = nil + +struct ContentTypeTab: View { + let contentType: OCourseRefs.ContentType + let isSelected: Bool + let count: Int + let action: () -> Void var body: some View { - Button(action: { - action?() - }) { - VStack { + 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) { + + 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 + + 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 + var title: String + var action: (() -> Void)? = nil + + var body: some View { + Button(action: { + action?() + }) { + VStack { Image(icon) .font(.title) .padding() @@ -788,6 +1346,7 @@ struct TagView: View { } } + struct MoreTagView: View { var count: Int @@ -810,6 +1369,11 @@ struct CourseCardNotes: View { @State private var showComingSoonAlert = false + var isLoading: Bool = false + var onDelete: () -> Void + + @State private var showComingSoonAlert = false + var body: some View { HStack { VStack(alignment: .leading, spacing: 6) { @@ -825,6 +1389,52 @@ struct CourseCardNotes: View { 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()) + } + } + 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)) @@ -867,6 +1477,11 @@ struct CourseCardNotes: View { .alert("Feature coming soon", isPresented: $showComingSoonAlert) { Button("OK", role: .cancel) { } } + .opacity(isLoading ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isLoading) + .alert("Feature coming soon", isPresented: $showComingSoonAlert) { + Button("OK", role: .cancel) { } + } } } 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..50465eb 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -4,6 +4,12 @@ // // Created by Rujin Devkota on 2/27/25. +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + import SwiftUI import UIKit @@ -13,6 +19,7 @@ struct RichTextView: UIViewRepresentable { @Binding var typingAttributes: [NSAttributedString.Key: Any] @Binding var isEmpty: Bool + func makeUIView(context: Context) -> UITextView { let textView = UITextView() @@ -25,6 +32,7 @@ struct RichTextView: UIViewRepresentable { textView.textColor = .white + textView.attributedText = attributedText textView.selectedRange = selectedRange @@ -33,26 +41,33 @@ struct RichTextView: UIViewRepresentable { func updateUIView(_ uiView: UITextView, context: Context) { + if context.coordinator.isUpdating { return } + if !uiView.attributedText.isEqual(to: attributedText) { let previousSelectedRange = uiView.selectedRange context.coordinator.isUpdating = true uiView.attributedText = attributedText + if previousSelectedRange.location <= uiView.attributedText.length { let maxRange = min(previousSelectedRange.location + previousSelectedRange.length, uiView.attributedText.length) let validRange = NSRange(location: previousSelectedRange.location, length: maxRange - previousSelectedRange.location) uiView.selectedRange = validRange + 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 } + if !NSEqualRanges(uiView.selectedRange, selectedRange) && selectedRange.location <= uiView.attributedText.length && NSMaxRange(selectedRange) <= uiView.attributedText.length { @@ -62,6 +77,7 @@ struct RichTextView: UIViewRepresentable { } + if !NSDictionary(dictionary: uiView.typingAttributes).isEqual(to: typingAttributes) { uiView.typingAttributes = typingAttributes } @@ -81,21 +97,27 @@ struct RichTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { + guard !isUpdating else { return } isUpdating = true defer { isUpdating = false } + parent.attributedText = NSMutableAttributedString(attributedString: textView.attributedText) parent.isEmpty = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + parent.typingAttributes = textView.typingAttributes + + parent.typingAttributes = textView.typingAttributes } func textViewDidChangeSelection(_ textView: UITextView) { + guard !isUpdating else { return } isUpdating = true @@ -118,11 +140,28 @@ struct RichTextView: UIViewRepresentable { + + + 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 + } + } + } + } +} + + + struct NoteEditorView: View { @Environment(\.dismiss) private var dismiss @Environment(AcademicsViewModel.self) private var academicsViewModel @Environment(AuthViewModel.self) private var authViewModel @Environment(\.presentationMode) var presentationMode + @Environment(\.presentationMode) var presentationMode @State private var attributedText = NSMutableAttributedString() @State private var selectedRange = NSRange(location: 0, length: 0) @@ -133,6 +172,7 @@ struct NoteEditorView: View { let existingNote: CreateNoteModel? let preloadedAttributedString: NSAttributedString? // Pre-processed content + 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 @@ -141,16 +181,21 @@ struct NoteEditorView: View { @State private var hasUnsavedChanges = false @State private var isInitialized = false @State private var goback = false + @State private var goback = false @Environment(\.modelContext) private var modelContext let courseCode: String let courseName: String let courseIns : String let courseSlot : String + let courseIns : String + let courseSlot : String + init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String,courseIns: String , courseSlot: String) { init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String,courseIns: String , courseSlot: String) { self.existingNote = existingNote self.preloadedAttributedString = preloadedAttributedString + self.preloadedAttributedString = preloadedAttributedString self.courseCode = existingNote?.courseId ?? courseCode self.courseName = existingNote?.courseName ?? courseName self.courseIns = courseIns @@ -159,7 +204,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() @@ -177,6 +221,17 @@ struct NoteEditorView: View { isInitialized = true } else { + Task { @MainActor in + await loadNoteContent(note) + isInitialized = true + } + 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 @@ -184,6 +239,7 @@ struct NoteEditorView: View { } } else { + attributedText = NSMutableAttributedString() isEmpty = true isInitialized = true @@ -200,6 +256,15 @@ struct NoteEditorView: View { } + do { + + if let cachedAttributedString = note.cachedAttributedString { + attributedText = NSMutableAttributedString(attributedString: cachedAttributedString) + isEmpty = cachedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return + } + + do { guard let data = Data(base64Encoded: note.noteContent) else { print("Failed to decode base64 data") @@ -225,10 +290,12 @@ struct NoteEditorView: View { func saveContent() { guard hasUnsavedChanges || existingNote == nil else { + handleBackNavigation() handleBackNavigation() return } + do { let data = try NSKeyedArchiver.archivedData(withRootObject: attributedText, requiringSecureCoding: false) let dataString = data.base64EncodedString() @@ -240,6 +307,8 @@ struct NoteEditorView: View { note.createdAt = Date.now CreateNoteModel.clearCache() + + CreateNoteModel.clearCache() } else { let newNote = CreateNoteModel( noteName: title, @@ -287,21 +356,26 @@ struct NoteEditorView: View { if isInitialized { VStack { + headerView + textEditorView + toolbarView } } else { + ProgressView("Loading...") .foregroundColor(.white) } + if showFontPicker { fontPickerOverlay } @@ -328,9 +402,11 @@ struct NoteEditorView: View { private var headerView: some View { HStack { + Button(action: { handleBackNavigation() }) { Button(action: { handleBackNavigation() }) { Image(systemName: "chevron.left") .foregroundColor(Color("Accent")).font(.title2) + .foregroundColor(Color("Accent")).font(.title2) } Spacer() Text("Note") @@ -371,6 +447,7 @@ struct NoteEditorView: View { private var toolbarView: some View { HStack(spacing: 20) { + Button(action: { showFontPicker.toggle() showFontSizePicker = false @@ -380,6 +457,7 @@ struct NoteEditorView: View { } + Button(action: { showFontSizePicker.toggle() showFontPicker = false @@ -395,11 +473,13 @@ struct NoteEditorView: View { } + formatButton(action: toggleBold, icon: "bold", isActive: isBoldActive()) formatButton(action: toggleItalic, icon: "italic", isActive: isItalicActive()) formatButton(action: toggleUnderline, icon: "underline", isActive: isUnderlineActive()) + ColorPicker("", selection: $selectedColor, supportsOpacity: false) .labelsHidden() .frame(width: 30, height: 30) @@ -408,6 +488,7 @@ struct NoteEditorView: View { } + Button(action: addBulletPoints) { Image(systemName: "list.bullet") .foregroundColor(Color("Accent")) @@ -507,6 +588,7 @@ struct NoteEditorView: View { } + func addBulletPoints() { guard selectedRange.length > 0 else { return } @@ -524,10 +606,12 @@ struct NoteEditorView: View { func isBoldActive() -> Bool { return checkTraitActive(.traitBold) + return checkTraitActive(.traitBold) } func isItalicActive() -> Bool { return checkTraitActive(.traitItalic) + return checkTraitActive(.traitItalic) } func isUnderlineActive() -> Bool { @@ -557,6 +641,28 @@ struct NoteEditorView: View { } } + 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) @@ -574,6 +680,8 @@ struct NoteEditorView: View { } func applyFontFamily(_ font: UIFont) { + let size = getCurrentFont().pointSize + let newFont = UIFont(name: font.fontName, size: size) ?? font let size = getCurrentFont().pointSize let newFont = UIFont(name: font.fontName, size: size) ?? font applyAttribute(.font, value: newFont) @@ -591,6 +699,38 @@ struct NoteEditorView: View { 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 { + 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 + } + 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 @@ -620,6 +760,7 @@ struct NoteEditorView: View { } } hasUnsavedChanges = true + hasUnsavedChanges = true } func toggleItalic() { @@ -628,6 +769,38 @@ struct NoteEditorView: View { 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 { + 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 + } + 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 @@ -657,8 +830,10 @@ struct NoteEditorView: View { } } hasUnsavedChanges = true + hasUnsavedChanges = true } + func toggleUnderline() { if selectedRange.length > 0 { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) @@ -677,6 +852,23 @@ struct NoteEditorView: View { typingAttributes[.underlineStyle] = newUnderline } hasUnsavedChanges = true + 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) { @@ -685,6 +877,9 @@ struct NoteEditorView: View { 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) + 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 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.startIndex { let beforeText = String(remainingText[remainingText.startIndex.. [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 +315,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 +341,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 +475,8 @@ struct SubjectSelectionCard: View { .buttonStyle(PlainButtonStyle()) } } + + struct StatusTabView: View { let isSelected: Bool let title: String @@ -403,11 +486,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 +661,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..5181cb3 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 { @@ -25,6 +57,9 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { + + + var isLoading: Bool = false var isLoadingApple: Bool = false let firebaseAuth = Auth.auth() @@ -62,11 +97,80 @@ 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")") logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") do { + self.loggedInBackendUser = try await AuthAPIService.shared .signInUser( with: AuthRequestBody( @@ -76,6 +180,8 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { ) ) + + } catch { @@ -83,8 +189,11 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { } print("this is kinda empty : \(self.loggedInBackendUser?.name ?? "")") logger.info("Signed into server \(self.loggedInBackendUser?.name ?? "empty")") + 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?) { logger.info("Firebase User Auth State Updated") DispatchQueue.main.async { @@ -146,7 +255,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 +280,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..) -> Self { - return min(max(self, range.lowerBound), range.upperBound) - } + func clamped(to range: Range) -> 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..7323d35 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,27 +35,59 @@ 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 } } 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() @@ -69,7 +100,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..94420d9 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift @@ -18,6 +18,30 @@ struct CirclesRow: View { } + 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) + } + @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" @@ -36,7 +60,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) { @@ -71,6 +99,40 @@ struct CirclesRow: View { } + if circleMembers.isEmpty && !isLoadingMembers { + Text("No members") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent").opacity(0.7)) + } + } + } + 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)) @@ -95,9 +157,13 @@ struct CirclesRow: View { 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: []) @@ -107,3 +173,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..1b2df7b 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -1,10 +1,12 @@ // -// Freinds.swift +// CreateGroup.swift // VITTY // // Created by Rujin Devkota on 2/27/25. + import SwiftUI import Alamofire +import Alamofire struct CreateGroup: View { let screenHeight = UIScreen.main.bounds.height @@ -19,9 +21,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,12 +38,13 @@ 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) + Button(action: { showImagePicker = true }) { @@ -64,10 +69,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 +87,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)) @@ -96,6 +122,7 @@ struct CreateGroup: View { Button(action: { showFriendSelector = true + showFriendSelector = true }) { Image(systemName: "person.badge.plus") .foregroundColor(.white) @@ -104,7 +131,7 @@ struct CreateGroup: View { .padding(.trailing, 20) } - + if selectedFriends.isEmpty { VStack { @@ -194,11 +221,12 @@ struct CreateGroup: View { Spacer() - + HStack { Spacer() Button(action: { createGroup() + createGroup() }) { HStack { if isCreatingGroup { @@ -207,14 +235,15 @@ 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) } .disabled(groupName.isEmpty || isCreatingGroup) + .disabled(groupName.isEmpty || isCreatingGroup) .padding(.trailing, 20) } .padding(.bottom, 20) @@ -240,63 +269,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 }) { - case .failure(let error): + print("Found circle ID: \(circle.circleID) for name: \(self.groupName)") + + self.circle_ID = circle.circleID + + + 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 +348,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 +507,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..3ed9272 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -3,14 +3,21 @@ // // Created by Rujin Devkota on 2/28/25. // +// JoinGroup.swift +// VITTY +// +// Created by Rujin Devkota on 2/28/25. +// import SwiftUI import AVFoundation import UIKit +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 = "" @@ -26,6 +33,18 @@ struct JoinGroup: View { @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(\.dismiss) private var dismiss + @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 { ZStack { VStack(spacing: 20) { @@ -33,7 +52,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 +134,7 @@ struct JoinGroup: View { .disabled(isJoining) Spacer() - + HStack { Spacer() Button(action: { @@ -124,23 +144,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 +175,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 +263,7 @@ struct JoinGroup: View { } // MARK: - Join Circle + private func joinCircle() { guard !localGroupCode.isEmpty, let username = authViewModel.loggedInBackendUser?.username, @@ -250,7 +280,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 +308,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 +338,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..24f0fac 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 { @@ -143,6 +382,7 @@ struct InsideCircle: View { }) { Image(systemName: "chevron.left") .foregroundColor(.white).font(.title2) + .foregroundColor(.white).font(.title2) } Spacer() Text("Circle") @@ -151,10 +391,13 @@ struct InsideCircle: View { Spacer() Button(action: { showCircleMenu = true + showCircleMenu = true }) { + Image(systemName: "ellipsis") Image(systemName: "ellipsis") .foregroundColor(.white) .font(.system(size: 18)) + .font(.system(size: 18)) } } .padding() @@ -170,10 +413,11 @@ struct InsideCircle: View { .foregroundColor(.white) Spacer() + } Spacer().frame(height: 5) HStack { - // Dynamic busy count + if busyCount > 0 { HStack { Image("inclass").resizable().frame(width: 18, height: 18) @@ -188,7 +432,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 +447,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 +465,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 +488,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 +517,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..238cb83 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -22,21 +22,28 @@ 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? @State private var showCircleMenu = false @Environment(\.dismiss) private var dismiss + @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 + @State private var hasLoadedInitialData = false var body: some View { ZStack { @@ -65,12 +72,32 @@ struct ConnectPage: View { .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) } + if isCircleView == false { 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, @@ -79,15 +106,22 @@ struct ConnectPage: View { } ) .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: { showCircleMenu = true + showCircleMenu = true }) { + Image(systemName: "ellipsis") Image(systemName: "ellipsis") .foregroundColor(.white) .font(.system(size: 18)) + .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)) } } .overlay( @@ -115,7 +149,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 +157,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 +193,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..5c2a02c 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -6,6 +6,7 @@ // import SwiftUI + struct FriendsView: View { @State private var searchText = "" @State private var selectedFilterOption = 0 @@ -20,6 +21,8 @@ struct FriendsView: View { Spacer().frame(height: 8) + + HStack { FilterPill(title: "Available", isSelected: selectedFilterOption == 0) .onTapGesture { @@ -35,6 +38,7 @@ struct FriendsView: View { Spacer().frame(height: 7) + if communityPageViewModel.errorFreinds { Spacer() VStack(spacing: 5) { @@ -54,17 +58,35 @@ struct FriendsView: View { Spacer() } else { + let filteredFriends = communityPageViewModel.friends.filter { friend in + let matchesSearch: Bool + let matchesSearch: Bool if searchText.isEmpty { matchesSearch = true + matchesSearch = true } else { + matchesSearch = 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 + + let matchesFilter: Bool switch selectedFilterOption { case 0: @@ -94,11 +116,26 @@ struct FriendsView: View { .font(Font.custom("Poppins-Regular", size: 16)) .foregroundColor(.white) .multilineTextAlignment(.center) + 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,isFriendsTimeTable: true)) { NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { FriendRow(friend: friend) } @@ -111,6 +148,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..186343a 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -5,6 +5,7 @@ // Created by Chandram Dutta on 04/01/24. // // +// import Foundation import Alamofire @@ -16,19 +17,28 @@ class CommunityPageViewModel { var circles = [CircleModel]() var circleRequests = [CircleRequest]() + var circleRequests = [CircleRequest]() + var loadingFreinds = false var loadingCircle = false var loadingCircleMembers = false var loadingCircleRequests = false var loadingRequestAction = false + var loadingCircleRequests = false + var loadingRequestAction = false var errorFreinds = false var errorCircle = false var errorCircleMembers = false var errorCircleRequests = false + var errorCircleRequests = false + var circleMembers = [CircleUserTemp]() + var circleMembersDict: [String: [CircleUserTemp]] = [:] + var loadingCircleMembersDict: [String: Bool] = [:] + var circleMembersDict: [String: [CircleUserTemp]] = [:] var loadingCircleMembersDict: [String: Bool] = [:] @@ -45,21 +55,37 @@ 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 + switch response.result { + DispatchQueue.main.async { + self.loadingFreinds = false + switch response.result { case .success(let data): self.friends = data.data self.errorFreinds = false + self.errorFreinds = false + case .failure(let error): self.logger.error("Error fetching friends: \(error)") + if self.friends.isEmpty { + self.errorFreinds = true + } + } + self.logger.error("Error fetching friends: \(error)") + if self.friends.isEmpty { self.errorFreinds = true } @@ -77,6 +103,15 @@ class CommunityPageViewModel { } + self.errorCircle = false + + 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)"]) @@ -85,12 +120,19 @@ class CommunityPageViewModel { DispatchQueue.main.async { self.loadingCircle = false + switch response.result { + DispatchQueue.main.async { + self.loadingCircle = false + switch response.result { case .success(let data): self.circles = data.data self.errorCircle = false print("Successfully fetched circles: \(data.data)") + self.errorCircle = false + print("Successfully fetched circles: \(data.data)") + case .failure(let error): self.logger.error("Error fetching circles: \(error)") @@ -151,22 +193,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) } } @@ -212,6 +285,8 @@ class CommunityPageViewModel { AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in + DispatchQueue.main.async { + switch response.result { DispatchQueue.main.async { switch response.result { case .success(let data): @@ -224,9 +299,29 @@ class CommunityPageViewModel { } print("Successfully fetched circle members: \(data.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 + } + } + } + self.logger.error("Error fetching circle members: \(error)") + if let circleID = circleID { self.loadingCircleMembersDict[circleID] = false } else { @@ -240,7 +335,12 @@ class CommunityPageViewModel { } } + //MARK : Circle Leave + func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { + if loading { + self.loadingCircleMembers = true + } func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { if loading { self.loadingCircleMembers = true @@ -249,20 +349,31 @@ class CommunityPageViewModel { AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in + DispatchQueue.main.async { + self.loadingCircleMembers = false DispatchQueue.main.async { self.loadingCircleMembers = false + switch response.result { switch response.result { case .success(let data): self.circleMembers = data.data print("Successfully fetched circle members after leave: \(data.data)") + self.circleMembers = data.data + print("Successfully fetched circle members after leave: \(data.data)") + case .failure(let error): self.logger.error("Error fetching circle members: \(error)") if self.circleMembers.isEmpty { self.errorCircleMembers = true } } + self.logger.error("Error fetching circle members: \(error)") + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } + } } } } @@ -292,6 +403,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 +457,13 @@ class CommunityPageViewModel { // MARK: - Group Creation func createCircle(name: String, token: String, completion: @escaping (Result) -> 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 +473,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 +507,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 +565,46 @@ class CommunityPageViewModel { fetchCircleRequests(token: token, loading: false) } + + func generateJoinCode(circleId: String, token: String, completion: @escaping (Result) -> 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..af0eb8a 100644 --- a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -3,6 +3,7 @@ import SwiftUI import UserNotifications class SettingsViewModel : ObservableObject{ + @Published var notificationsEnabled: Bool = false { @Published var notificationsEnabled: Bool = false { didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") @@ -17,12 +18,15 @@ class SettingsViewModel : ObservableObject{ } } + @Published var timetable: TimeTable? + @Published var showNotificationDisabledAlert = false @Published var timetable: TimeTable? @Published var showNotificationDisabledAlert = false init(timetable: TimeTable? = nil) { self.timetable = timetable + self.notificationsEnabled = UserDefaults.standard.bool(forKey: "notificationsEnabled") checkNotificationAuthorization() } @@ -55,6 +59,9 @@ class SettingsViewModel : ObservableObject{ // Clear existing notifications first UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + // Clear existing notifications first + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + let weekdays: [(Int, [Lecture])] = [ (2, timetable.monday), // Monday = 2 (3, timetable.tuesday), // Tuesday = 3 @@ -63,6 +70,13 @@ class SettingsViewModel : ObservableObject{ (6, timetable.friday), // Friday = 6 (7, timetable.saturday), // Saturday = 7 (1, timetable.sunday) // Sunday = 1 + (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 { @@ -73,14 +87,24 @@ class SettingsViewModel : ObservableObject{ } + 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") + + print("Scheduled notifications for all lectures") } private func scheduleNotification(for lectureName: String, at date: Date, title: String, minutesBefore: Int) { @@ -94,8 +118,10 @@ 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 identifier = "\(lectureName)-\(title)-\(minutesBefore)min-weekday\(triggerComponents.weekday ?? 0)" let request = UNNotificationRequest( + identifier: identifier, identifier: identifier, content: content, trigger: trigger @@ -108,9 +134,18 @@ class SettingsViewModel : ObservableObject{ print("Successfully scheduled notification: \(identifier)") } } + 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? { let formattedTimeString = formatTime(time: timeString) @@ -169,6 +204,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..27d0a12 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/" + + "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/" + +// "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..d958067 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -209,6 +209,7 @@ class Lecture: Codable, Identifiable, Comparable { } } + extension TimeTable { var isEmpty: Bool { monday.isEmpty && tuesday.isEmpty && wednesday.isEmpty && @@ -219,14 +220,26 @@ extension TimeTable { let formattedTime = formatTime(time: lecture.startTime) + guard formattedTime != "Failed to parse the time string." 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: formattedTime) + } + func classesFor(date: Date) -> [Classes] { @@ -253,6 +266,10 @@ extension TimeTable { ) } + // 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 { // Sort using the original lecture objects instead of formatted strings return lectures.sorted { lecture1, lecture2 in guard let time1 = extractStartTime(from: lecture1), @@ -268,10 +285,20 @@ extension TimeTable { ) } } + 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: "+").first ?? "" + timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm:ss" @@ -285,6 +312,26 @@ extension TimeTable { } } + 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 + 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 || diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index b66bc69..7eb83d0 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 @@ -13,6 +14,9 @@ public enum Stage { case loading case error case data + case loading + case error + case data } @@ -27,6 +31,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 +70,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 +80,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 +104,8 @@ extension TimeTableView { private func backgroundSync( localTimeTable: TimeTable, username: String, - authToken: String + authToken: String, + context: ModelContext ) async { guard !isSyncing else { return } @@ -114,7 +124,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 +147,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 +185,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 +239,7 @@ extension TimeTableView { stage = .data context.insert(data) + try context.save() hasSyncedThisSession = true } catch { @@ -209,6 +248,9 @@ extension TimeTableView { } } + var updatedTimeTable: TimeTable? { + timeTable + } var updatedTimeTable: TimeTable? { timeTable } @@ -218,6 +260,11 @@ extension TimeTableView { logger.debug("Sync status reset") } } + 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 8f16f2a..4140f97 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" @@ -104,4 +105,15 @@ struct LectureDetailView: View { 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 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..f7ec92a 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -1,4 +1,5 @@ + import OSLog import SwiftData import SwiftUI @@ -7,15 +8,22 @@ struct TimeTableView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.modelContext) private var context @Environment(\.scenePhase) private var scenePhase + @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] @Environment(\.dismiss) private var dismiss + @Query private var timetableItem: [TimeTable] + @Environment(\.dismiss) private var dismiss let friend: Friend? + var isFriendsTimeTable: Bool + + var isFriendsTimeTable: Bool private let logger = Logger( @@ -25,7 +33,9 @@ struct TimeTableView: View { ) ) + var body: some View { + NavigationStack { NavigationStack { ZStack { BackgroundView() @@ -40,6 +50,18 @@ struct TimeTableView: View { }.padding(8) } + 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 { @@ -64,10 +86,12 @@ struct TimeTableView: View { 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 { @@ -86,6 +110,7 @@ struct TimeTableView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) + if viewModel.lectures.isEmpty { Spacer() Text("No classes today!") @@ -96,9 +121,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 +167,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..8bc6646 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -1,4 +1,6 @@ import SwiftUI +import OSLog +import SwiftData @@ -8,17 +10,20 @@ 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) { Button { isPresented = false + isPresented = false } label: { Image(systemName: "xmark") .foregroundColor(.white) .padding() } + VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 8) { UserImage( @@ -35,22 +40,34 @@ struct UserProfileSidebar: View { } .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").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") + + // 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") @@ -60,6 +77,7 @@ struct UserProfileSidebar: View { Divider().background(Color.clear) + VStack(alignment: .leading, spacing: 4) { Text("Ghost Mode") .font(Font.custom("Poppins-Medium", size: 16)) @@ -84,12 +102,40 @@ struct UserProfileSidebar: View { .foregroundColor(.white) } } + + 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() + 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") @@ -130,6 +176,68 @@ struct UserProfileSidebar: View { 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() + .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)" @@ -175,6 +283,8 @@ struct MenuOption: View { let title: String + + var body: some View { HStack(spacing: 16) { Image(icon) diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 8236c5a..1cbadeb 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 = "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/" 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: 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 0000000..4561de5 Binary files /dev/null and b/VITTY/VittyWidget/Assets.xcassets/classellipse.imageset/classellipse.png differ 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 0000000..61601a5 Binary files /dev/null and b/VITTY/VittyWidget/Assets.xcassets/currentellipse.imageset/currentellipse.png differ 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 0000000..1a75638 Binary files /dev/null and b/VITTY/VittyWidget/Assets.xcassets/fourclassesline.imageset/allclassesline.png differ 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 + } +}