diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6c95d90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behaviour** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone16] + - OS: [e.g. iOS18] + - App Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json new file mode 100644 index 0000000..ad8b1af --- /dev/null +++ b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Frame 10.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg new file mode 100644 index 0000000..964e950 --- /dev/null +++ b/VITTY/Assets.xcassets/Icons/Frame 10.imageset/Frame 10.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VITTY/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 be5f1b1..431c6b2 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -27,10 +27,30 @@ 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 */; }; 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; + 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; + 4B74D8742E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; + 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8762E0BF77400B390E9 /* Alerts.swift */; }; + 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */; }; + 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */; }; + 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 */; }; @@ -175,9 +195,27 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -437,9 +475,37 @@ path = Utilities; sourceTree = ""; }; + 4B37F1E72E04172E00DCEE5F /* ViewModel */ = { + isa = PBXGroup; + children = ( + 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + 4B74D8752E0BF76B00B390E9 /* Components */ = { + isa = PBXGroup; + children = ( + 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */, + 4B74D8762E0BF77400B390E9 /* Alerts.swift */, + ); + 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 */, 4BBB002D2D9550F8003B8FE2 /* View */, @@ -460,21 +526,15 @@ 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 */, 4B7DA5F12D7228E5007354A3 /* JoinGroup.swift */, @@ -487,6 +547,8 @@ 4B7DA5EC2D71E0FB007354A3 /* View */ = { isa = PBXGroup; children = ( + 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, + 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B7DA5E42D70B2C8007354A3 /* Circles.swift */, 4BF0C79C2D94680A00016202 /* InsideCircle.swift */, ); @@ -514,6 +576,8 @@ 4BBB002D2D9550F8003B8FE2 /* View */ = { isa = PBXGroup; children = ( + 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, + 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, 4B183EE72D7C78B300C9D801 /* Courses.swift */, 4BF03C982D7819E00098C803 /* Notes.swift */, 4BF03C9A2D7838C50098C803 /* NotesHelper.swift */, @@ -521,6 +585,7 @@ 4B7DA5DE2D7094E3007354A3 /* Academics.swift */, 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */, 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */, + 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */, ); path = View; sourceTree = ""; @@ -528,6 +593,7 @@ 4BBB002E2D955104003B8FE2 /* VIewModel */ = { isa = PBXGroup; children = ( + 4B37F1E32E02AA6E00DCEE5F /* ReminderNotifcationManager.swift */, 4BBB00302D95515C003B8FE2 /* AcademicsViewModel.swift */, ); path = VIewModel; @@ -536,6 +602,8 @@ 4BBB002F2D95510B003B8FE2 /* Model */ = { isa = PBXGroup; children = ( + 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, + 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, 4B5977462DF97D5A009CC224 /* RemainderModel.swift */, 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */, ); @@ -576,13 +644,6 @@ path = View; sourceTree = ""; }; - 4BF03C972D7819D30098C803 /* Academics */ = { - isa = PBXGroup; - children = ( - ); - path = Academics; - sourceTree = ""; - }; 520BA63B2B47FF5700124850 /* SuggestedFriends */ = { isa = PBXGroup; children = ( @@ -636,6 +697,7 @@ 522B8BAB2B47296900EE686E /* ViewModel */ = { isa = PBXGroup; children = ( + 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */, 522B8BAC2B47297A00EE686E /* CommunityPageViewModel.swift */, ); path = ViewModel; @@ -644,6 +706,7 @@ 522B8BAE2B4732C200EE686E /* Models */ = { isa = PBXGroup; children = ( + 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */, 4BF0C77C2D932B8A00016202 /* CircleModel.swift */, 522B8BAF2B4732CC00EE686E /* Friend.swift */, ); @@ -704,6 +767,7 @@ 524B842D2B46EBAE006D18BD /* View */ = { isa = PBXGroup; children = ( + 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */, 524B842E2B46EBBD006D18BD /* HomeView.swift */, ); path = View; @@ -726,7 +790,6 @@ 524B84312B46EF28006D18BD /* View */ = { isa = PBXGroup; children = ( - 4BF03C972D7819D30098C803 /* Academics */, 4B7DA5E92D71E0D7007354A3 /* Freinds */, 4B7DA5E82D71E0CE007354A3 /* Circles */, 524B84342B46F0FE006D18BD /* Components */, @@ -763,6 +826,7 @@ 524B843D2B46F705006D18BD /* Components */ = { isa = PBXGroup; children = ( + 4B341C112E18031E0073906B /* FreindRequestCard.swift */, 524B843B2B46F6FD006D18BD /* AddFriendsHeader.swift */, ); path = Components; @@ -890,6 +954,7 @@ 5D1FF2632A32643400B0620A /* Settings */ = { isa = PBXGroup; children = ( + 4B37F1E72E04172E00DCEE5F /* ViewModel */, 5D1FF2642A32643B00B0620A /* View */, ); path = Settings; @@ -1081,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 */, @@ -1095,26 +1161,40 @@ 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 */, 524B84332B46EF3A006D18BD /* ConnectPage.swift in Sources */, 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 */, + 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */, 520BA6482B4802EE00124850 /* AddFriendCard.swift in Sources */, 4B7DA5E32D70B2C3007354A3 /* Freinds.swift in Sources */, 4BBB00332D957A6A003B8FE2 /* NotesModel.swift in Sources */, @@ -1135,6 +1215,8 @@ 52D5AB862B6FE2ED00B2E66D /* AuthViewModel.swift in Sources */, 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 */, @@ -1144,6 +1226,8 @@ 52D5AB8F2B6FE82E00B2E66D /* AuthAPIService.swift in Sources */, 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; @@ -1154,6 +1238,8 @@ files = ( 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/Components/Alerts.swift b/VITTY/VITTY/Academics/Components/Alerts.swift new file mode 100644 index 0000000..7a2d2bc --- /dev/null +++ b/VITTY/VITTY/Academics/Components/Alerts.swift @@ -0,0 +1,112 @@ +// +// Alerts.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + +import SwiftUI + +struct DeleteNoteAlert: View { + let noteName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete note?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete '\(noteName)'?") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + +struct DeleteFileAlert: View { + let noteName: String + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Delete File?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to delete '\(noteName)'?") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} diff --git a/VITTY/VITTY/Academics/Components/FileUploadHelper.swift b/VITTY/VITTY/Academics/Components/FileUploadHelper.swift new file mode 100644 index 0000000..8500c36 --- /dev/null +++ b/VITTY/VITTY/Academics/Components/FileUploadHelper.swift @@ -0,0 +1,146 @@ +// +// FileUploadHelper.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Manager Helper +class FileManagerHelper { + static let shared = FileManagerHelper() + + private init() {} + + var documentsDirectory: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + + func createCourseDirectory(courseCode: String) -> URL { + let courseDir = documentsDirectory.appendingPathComponent("Courses/\(courseCode)") + try? FileManager.default.createDirectory(at: courseDir, withIntermediateDirectories: true) + return courseDir + } + + func saveFile(data: Data, fileName: String, courseCode: String) -> String? { + let courseDir = createCourseDirectory(courseCode: courseCode) + let fileURL = courseDir.appendingPathComponent(fileName) + + do { + try data.write(to: fileURL) + return fileURL.path + } catch { + print("Error saving file: \(error)") + return nil + } + } + + func loadFile(from path: String) -> Data? { + return FileManager.default.contents(atPath: path) + } + + + func fileExists(at path: String) -> Bool { + return FileManager.default.fileExists(atPath: path) + } + + func generateThumbnail(for imageData: Data, courseCode: String, fileName: String) -> String? { + guard let image = UIImage(data: imageData) else { return nil } + + let thumbnailSize = CGSize(width: 150, height: 150) + let renderer = UIGraphicsImageRenderer(size: thumbnailSize) + + let thumbnailImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: thumbnailSize)) + } + + guard let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.7) else { return nil } + + let thumbnailFileName = "thumb_\(fileName)" + return saveFile(data: thumbnailData, fileName: thumbnailFileName, courseCode: courseCode) + } + + func deleteFile(at path: String) { + try? FileManager.default.removeItem(atPath: path) + } + + func formatFileSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + + +extension FileManagerHelper { + + + func getValidFileURL(from storedPath: String, courseCode: String) -> URL? { + + if FileManager.default.fileExists(atPath: storedPath) { + return URL(fileURLWithPath: storedPath) + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: courseCode) + let reconstructedURL = courseDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: reconstructedURL.path) { + return reconstructedURL + } + + return nil + } + + + func updateFilePathsIfNeeded(files: [UploadedFile], modelContext: ModelContext) { + var hasChanges = false + + for file in files { + + if !fileExists(at: file.localPath) { + + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + + file.localPath = newPath + hasChanges = true + } + } + + + if let thumbnailPath = file.thumbnailPath, !fileExists(at: thumbnailPath) { + let thumbnailFileName = URL(fileURLWithPath: thumbnailPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newThumbnailPath = courseDir.appendingPathComponent(thumbnailFileName).path + + if fileExists(at: newThumbnailPath) { + file.thumbnailPath = newThumbnailPath + hasChanges = true + } + } + } + + + if hasChanges { + do { + try modelContext.save() + print("Updated file paths for \(files.count) files") + } catch { + print("Error updating file paths: \(error)") + } + } + } + + +} diff --git a/VITTY/VITTY/Academics/Model/CourseFile.swift b/VITTY/VITTY/Academics/Model/CourseFile.swift new file mode 100644 index 0000000..f6baf3c --- /dev/null +++ b/VITTY/VITTY/Academics/Model/CourseFile.swift @@ -0,0 +1,43 @@ +// +// CourseFile.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + + +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Model +@Model +class UploadedFile { + var id: UUID + var fileName: String + var fileType: String + var fileSize: Int64 + var courseName: String + var courseCode: String + var uploadDate: Date + var localPath: String + var thumbnailPath: String? + var isImage: Bool + + init(fileName: String, fileType: String, fileSize: Int64, courseName: String, courseCode: String, localPath: String, thumbnailPath: String? = nil, isImage: Bool = false) { + self.id = UUID() + self.fileName = fileName + self.fileType = fileType + self.fileSize = fileSize + self.courseName = courseName + self.courseCode = courseCode + self.uploadDate = Date() + self.localPath = localPath + self.thumbnailPath = thumbnailPath + self.isImage = isImage + } +} + diff --git a/VITTY/VITTY/Academics/Model/NotesModel.swift b/VITTY/VITTY/Academics/Model/NotesModel.swift index 2af20e1..9bb0e95 100644 --- a/VITTY/VITTY/Academics/Model/NotesModel.swift +++ b/VITTY/VITTY/Academics/Model/NotesModel.swift @@ -1,23 +1,16 @@ -// -// NotesModel.swift -// VITTY -// -// Created by Rujin Devkota on 3/27/25. -// - import Foundation +import SwiftData +@Model +class CreateNoteModel { + var noteName: String + var userName: String + var courseId: String + var courseName: String + var noteContent: String + var createdAt: Date -struct CreateNoteModel: Codable { - - let noteName: String - let userName: String - let courseId: String - let courseName: String - let noteContent: String - let createdAt: Date - - init( noteName: String, userName: String, courseId: String, courseName: String, noteContent: String, createdAt: Date = Date()) { + init(noteName: String, userName: String, courseId: String, courseName: String, noteContent: String, createdAt: Date = Date()) { self.noteName = noteName self.userName = userName self.courseId = courseId @@ -25,4 +18,83 @@ struct CreateNoteModel: Codable { self.noteContent = noteContent self.createdAt = createdAt } + enum CodingKeys: String, CodingKey { + case noteName, userName, courseId, courseName, noteContent, createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + noteName = try container.decode(String.self, forKey: .noteName) + userName = try container.decode(String.self, forKey: .userName) + courseId = try container.decode(String.self, forKey: .courseId) + courseName = try container.decode(String.self, forKey: .courseName) + noteContent = try container.decode(String.self, forKey: .noteContent) + createdAt = try container.decode(Date.self, forKey: .createdAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(noteName, forKey: .noteName) + try container.encode(userName, forKey: .userName) + try container.encode(courseId, forKey: .courseId) + try container.encode(courseName, forKey: .courseName) + try container.encode(noteContent, forKey: .noteContent) + try container.encode(createdAt, forKey: .createdAt) + } +} + + + +extension CreateNoteModel { + + private static var plainTextCache: [String: String] = [:] + private static var attributedStringCache: [String: NSAttributedString] = [:] + + var cachedPlainText: String { + let cacheKey = "\(self.courseId)_\(self.createdAt.timeIntervalSince1970)" + + if let cached = Self.plainTextCache[cacheKey] { + return cached + } + + let plainText = extractPlainText() + Self.plainTextCache[cacheKey] = plainText + return plainText + } + + var cachedAttributedString: NSAttributedString? { + let cacheKey = "\(self.courseId)_\(self.createdAt.timeIntervalSince1970)" + + if let cached = Self.attributedStringCache[cacheKey] { + return cached + } + + let attributedString = extractAttributedString() + if let attributedString = attributedString { + Self.attributedStringCache[cacheKey] = attributedString + } + return attributedString + } + + private func extractPlainText() -> String { + guard let data = Data(base64Encoded: noteContent), + let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + return noteContent + } + return attributedString.string + } + + private func extractAttributedString() -> NSAttributedString? { + guard let data = Data(base64Encoded: noteContent), + let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) else { + return NSAttributedString(string: noteContent) + } + return attributedString + } + + + static func clearCache() { + plainTextCache.removeAll() + attributedStringCache.removeAll() + } } diff --git a/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift b/VITTY/VITTY/Academics/VIewModel/AcademicsViewModel.swift index 8bd056d..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/VIewModel/ReminderNotifcationManager.swift b/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift new file mode 100644 index 0000000..5cf4d87 --- /dev/null +++ b/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift @@ -0,0 +1,62 @@ +// +// ReminderNotifcationManager.swift +// VITTY +// +// Created by Rujin Devkota on 6/18/25. +// + +import Foundation +import UserNotifications + +class NotificationManager { + static let shared = NotificationManager() + + private init() {} + + func requestAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + print("Notification authorization error: \(error)") + } else { + print("Notification permission granted: \(granted)") + } + } + } + + func scheduleNotification(title: String, body: String, date: Date, identifier: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let triggerDate = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false) + + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Failed to schedule notification: \(error)") + } + } + } + + func scheduleReminderNotifications(title: String, date: Date,subject: String) { + + scheduleNotification( + title: "\(subject): \(title)", + body: "Your scheduled reminder is now.", + date: date, + identifier: "\(title)-exact" + ) + + + let tenMinutesBefore = Calendar.current.date(byAdding: .minute, value: -10, to: date)! + scheduleNotification( + title: "Upcoming Reminder: \(title)", + body: "Your reminder is in 10 minutes.", + date: tenMinutesBefore, + identifier: "\(title)-early" + ) + } +} diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index c3ddf79..e43f3ab 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -1,7 +1,22 @@ +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + + +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + + import SwiftUI import SwiftData -struct CourseRefs: View { +struct OCourseRefs: View { +struct OCourseRefs: View { var courseName: String var courseInstitution: String var slot: String @@ -9,14 +24,59 @@ struct CourseRefs: 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? + @State private var preloadedAttributedString: NSAttributedString? + @State private var searchText = "" + @State private var showDeleteAlert = false + @State private var noteToDelete: CreateNoteModel? + @State private var fileToDelete: UploadedFile? + @State private var isLoadingNote = false + @State private var loadingNoteId: Date? + @State private var showimgDeleteAlert = false + // File upload related states + @State private var showFileUpload = false + @State private var showFileGallery = false + @State private var selectedContentType: ContentType = .notes + @State private var showExpandedFAB = false @Environment(\.dismiss) private var dismiss - + @Environment(\.modelContext) private var modelContext + @Environment(\.modelContext) private var modelContext + private let maxVisible = 4 - // Fetch remainders with dynamic predicate @Query private var filteredRemainders: [Remainder] + @Query private var courseNotes: [CreateNoteModel] + @Query private var courseFiles: [UploadedFile] + + enum ContentType: String, CaseIterable { + case notes = "Notes" + case files = "Files" + + var icon: String { + switch self { + case .notes: return "doc.text" + case .files: return "folder" + } + } + } + @Query private var courseFiles: [UploadedFile] + + enum ContentType: String, CaseIterable { + case notes = "Notes" + case files = "Files" + + var icon: String { + switch self { + case .notes: return "doc.text" + case .files: return "folder" + } + } + } init(courseName: String, courseInstitution: String, slot: String, courseCode: String) { self.courseName = courseName @@ -24,17 +84,76 @@ struct CourseRefs: View { self.slot = slot self.courseCode = courseCode - // Dynamic predicate and descriptor - let predicate = #Predicate { + let reminderPredicate = #Predicate { $0.subject == courseName && $0.isCompleted == false } + _filteredRemainders = Query( + FetchDescriptor(predicate: reminderPredicate, sortBy: [SortDescriptor(\.date, order: .forward)]) + ) - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.date, order: .forward)] + let notesPredicate = #Predicate { + $0.courseId == courseCode + $0.courseId == courseCode + } + _courseNotes = Query( + FetchDescriptor(predicate: notesPredicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + ) + + + let filesPredicate = #Predicate { + $0.courseCode == courseCode + } + _courseFiles = Query( + FetchDescriptor(predicate: filesPredicate, sortBy: [SortDescriptor(\.uploadDate, order: .reverse)]) ) + } - _filteredRemainders = Query(descriptor) + private var filteredNotes: [CreateNoteModel] { + if searchText.isEmpty { + return courseNotes + } else { + return courseNotes.filter { note in + note.noteName.localizedCaseInsensitiveContains(searchText) + } + } + } + + private var filteredFiles: [UploadedFile] { + if searchText.isEmpty { + return courseFiles + } else { + return courseFiles.filter { file in + file.fileName.localizedCaseInsensitiveContains(searchText) + } + } + + + 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 + } else { + return courseFiles.filter { file in + file.fileName.localizedCaseInsensitiveContains(searchText) + } + } } var body: some View { @@ -59,32 +178,91 @@ struct CourseRefs: View { .foregroundColor(.white) Spacer() - - Button(action: {}) { - Image(systemName: "magnifyingglass") - .foregroundColor(.white) - .font(.title2) + + + if selectedContentType == .files && !courseFiles.isEmpty { + Button("View All") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) + + + if selectedContentType == .files && !courseFiles.isEmpty { + Button("View All") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) } } .padding() HStack { Spacer() - TextField("Search", text: .constant("")) + TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) + TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) .padding(10) .frame(width: UIScreen.main.bounds.width * 0.85) .background(Color.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) + .foregroundColor(.white) + .foregroundColor(.white) Spacer() } - Spacer().frame(height: 20) + + + + + Spacer().frame(height: 15) + + + + + Spacer().frame(height: 15) Text("\(courseName) - \(courseInstitution)") .font(.title2) .bold() .foregroundColor(.white) .padding(.horizontal) + + HStack(spacing: 12) { + ForEach(ContentType.allCases, id: \.self) { contentType in + ContentTypeTab( + contentType: contentType, + isSelected: selectedContentType == contentType, + count: contentType == .notes ? filteredNotes.count : filteredFiles.count + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedContentType = contentType + searchText = "" + } + } + } + Spacer() + } + .padding(.horizontal) + .padding(.top, 10) + + 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) { @@ -104,65 +282,1011 @@ struct CourseRefs: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 15) { - CourseCardNotes(title: "Sample Title", description: "Data science and software engineering experience is recommended.") - CourseCardNotes(title: "More Information", description: "This certification is intended for you if you have both technical and non-technical backgrounds.") + if 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", + title: searchText.isEmpty ? "No notes found for this course" : "No notes match your search", + subtitle: searchText.isEmpty ? nil : "Try searching with different keywords" + ) + } else { + ForEach(filteredNotes, id: \.createdAt) { note in + CourseCardNotes( + title: note.noteName, + description: note.cachedPlainText, + isLoading: loadingNoteId == note.createdAt, + onDelete: { + noteToDelete = note + showDeleteAlert = true + } + ) + .onTapGesture { + openNote(note) + } + } + } + } else { + + if filteredFiles.isEmpty { + EmptyStateView( + icon: searchText.isEmpty ? "folder" : "magnifyingglass", + title: searchText.isEmpty ? "No files found for this course" : "No files match your search", + subtitle: searchText.isEmpty ? "Upload some files to get started" : "Try searching with different keywords" + ) + } else { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 12) { + ForEach(Array(filteredFiles.prefix(6)), id: \.id) { file in + CompactFileCard(file: file) { + + + showimgDeleteAlert = true + fileToDelete = file + } + } + } + + if filteredFiles.count > 6 { + Button("View All \(filteredFiles.count) Files") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) + .padding(.top, 8) + .frame(maxWidth: .infinity) + } + description: note.cachedPlainText, + isLoading: loadingNoteId == note.createdAt, + onDelete: { + noteToDelete = note + showDeleteAlert = true + } + ) + .onTapGesture { + openNote(note) + } + } + } + } else { + + if filteredFiles.isEmpty { + EmptyStateView( + icon: searchText.isEmpty ? "folder" : "magnifyingglass", + title: searchText.isEmpty ? "No files found for this course" : "No files match your search", + subtitle: searchText.isEmpty ? "Upload some files to get started" : "Try searching with different keywords" + ) + } else { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 12) { + ForEach(Array(filteredFiles.prefix(6)), id: \.id) { file in + CompactFileCard(file: file) { + + + showimgDeleteAlert = true + fileToDelete = file + } + } + } + + if filteredFiles.count > 6 { + Button("View All \(filteredFiles.count) Files") { + showFileGallery = true + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color("Secondary")) + .padding(.top, 8) + .frame(maxWidth: .infinity) + } + } + } } .padding() } } + 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) } } - } - .navigationBarHidden(true) - .edgesIgnoringSafeArea(.bottom) - .sheet(isPresented: $showBottomSheet) { - ZStack { - Color("Secondary").edgesIgnoringSafeArea(.all) - - HStack { - BottomSheetButton(icon: "upload", title: "Write Note") { - showBottomSheet = false - navigateToNotesEditor = true + + if showDeleteAlert { + DeleteNoteAlert( + noteName: noteToDelete?.noteName ?? "", + onCancel: { + showDeleteAlert = false + noteToDelete = nil + }, + onDelete: { + deleteNote() } - - BottomSheetButton(icon: "edit_document", title: "Upload File") - BottomSheetButton(icon: "alarm", title: "Set Reminder") { - showBottomSheet = false - showReminderSheet = true + ) + .zIndex(1) + } + if showimgDeleteAlert { + DeleteFileAlert( + noteName: noteToDelete?.noteName ?? "", + onCancel: { + showimgDeleteAlert = false + fileToDelete = nil + }, + onDelete: { + deleteFile() } + ) + .zIndex(1) + } + + + } + .onAppear { + print("this is course code") + print(courseCode) + }.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)]) } + .sheet(isPresented: $showFileUpload) { + FileUploadView(courseName: courseName, courseCode: courseCode) + } + .sheet(isPresented: $showFileGallery) { + FileGalleryView(courseCode: courseCode) + } + .sheet(isPresented: $showFileUpload) { + FileUploadView(courseName: courseName, courseCode: courseCode) + } + .sheet(isPresented: $showFileGallery) { + FileGalleryView(courseCode: courseCode) + } .navigationDestination(isPresented: $navigateToNotesEditor) { - NoteEditorView() + 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, + preloadedAttributedString: preloadedAttributedString, + courseCode: courseCode, + courseName: courseName, + courseIns: courseInstitution, + courseSlot: slot + ) + }) + } + } + + // MARK: - Note Loading Function + private func openNote(_ note: CreateNoteModel) { + guard !isLoadingNote else { return } + + isLoadingNote = true + loadingNoteId = note.createdAt + + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + + Task { @MainActor in + do { + let attributedString = try await loadNoteContent(note) + + selectedNote = note + preloadedAttributedString = attributedString + + try await Task.sleep(nanoseconds: 300_000_000) + + isLoadingNote = false + loadingNoteId = nil + showNotes = true + + } catch { + print("Error loading note: \(error)") + isLoadingNote = false + loadingNoteId = nil + + let errorFeedback = UINotificationFeedbackGenerator() + errorFeedback.notificationOccurred(.error) + } + } + } + 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 { + if let cachedAttributedString = note.cachedAttributedString { + return cachedAttributedString + } + + guard let data = Data(base64Encoded: note.noteContent) else { + throw NoteLoadingError.invalidData + } + + if let attributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { + return attributedString + } else { + throw NoteLoadingError.unarchiveFailed + } + } + + private func deleteNote() { + guard let note = noteToDelete else { return } + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + modelContext.delete(note) + + do { + try modelContext.save() + } catch { + print("Failed to delete note: \(error)") + } + + showDeleteAlert = false + noteToDelete = nil + } + + private func deleteFile(){ + guard let file = fileToDelete else{return} + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + modelContext.delete(file) + do { + try modelContext.save() + } catch { + print("Failed to delete file: \(error)") + } + showimgDeleteAlert = false + + } + +} + + + +struct ContentTypeTab: View { + let contentType: OCourseRefs.ContentType + let isSelected: Bool + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: contentType.icon) + .font(.system(size: 14)) + + Text(contentType.rawValue) + .font(.system(size: 14, weight: .medium)) + + if count > 0 { + Text("(\(count))") + .font(.system(size: 12)) + .opacity(0.8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color("Accent") : Color("Secondary")) + .foregroundColor(isSelected ? .black : .white) + .cornerRadius(20) + } + } +} + +struct EmptyStateView: View { + let icon: String + let title: String + let subtitle: String? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text(title) + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + .multilineTextAlignment(.center) + + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.gray.opacity(0.8)) + .font(.system(size: 14)) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } +} +struct CompactFileCard: View { + let file: UploadedFile + let onDelete: (() -> Void)? + + @State private var showFileViewer = false + @State private var showActionSheet = false + @State private var fileImage: UIImage? + @State private var imageLoadError = false + @State private var isLoading = true + + init(file: UploadedFile, onDelete: (() -> Void)? = nil) { + self.file = file + self.onDelete = onDelete + } + + var body: some View { + VStack(spacing: 8) { + + 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 + 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 ContentTypeTab: View { + let contentType: OCourseRefs.ContentType + let isSelected: Bool + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: contentType.icon) + .font(.system(size: 14)) + + Text(contentType.rawValue) + .font(.system(size: 14, weight: .medium)) + + if count > 0 { + Text("(\(count))") + .font(.system(size: 12)) + .opacity(0.8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color("Accent") : Color("Secondary")) + .foregroundColor(isSelected ? .black : .white) + .cornerRadius(20) + } + } +} + +struct EmptyStateView: View { + let icon: String + let title: String + let subtitle: String? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text(title) + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + .multilineTextAlignment(.center) + + if let subtitle = subtitle { + Text(subtitle) + .foregroundColor(.gray.opacity(0.8)) + .font(.system(size: 14)) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } +} +struct CompactFileCard: View { + let file: UploadedFile + let onDelete: (() -> Void)? + + @State private var showFileViewer = false + @State private var showActionSheet = false + @State private var fileImage: UIImage? + @State private var imageLoadError = false + @State private var isLoading = true + + init(file: UploadedFile, onDelete: (() -> Void)? = nil) { + self.file = file + self.onDelete = onDelete + } + + var body: some View { + VStack(spacing: 8) { + + 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 } @@ -221,6 +1345,8 @@ struct TagView: View { } } } + + struct MoreTagView: View { var count: Int @@ -235,27 +1361,127 @@ struct MoreTagView: View { } } - struct CourseCardNotes: View { var title: String var description: String - + var isLoading: Bool = false + var onDelete: () -> Void + + @State private var showComingSoonAlert = false + + var isLoading: Bool = false + var onDelete: () -> Void + + @State private var showComingSoonAlert = false + var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.headline) - .foregroundColor(.white) - .padding(.bottom, 5) - - Text(description) - .font(.subheadline) - .foregroundColor(.gray) - .lineLimit(2) + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(description) + .font(.subheadline) + .foregroundColor(.gray) + .lineLimit(2) + } + + Spacer() + + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + .padding(.trailing, 8) + } else { + Menu { + Button(role: .destructive) { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + showComingSoonAlert = true + } label: { + Label("Export Markdown", systemImage: "square.and.arrow.down") + } + + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundColor(.white) + .font(.system(size: 20, weight: .medium)) + .padding(8) + .clipShape(Circle()) + } + } + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(description) + .font(.subheadline) + .foregroundColor(.gray) + .lineLimit(2) + } + + Spacer() + + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + .padding(.trailing, 8) + } else { + Menu { + Button(role: .destructive) { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + let feedback = UISelectionFeedbackGenerator() + feedback.selectionChanged() + showComingSoonAlert = true + } label: { + Label("Export Markdown", systemImage: "square.and.arrow.down") + } + + } label: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + .foregroundColor(.white) + .font(.system(size: 20, weight: .medium)) + .padding(8) + .clipShape(Circle()) + } + } } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.2)) .cornerRadius(15) + .opacity(isLoading ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isLoading) + .alert("Feature coming soon", isPresented: $showComingSoonAlert) { + Button("OK", role: .cancel) { } + } + .opacity(isLoading ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isLoading) + .alert("Feature coming soon", isPresented: $showComingSoonAlert) { + Button("OK", role: .cancel) { } + } } } @@ -274,5 +1500,3 @@ struct RoundedCorner: Shape { return Path(path.cgPath) } } - - diff --git a/VITTY/VITTY/Academics/View/Courses.swift b/VITTY/VITTY/Academics/View/Courses.swift index 71a7421..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,42 +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) - HStack(spacing: 16) { - SemesterFilterButton(isSelected: isCurrentSemester, title: "Current Semester") - .onTapGesture { isCurrentSemester = true } - - SemesterFilterButton(isSelected: !isCurrentSemester, title: "All Semesters") - .onTapGesture { isCurrentSemester = false } - - Spacer() - } - .padding(.horizontal) - .padding(.top, 16) - - VStack(spacing: 16) { - ForEach(filtered) { course in - NavigationLink(destination: CourseRefs(courseName: course.title, courseInstitution: course.code,slot:course.slot,courseCode: course.code)) { - 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()) @@ -60,32 +58,30 @@ struct CoursesView: View { let currentSemester = determineSemester(for: Date()) - let groupedLectures = Dictionary(grouping: allLectures, by: { $0.name }) var result: [Course] = [] - for (title, lectures) in groupedLectures { - _ = lectures.map { $0.slot }.joined(separator: " + ") - let uniqueSlot = Set(lectures.map { $0.slot }).joined(separator: " + ") - _ = Set(lectures.map { $0.code }).joined(separator: " / ") - - - result.append( - Course( - title: title, - slot: uniqueSlot, - code: uniqueSlot, - semester: currentSemester, - isFavorite: false + for title in groupedLectures.keys.sorted() { + if let lectures = groupedLectures[title] { + let uniqueSlot = Set(lectures.map { $0.slot }).sorted().joined(separator: " + ") + let uniqueCode = Set(lectures.map { $0.code }).sorted().joined(separator: " / ") + + result.append( + Course( + title: title, + slot: uniqueSlot, + code: uniqueCode, + semester: currentSemester, + isFavorite: false + ) ) - ) + } } - return result + return result.sorted { $0.title < $1.title } } - private func determineSemester(for date: Date) -> String { let month = Calendar.current.component(.month, from: date) @@ -110,7 +106,6 @@ struct CoursesView: View { return "\(year)-\(String(format: "%02d", (year + 1) % 100))" } } - } struct SemesterFilterButton: View { @@ -141,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 1b97944..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() @@ -53,6 +59,14 @@ struct ReminderView: View { modelContext.insert(newReminder) try modelContext.save() print("Saved successfully") + + + NotificationManager.shared.scheduleReminderNotifications( + title: title, + date: startTime, + subject: courseName + ) + } catch { print("Failed to save: \(error.localizedDescription)") } @@ -93,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() @@ -210,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 new file mode 100644 index 0000000..fc9e2ca --- /dev/null +++ b/VITTY/VITTY/Academics/View/ExistingHotelView.swift @@ -0,0 +1,40 @@ +// +// ExistingHotelView.swift +// VITTY +// +// Created by Rujin Devkota on 6/19/25. +// + +import SwiftUI + +struct ExistingHotelView: View { + var existingNote: CreateNoteModel + + + + var body: some View { + VStack{ + Text("Note: \(existingNote)") + Text("Note name: \(existingNote.noteName)") + Text("user name: \(existingNote.userName)") + Text("course id: \(existingNote.courseId)") + Text("course name: \(existingNote.courseName)") + Text("note content: \(existingNote.noteContent)") + + Text("-----------------") + + if let attriString = try? AttributedString(markdown: existingNote.noteContent){ + Text("attr content: \(attriString)") + }else{ + Text("cant do baby doll") + } + + + + } + .onAppear { + print("existing hotel: \(existingNote)") + + } + } +} diff --git a/VITTY/VITTY/Academics/View/FileUpload.swift b/VITTY/VITTY/Academics/View/FileUpload.swift new file mode 100644 index 0000000..b720a52 --- /dev/null +++ b/VITTY/VITTY/Academics/View/FileUpload.swift @@ -0,0 +1,1243 @@ +// +// FileUpload.swift +// VITTY +// +// Created by Rujin Devkota on 6/25/25. +// + +import SwiftUI +import SwiftUI +import SwiftData +import PhotosUI +import UniformTypeIdentifiers +import QuickLook +import PDFKit + +// MARK: - File Upload View +struct FileUploadView: View { + let courseName: String + let courseCode: String + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var selectedImages: [PhotosPickerItem] = [] + @State private var showDocumentPicker = false + @State private var isUploading = false + @State private var uploadProgress: Double = 0 + @State private var showSuccessAlert = false + @State private var uploadedCount = 0 + @State private var showErrorAlert = false + @State private var errorMessage = "" + @State private var capturedImage: UIImage? + @State private var showCamera = false + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 30) { + // Header + VStack(spacing: 8) { + Image(systemName: "icloud.and.arrow.up") + .font(.system(size: 48)) + .foregroundColor(Color("Secondary")) + + Text("Upload Files") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + + Text("Add images and documents to \(courseName)") + .font(.subheadline) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + } + .padding(.top, 20) + + + VStack(spacing: 16) { + Button(action: { + showCamera = true + }) { + UploadOptionCard( + icon: "camera.fill", + title: "Take Photo", + description: "Capture new photos with camera", + color: .orange + ) + } + .disabled(isUploading) + + PhotosPicker( + selection: $selectedImages, + maxSelectionCount: 10, + matching: .images + ) { + UploadOptionCard( + icon: "photo.on.rectangle.angled", + title: "Upload Images", + description: "Take photos or select from gallery", + color: .blue + ) + } + .disabled(isUploading) + + + Button(action: { + showDocumentPicker = true + }) { + UploadOptionCard( + icon: "doc.fill", + title: "Upload Documents", + description: "Select PDF, Word, or other files", + color: .green + ) + } + .disabled(isUploading) + } + + + if isUploading { + VStack(spacing: 12) { + ProgressView(value: uploadProgress) + .progressViewStyle(LinearProgressViewStyle(tint: Color("Secondary"))) + + Text("Uploading files... (\(Int(uploadProgress * 100))%)") + .font(.subheadline) + .foregroundColor(.gray) + } + .padding(.horizontal) + } + + Spacer() + } + .padding(.horizontal) + } + .navigationTitle("Upload Files") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) .sheet(isPresented: $showCamera) { + CameraView(capturedImage: $capturedImage) + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundColor(.white) + .disabled(isUploading) + } + } + } + .onChange(of: selectedImages) { _, newItems in + if !newItems.isEmpty { + uploadImages(newItems) + } + } + .fileImporter( + isPresented: $showDocumentPicker, + allowedContentTypes: [.pdf, .plainText, .rtf, .rtfd, .data, .item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + uploadDocuments(urls) + case .failure(let error): + errorMessage = "Error selecting documents: \(error.localizedDescription)" + showErrorAlert = true + } + } + .alert("Upload Complete", isPresented: $showSuccessAlert) { + Button("OK") { + dismiss() + } + } message: { + Text("Successfully uploaded \(uploadedCount) file(s)") + } + .alert("Upload Error", isPresented: $showErrorAlert) { + Button("OK") { } + } message: { + Text(errorMessage) + } + } + + private func uploadImages(_ items: [PhotosPickerItem]) { + guard !items.isEmpty else { return } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + for (index, item) in items.enumerated() { + do { + if let data = try await item.loadTransferable(type: Data.self) { + let timestamp = Int(Date().timeIntervalSince1970) + let fileName = "image_\(timestamp)_\(index).jpg" + + if let savedPath = FileManagerHelper.shared.saveFile( + data: data, + fileName: fileName, + courseCode: courseCode + ) { + let thumbnailPath = FileManagerHelper.shared.generateThumbnail( + for: data, + courseCode: courseCode, + fileName: fileName + ) + + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: "jpg", + fileSize: Int64(data.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + thumbnailPath: thumbnailPath, + isImage: true + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount += 1 + } + } + } + } catch { + await MainActor.run { + errorMessage = "Failed to process image: \(error.localizedDescription)" + showErrorAlert = true + } + } + + await MainActor.run { + uploadProgress = Double(index + 1) / Double(items.count) + } + } + + await MainActor.run { + isUploading = false + selectedImages = [] + + if uploadedCount > 0 { + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving files: \(error.localizedDescription)" + showErrorAlert = true + } + } + } + } + } + + private func uploadDocuments(_ urls: [URL]) { + guard !urls.isEmpty else { return } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + for (index, url) in urls.enumerated() { + var canAccess = false + + if url.startAccessingSecurityScopedResource() { + canAccess = true + } + + defer { + if canAccess { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let data = try Data(contentsOf: url) + let fileName = url.lastPathComponent + let fileType = url.pathExtension + + if let savedPath = FileManagerHelper.shared.saveFile( + data: data, + fileName: fileName, + courseCode: courseCode + ) { + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: fileType, + fileSize: Int64(data.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + isImage: false + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount += 1 + } + } + } catch { + await MainActor.run { + errorMessage = "Failed to process document \(url.lastPathComponent): \(error.localizedDescription)" + showErrorAlert = true + } + } + + await MainActor.run { + uploadProgress = Double(index + 1) / Double(urls.count) + } + } + + await MainActor.run { + isUploading = false + + if uploadedCount > 0 { + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving files: \(error.localizedDescription)" + showErrorAlert = true + } + } + } + } + } + private func uploadCapturedImage(_ image: UIImage) { + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + errorMessage = "Failed to process captured image" + showErrorAlert = true + return + } + + isUploading = true + uploadProgress = 0 + uploadedCount = 0 + + Task { + let timestamp = Int(Date().timeIntervalSince1970) + let fileName = "camera_\(timestamp).jpg" + + if let savedPath = FileManagerHelper.shared.saveFile( + data: imageData, + fileName: fileName, + courseCode: courseCode + ) { + let thumbnailPath = FileManagerHelper.shared.generateThumbnail( + for: imageData, + courseCode: courseCode, + fileName: fileName + ) + + let uploadedFile = UploadedFile( + fileName: fileName, + fileType: "jpg", + fileSize: Int64(imageData.count), + courseName: courseName, + courseCode: courseCode, + localPath: savedPath, + thumbnailPath: thumbnailPath, + isImage: true + ) + + await MainActor.run { + modelContext.insert(uploadedFile) + uploadedCount = 1 + uploadProgress = 1.0 + isUploading = false + capturedImage = nil + + do { + try modelContext.save() + showSuccessAlert = true + } catch { + errorMessage = "Error saving captured image: \(error.localizedDescription)" + showErrorAlert = true + } + } + } else { + await MainActor.run { + isUploading = false + capturedImage = nil + errorMessage = "Failed to save captured image" + showErrorAlert = true + } + } + } + } + + +} +// MARK: - Camera View +struct CameraView: UIViewControllerRepresentable { + @Binding var capturedImage: UIImage? + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + picker.allowsEditing = true + picker.cameraCaptureMode = .photo + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let editedImage = info[.editedImage] as? UIImage { + parent.capturedImage = editedImage + } else if let originalImage = info[.originalImage] as? UIImage { + parent.capturedImage = originalImage + } + + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + + +// MARK: - Upload Option Card +struct UploadOptionCard: View { + let icon: String + let title: String + let description: String + let color: Color + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(color) + .frame(width: 40, height: 40) + .background(color.opacity(0.1)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(description) + .font(.subheadline) + .foregroundColor(.gray) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + .padding(16) + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + } +} + +// MARK: - File Gallery View +struct FileGalleryView: View { + let courseCode: String + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query private var files: [UploadedFile] + @State private var selectedFilter: FileFilter = .all + @State private var showDeleteAlert = false + @State private var fileToDelete: UploadedFile? + @State private var selectedFile: UploadedFile? + @State private var showFileViewer = false + + enum FileFilter: String, CaseIterable { + case all = "All" + case images = "Images" + case documents = "Documents" + + var icon: String { + switch self { + case .all: return "folder" + case .images: return "photo" + case .documents: return "doc" + } + } + } + + init(courseCode: String) { + self.courseCode = courseCode + let predicate = #Predicate { file in + file.courseCode == courseCode + } + _files = Query( + FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.uploadDate, order: .reverse)] + ) + ) + } + + private var filteredFiles: [UploadedFile] { + switch selectedFilter { + case .all: + return files + case .images: + return files.filter { $0.isImage } + case .documents: + return files.filter { !$0.isImage } + } + } + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(FileFilter.allCases, id: \.self) { filter in + FilterTab( + filter: filter, + isSelected: selectedFilter == filter + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedFilter = filter + } + } + } + } + .padding(.horizontal) + } + .padding(.bottom, 16) + + + if filteredFiles.isEmpty { + VStack(spacing: 16) { + Image(systemName: selectedFilter.icon) + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.6)) + + Text("No \(selectedFilter.rawValue.lowercased()) found") + .foregroundColor(.gray) + .font(.system(size: 16, weight: .medium)) + + Text("Upload some files to get started") + .foregroundColor(.gray.opacity(0.7)) + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + ForEach(filteredFiles, id: \.id) { file in + FileCard(file: file) { + selectedFile = file + showFileViewer = true + } onDelete: { + fileToDelete = file + showDeleteAlert = true + } + } + } + .padding(.horizontal) + .padding(.bottom, 20) + } + } + } + } + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Back") { + dismiss() + } + .foregroundColor(.white) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Text("\(filteredFiles.count) files") + .font(.caption) + .foregroundColor(.gray) + } + } + } + .alert("Delete File", isPresented: $showDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + deleteFile() + } + } message: { + Text("Are you sure you want to delete '\(fileToDelete?.fileName ?? "")'?") + } + .sheet(isPresented: $showFileViewer) { + if let file = selectedFile { + EnhancedFileViewerSheet(file: file) + } + } + } + + private func deleteFile() { + guard let file = fileToDelete else { return } + + + FileManagerHelper.shared.deleteFile(at: file.localPath) + if let thumbnailPath = file.thumbnailPath { + FileManagerHelper.shared.deleteFile(at: thumbnailPath) + } + + + modelContext.delete(file) + + do { + try modelContext.save() + } catch { + print("Error deleting file: \(error)") + } + + fileToDelete = nil + } +} + +// MARK: - Filter Tab +struct FilterTab: View { + let filter: FileGalleryView.FileFilter + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: filter.icon) + .font(.system(size: 14)) + + Text(filter.rawValue) + .font(.system(size: 14, weight: .medium)) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color("Secondary") : Color.white.opacity(0.1)) + .foregroundColor(isSelected ? .black : .white) + .cornerRadius(20) + } + } +} +// MARK: - File Card +struct FileCard: View { + let file: UploadedFile + let onTap: () -> Void + let onDelete: () -> Void + + @State private var imageLoadError = false + @State private var showActionSheet = false + + var body: some View { + VStack(spacing: 0) { + + if file.isImage && !imageLoadError { + Group { + if let thumbnailPath = file.thumbnailPath, + let thumbnailURL = getValidFileURL(from: thumbnailPath) { + AsyncImage(url: thumbnailURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } else if let fileURL = getValidFileURL(from: file.localPath) { + AsyncImage(url: fileURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } else { + Rectangle() + .fill(Color.red.opacity(0.3)) + .overlay( + VStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("File not found") + .font(.caption2) + .foregroundColor(.red) + } + ) + } + } + .frame(height: 120) + .clipped() + .onAppear { + + if getValidFileURL(from: file.localPath) == nil { + imageLoadError = true + } + } + } else { + Rectangle() + .fill(getFileTypeColor(file.fileType).opacity(0.1)) + .frame(height: 120) + .overlay( + VStack(spacing: 8) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 32)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileType.uppercased()) + .font(.caption) + .fontWeight(.bold) + .foregroundColor(getFileTypeColor(file.fileType)) + } + ) + } + + + VStack(alignment: .leading, spacing: 4) { + Text(file.fileName) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(FileManagerHelper.shared.formatFileSize(file.fileSize)) + .font(.caption2) + .foregroundColor(.gray) + + Spacer() + + Text(file.uploadDate, style: .date) + .font(.caption2) + .foregroundColor(.gray) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .onTapGesture { + onTap() + } + .onLongPressGesture(minimumDuration: 0.5) { + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + + showActionSheet = true + } + .confirmationDialog("File Options", isPresented: $showActionSheet, titleVisibility: .visible) { + Button("Share") { + shareFile() + } + + Button("Delete", role: .destructive) { + onDelete() + } + + Button("Cancel", role: .cancel) { + + } + } message: { + Text("Choose an action for \(file.fileName)") + } + } + + // MARK: - Helper Methods + + + private func getValidFileURL(from storedPath: String) -> URL? { + + if FileManager.default.fileExists(atPath: storedPath) { + return URL(fileURLWithPath: storedPath) + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let currentDocumentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let courseDir = currentDocumentsDir.appendingPathComponent("Courses/\(file.courseCode)") + let reconstructedURL = courseDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: reconstructedURL.path) { + return reconstructedURL + } + + return nil + } + + + private func shareFile() { + guard let fileURL = getValidFileURL(from: file.localPath) else { + print("Cannot share file: File not found") + return + } + + let activityVC = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil) + + + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray + } + } +} + +// MARK: - Updated FileManagerHelper +extension FileManagerHelper { + + func updateFilePathsIfNeeded(for file: UploadedFile) -> Bool { + + if fileExists(at: file.localPath) { + return true + } + + + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + + return true + } + + return false + } +} + +struct EnhancedFileViewerSheet: View { + let file: UploadedFile + + @Environment(\.dismiss) private var dismiss + @State private var fileData: Data? + @State private var isLoading = true + @State private var showShareSheet = false + @State private var loadError: String? + @State private var showQuickLook = false + @State private var temporaryFileURL: URL? + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + if isLoading { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + + Text("Loading file...") + .foregroundColor(.gray) + } + } else if let error = loadError { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Unable to load file") + .foregroundColor(.white) + .font(.headline) + + Text(error) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding() + } else if let data = fileData { + contentView(for: data) + } + } + .navigationTitle(file.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done") { + dismiss() + } + .foregroundColor(.white) + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack { + if file.fileType.lowercased() == "pdf" { + Button("Open") { + showQuickLook = true + } + .foregroundColor(.white) + } + + Button("Share") { + showShareSheet = true + } + .foregroundColor(.white) + } + } + } + } + .onAppear { + loadFileData() + } + .sheet(isPresented: $showShareSheet) { + if let url = temporaryFileURL { + ShareSheet(items: [url]) + } + } + .sheet(isPresented: $showQuickLook) { + if let url = temporaryFileURL { + QuickLookView(url: url) + } + } + } + + @ViewBuilder + private func contentView(for data: Data) -> some View { + if file.isImage, let image = UIImage(data: data) { + + ScrollView([.horizontal, .vertical]) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + } + } else if file.fileType.lowercased() == "pdf" { + + PDFViewerWrapper(data: data) + } else if file.fileType.lowercased() == "txt" { + + if let text = String(data: data, encoding: .utf8) { + ScrollView { + Text(text) + .foregroundColor(.white) + .font(.system(size: 14)) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + fileInfoView(data: data) + } + } else { + + fileInfoView(data: data) + } + } + + @ViewBuilder + private func fileInfoView(data: Data) -> some View { + VStack(spacing: 20) { + Image(systemName: getFileTypeIcon(file.fileType)) + .font(.system(size: 64)) + .foregroundColor(getFileTypeColor(file.fileType)) + + Text(file.fileName) + .font(.headline) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + VStack(spacing: 8) { + Text("File size: \(FileManagerHelper.shared.formatFileSize(file.fileSize))") + .font(.subheadline) + .foregroundColor(.gray) + + Text("Type: \(file.fileType.uppercased())") + .font(.subheadline) + .foregroundColor(.gray) + + Text("Uploaded: \(file.uploadDate, style: .date)") + .font(.subheadline) + .foregroundColor(.gray) + } + + Button("Open with External App") { + showQuickLook = true + } + .padding() + .background(Color("Secondary")) + .foregroundColor(.black) + .cornerRadius(10) + } + .padding() + } + + private func loadFileData() { + Task { + + let data = FileManagerHelper.shared.loadFileWithFallback(from: file.localPath, courseCode: file.courseCode) + + await MainActor.run { + if let data = data { + fileData = data + createTemporaryFile(data: data) + } else { + loadError = "File not found or corrupted" + } + isLoading = false + } + } + } + + private func createTemporaryFile(data: Data) { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(file.fileName) + + do { + if FileManager.default.fileExists(atPath: tempURL.path) { + try FileManager.default.removeItem(at: tempURL) + } + try data.write(to: tempURL) + temporaryFileURL = tempURL + } catch { + print("Error creating temporary file: \(error)") + } + } + + private func getFileTypeIcon(_ fileType: String) -> String { + switch fileType.lowercased() { + case "pdf": + return "doc.richtext.fill" + case "txt": + return "doc.text.fill" + case "rtf", "rtfd": + return "doc.richtext.fill" + case "doc", "docx": + return "doc.fill" + case "jpg", "jpeg", "png", "gif", "heic": + return "photo.fill" + default: + return "doc.fill" + } + } + + private func getFileTypeColor(_ fileType: String) -> Color { + switch fileType.lowercased() { + case "pdf": + return .red + case "txt": + return .blue + case "rtf", "rtfd": + return .purple + case "doc", "docx": + return .blue + case "jpg", "jpeg", "png", "gif", "heic": + return .green + default: + return .gray + } + } +} +struct PDFViewerWrapper: UIViewRepresentable { + let data: Data + + func makeUIView(context: Context) -> PDFView { + let pdfView = PDFView() + pdfView.backgroundColor = UIColor.clear + pdfView.autoScales = true + pdfView.displayMode = .singlePageContinuous + pdfView.displayDirection = .vertical + + if let document = PDFDocument(data: data) { + pdfView.document = document + } + + return pdfView + } + + func updateUIView(_ uiView: PDFView, context: Context) { + + } +} + +// MARK: - QuickLook View +struct QuickLookView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, QLPreviewControllerDataSource { + let parent: QuickLookView + + init(_ parent: QuickLookView) { + self.parent = parent + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + return 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + return parent.url as NSURL + } + } +} + +// MARK: - Enhanced FileManagerHelper Extension +extension FileManagerHelper { + + + func loadFileWithFallback(from storedPath: String, courseCode: String) -> Data? { + + if fileExists(at: storedPath), let data = loadFile(from: storedPath) { + return data + } + + + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: courseCode) + let reconstructedPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: reconstructedPath), let data = loadFile(from: reconstructedPath) { + return data + } + + + return findAndLoadFile(fileName: fileName, in: courseDir) + } + + + private func findAndLoadFile(fileName: String, in directory: URL) -> Data? { + do { + let contents = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + + for fileURL in contents { + if fileURL.lastPathComponent == fileName { + return try? Data(contentsOf: fileURL) + } + } + } catch { + print("Error searching directory: \(error)") + } + + return nil + } + + + func updateStoredFilePaths(files: [UploadedFile], modelContext: ModelContext) { + var hasChanges = false + + for file in files { + if !fileExists(at: file.localPath) { + let fileName = URL(fileURLWithPath: file.localPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newPath = courseDir.appendingPathComponent(fileName).path + + if fileExists(at: newPath) { + file.localPath = newPath + hasChanges = true + } + } + + + if let thumbnailPath = file.thumbnailPath, !fileExists(at: thumbnailPath) { + let thumbnailFileName = URL(fileURLWithPath: thumbnailPath).lastPathComponent + let courseDir = createCourseDirectory(courseCode: file.courseCode) + let newThumbnailPath = courseDir.appendingPathComponent(thumbnailFileName).path + + if fileExists(at: newThumbnailPath) { + file.thumbnailPath = newThumbnailPath + hasChanges = true + } + } + } + + if hasChanges { + do { + try modelContext.save() + print("Updated \(files.count) file paths") + } catch { + print("Error updating file paths: \(error)") + } + } + } +} + + +// MARK: - Share Sheet +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + + } +} diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index 829f904..50465eb 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -1,3 +1,15 @@ +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + +// +// Academics.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. + import SwiftUI import UIKit @@ -7,6 +19,8 @@ struct RichTextView: UIViewRepresentable { @Binding var typingAttributes: [NSAttributedString.Key: Any] @Binding var isEmpty: Bool + + func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.isEditable = true @@ -16,13 +30,57 @@ struct RichTextView: UIViewRepresentable { textView.typingAttributes = typingAttributes textView.backgroundColor = .clear textView.textColor = .white + + + + textView.attributedText = attributedText + textView.selectedRange = selectedRange + return textView } func updateUIView(_ uiView: UITextView, context: Context) { - uiView.attributedText = attributedText - uiView.selectedRange = selectedRange - uiView.typingAttributes = typingAttributes + + + 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 { + context.coordinator.isUpdating = true + uiView.selectedRange = selectedRange + context.coordinator.isUpdating = false + } + + + + if !NSDictionary(dictionary: uiView.typingAttributes).isEqual(to: typingAttributes) { + uiView.typingAttributes = typingAttributes + } } func makeCoordinator() -> Coordinator { @@ -31,65 +89,263 @@ struct RichTextView: UIViewRepresentable { class Coordinator: NSObject, UITextViewDelegate { var parent: RichTextView + var isUpdating = false init(_ parent: RichTextView) { self.parent = parent } func textViewDidChange(_ textView: UITextView) { + + + guard !isUpdating else { return } + + isUpdating = true + defer { isUpdating = false } + + + parent.attributedText = NSMutableAttributedString(attributedString: textView.attributedText) - // Update isEmpty state based on text content 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 + defer { isUpdating = false } + parent.selectedRange = textView.selectedRange + + + if textView.selectedRange.length == 0 && textView.selectedRange.location > 0 { + + let location = min(textView.selectedRange.location - 1, textView.attributedText.length - 1) + if location >= 0 { + let attributes = textView.attributedText.attributes(at: location, effectiveRange: nil) + parent.typingAttributes = attributes + } + } + } + } +} + + + + + + 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() // Start with empty text + @State private var attributedText = NSMutableAttributedString() @State private var selectedRange = NSRange(location: 0, length: 0) @State private var typingAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.white ] + + let existingNote: CreateNoteModel? + 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 @State private var showFontSizePicker = false - @State private var isEmpty = true // Track if the text view is empty + @State private var isEmpty = true + @State private var hasUnsavedChanges = false + @State private var isInitialized = false + @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 + self.courseSlot = courseSlot + } + private func handleBackNavigation() { + + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if presentationMode.wrappedValue.isPresented { + presentationMode.wrappedValue.dismiss() + } + } + } + private func initializeContent() { + guard !isInitialized else { return } + + if let note = existingNote { + 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 + } + if let preloaded = preloadedAttributedString { + + attributedText = NSMutableAttributedString(attributedString: preloaded) + isEmpty = preloaded.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + isInitialized = true + } else { + + Task { @MainActor in + await loadNoteContent(note) + isInitialized = true + } + } + } else { + + + attributedText = NSMutableAttributedString() + isEmpty = true + isInitialized = true + } + } + + @MainActor + private func loadNoteContent(_ note: CreateNoteModel) async { + + if let cachedAttributedString = note.cachedAttributedString { + attributedText = NSMutableAttributedString(attributedString: cachedAttributedString) + isEmpty = cachedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return + } + + + do { + + 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") + attributedText = NSMutableAttributedString(string: note.noteContent) + isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return + } + + if let loadedAttributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { + attributedText = NSMutableAttributedString(attributedString: loadedAttributedString) + isEmpty = loadedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } else { + print("Failed to unarchive attributed string") + attributedText = NSMutableAttributedString(string: note.noteContent) + isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } catch { + print("Error loading note content: \(error)") + attributedText = NSMutableAttributedString(string: note.noteContent) + isEmpty = note.noteContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } func saveContent() { - let markdown = attributedText.toMarkdown() - let note = CreateNoteModel( - noteName:"", - userName: "", - courseId:"", - courseName: "", - noteContent: markdown, - createdAt: Date.now - ) - - let uRL = URL(string: "\(APIConstants.base_url)notes/save")! + guard hasUnsavedChanges || existingNote == nil else { + handleBackNavigation() + handleBackNavigation() + return + } + - academicsViewModel.createNote(at: uRL , - authToken:authViewModel.loggedInBackendUser?.token ?? "", note: note) + do { + let data = try NSKeyedArchiver.archivedData(withRootObject: attributedText, requiringSecureCoding: false) + let dataString = data.base64EncodedString() + let title = generateSmartTitle(from: attributedText.string) + + if let note = existingNote { + note.noteName = title + note.noteContent = dataString + note.createdAt = Date.now + + CreateNoteModel.clearCache() + + CreateNoteModel.clearCache() + } else { + let newNote = CreateNoteModel( + noteName: title, + userName: authViewModel.loggedInBackendUser?.name ?? "", + courseId: courseCode, + courseName: courseName, + noteContent: dataString, + createdAt: Date.now + ) + modelContext.insert(newNote) + } + + try modelContext.save() + print("Note saved/updated in SwiftData.") + hasUnsavedChanges = false + dismiss() + } catch { + print("Error saving note: \(error)") + } } + func generateSmartTitle(from plainText: String) -> String { + let lines = plainText.components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + if let firstLine = lines.first { + return String(firstLine.prefix(40)).trimmingCharacters(in: .whitespaces) + } + return "Untitled Note" + } + private let fonts: [UIFont] = [ UIFont.systemFont(ofSize: 18), - UIFont(name: "Times New Roman", size: 18)!, - UIFont(name: "Helvetica", size: 18)!, - UIFont(name: "Courier", size: 18)! + UIFont(name: "Times New Roman", size: 18) ?? UIFont.systemFont(ofSize: 18), + UIFont(name: "Helvetica", size: 18) ?? UIFont.systemFont(ofSize: 18), + UIFont(name: "Courier", size: 18) ?? UIFont.systemFont(ofSize: 18) ] - // Font sizes for the aA picker private let fontSizes: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 42, 48] var body: some View { @@ -97,212 +353,249 @@ struct NoteEditorView: View { Color("Background") .edgesIgnoringSafeArea(.all) - VStack { - HStack { - Button(action: { - dismiss() - }) { - Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) - } - Spacer() - Text("Note") - .foregroundColor(.white) - .font(.system(size: 25,weight: Font.Weight.bold)) - Spacer() - Button(action:{ - saveContent() - }){ - Image("save").resizable().frame(width: 30,height: 30) - } + if isInitialized { + VStack { + + + headerView + + + + textEditorView + + + + toolbarView } - .padding() + } else { + + + ProgressView("Loading...") + .foregroundColor(.white) + } - ZStack(alignment: .topLeading) { - RichTextView( - attributedText: $attributedText, - selectedRange: $selectedRange, - typingAttributes: $typingAttributes, - isEmpty: $isEmpty - ) - .padding() - .frame(maxHeight: .infinity) - - // Placeholder overlay - if isEmpty { - Text("Start typing here...") - .foregroundColor(.gray.opacity(0.6)) - .font(.system(size: 18)) - .padding(.horizontal, 20) - .padding(.vertical, 24) - .allowsHitTesting(false) // Allow taps to pass through to the text view - } + + + if showFontPicker { + fontPickerOverlay + } + + if showFontSizePicker { + fontSizePickerOverlay + } + } + .onAppear { + initializeContent() + } + .onChange(of: attributedText) { _, _ in + if isInitialized { + hasUnsavedChanges = true + } + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .animation(.easeInOut(duration: 0.3), value: showFontPicker) + .animation(.easeInOut(duration: 0.3), value: showFontSizePicker) + } + + // MARK: - View Components + + private var headerView: some View { + HStack { + Button(action: { handleBackNavigation() }) { + Button(action: { handleBackNavigation() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + .foregroundColor(Color("Accent")).font(.title2) + } + Spacer() + Text("Note") + .foregroundColor(.white) + .font(.system(size: 25, weight: .bold)) + Spacer() + Button(action: { saveContent() }) { + Image("save") + .resizable() + .frame(width: 30, height: 30) + } + } + .padding() + } + + private var textEditorView: some View { + ZStack(alignment: .topLeading) { + RichTextView( + attributedText: $attributedText, + selectedRange: $selectedRange, + typingAttributes: $typingAttributes, + isEmpty: $isEmpty + ) + .padding() + .frame(maxHeight: .infinity) + + if isEmpty { + Text("Start typing here...") + .foregroundColor(.gray.opacity(0.6)) + .font(.system(size: 18)) + .padding(.horizontal, 20) + .padding(.vertical, 24) + .allowsHitTesting(false) + } + } + } + + private var toolbarView: some View { + HStack(spacing: 20) { + + + Button(action: { + showFontPicker.toggle() + showFontSizePicker = false + }) { + Image(systemName: "textformat") + .foregroundColor(Color("Accent")) + } + + + + Button(action: { + showFontSizePicker.toggle() + showFontPicker = false + }) { + HStack(spacing: 2) { + Text("a") + .font(.system(size: 12)) + .foregroundColor(Color("Accent")) + Text("A") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(Color("Accent")) } + } + + + + formatButton(action: toggleBold, icon: "bold", isActive: isBoldActive()) + formatButton(action: toggleItalic, icon: "italic", isActive: isItalicActive()) + formatButton(action: toggleUnderline, icon: "underline", isActive: isUnderlineActive()) - HStack(spacing: 20) { - // Font family picker - Button(action: { - showFontPicker.toggle() - showFontSizePicker = false - }) { - Image(systemName: "textformat") - .foregroundColor(Color("Accent")) - } - - // Font size picker (aA icon) + + + ColorPicker("", selection: $selectedColor, supportsOpacity: false) + .labelsHidden() + .frame(width: 30, height: 30) + .onChange(of: selectedColor) { _, newColor in + applyAttribute(.foregroundColor, value: UIColor(newColor)) + } + + + + Button(action: addBulletPoints) { + Image(systemName: "list.bullet") + .foregroundColor(Color("Accent")) + } + } + .padding() + .background(Color("Background").opacity(0.8)) + } + + private func formatButton(action: @escaping () -> Void, icon: String, isActive: Bool) -> some View { + Button(action: action) { + Image(systemName: icon) + .foregroundColor(Color("Accent")) + .padding(8) + .background(isActive ? Color("Accent").opacity(0.2) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isActive ? Color("Accent") : Color.clear, lineWidth: 1) + ) + } + } + + // MARK: - Overlay Views + + private var fontPickerOverlay: some View { + VStack { + Spacer() + VStack(spacing: 0) { + ForEach(fonts, id: \.fontName) { font in Button(action: { - showFontSizePicker.toggle() + selectedFont = font + applyFontFamily(font) showFontPicker = false }) { - HStack(spacing: 2) { - Text("a") - .font(.system(size: 12)) - .foregroundColor(Color("Accent")) - Text("A") - .font(.system(size: 18, weight: .bold)) - .foregroundColor(Color("Accent")) - } - } - - Button(action: { toggleBold() }) { - Image(systemName: "bold") - .foregroundColor(Color("Accent")) - .padding(8) - .background(isBoldActive() ? Color("Accent").opacity(0.2) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isBoldActive() ? Color("Accent") : Color.clear, lineWidth: 1) - ) - } - - Button(action: { toggleItalic() }) { - Image(systemName: "italic") - .foregroundColor(Color("Accent")) - .padding(8) - .background(isItalicActive() ? Color("Accent").opacity(0.2) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isItalicActive() ? Color("Accent") : Color.clear, lineWidth: 1) - ) - } - - Button(action: { toggleUnderline() }) { - Image(systemName: "underline") - .foregroundColor(Color("Accent")) - .padding(8) - .background(isUnderlineActive() ? Color("Accent").opacity(0.2) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isUnderlineActive() ? Color("Accent") : Color.clear, lineWidth: 1) - ) - } - - ColorPicker("", selection: $selectedColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 30, height: 30) - .onChange(of: selectedColor) { newColor in - applyAttribute(.foregroundColor, value: UIColor(newColor)) - } - - Button(action: { addBulletPoints() }) { - Image(systemName: "list.bullet") - .foregroundColor(Color("Accent")) + Text(font.fontName.replacingOccurrences(of: "-", with: " ")) + .foregroundColor(.white) + .font(Font(font as CTFont)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } - } - .padding() - .background(Color("Background").opacity(0.8)) - } - - // Font family picker overlay - if showFontPicker { - VStack { - Spacer() - VStack(spacing: 0) { - ForEach(fonts, id: \.fontName) { font in - Button(action: { - selectedFont = font - applyFontFamily(font) - showFontPicker = false - }) { - Text(font.fontName.replacingOccurrences(of: "-", with: " ")) - .foregroundColor(.white) - .font(Font(font as CTFont)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - if font != fonts.last { - Divider().background(Color.gray.opacity(0.3)) - } - } + if font != fonts.last { + Divider().background(Color.gray.opacity(0.3)) } - .background(Color("Background")) - .cornerRadius(10) - .shadow(color: .black.opacity(0.3), radius: 10) - .padding(.horizontal, 40) - .padding(.bottom, 100) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - .background(Color.black.opacity(0.3)) - .onTapGesture { - showFontPicker = false } } - - // Font size picker overlay - if showFontSizePicker { - VStack { - Spacer() - ScrollView { - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 10) { - ForEach(fontSizes, id: \.self) { size in - Button(action: { - applyFontSize(size) - showFontSizePicker = false - }) { - Text("\(Int(size))") - .foregroundColor(.white) - .font(.system(size: min(size, 24))) - .frame(width: 50, height: 40) - .background(Color("Accent").opacity(0.2)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color("Accent"), lineWidth: 1) - ) - } - } + .background(Color("Background")) + .cornerRadius(10) + .shadow(color: .black.opacity(0.3), radius: 10) + .padding(.horizontal, 40) + .padding(.bottom, 100) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + .background(Color.black.opacity(0.3)) + .onTapGesture { + showFontPicker = false + } + } + + private var fontSizePickerOverlay: some View { + VStack { + Spacer() + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 10) { + ForEach(fontSizes, id: \.self) { size in + Button(action: { + applyFontSize(size) + showFontSizePicker = false + }) { + Text("\(Int(size))") + .foregroundColor(.white) + .font(.system(size: min(size, 24))) + .frame(width: 50, height: 40) + .background(Color("Accent").opacity(0.2)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color("Accent"), lineWidth: 1) + ) } - .padding() } - .frame(maxHeight: 300) - .background(Color("Background")) - .cornerRadius(10) - .shadow(color: .black.opacity(0.3), radius: 10) - .padding(.horizontal, 40) - .padding(.bottom, 100) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - .background(Color.black.opacity(0.3)) - .onTapGesture { - showFontSizePicker = false } + .padding() } + .frame(maxHeight: 300) + .background(Color("Background")) + .cornerRadius(10) + .shadow(color: .black.opacity(0.3), radius: 10) + .padding(.horizontal, 40) + .padding(.bottom, 100) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + .background(Color.black.opacity(0.3)) + .onTapGesture { + showFontSizePicker = false } - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .animation(.easeInOut(duration: 0.3), value: showFontPicker) - .animation(.easeInOut(duration: 0.3), value: showFontSizePicker) } + + + func addBulletPoints() { guard selectedRange.length > 0 else { return } let selectedText = attributedText.attributedSubstring(from: selectedRange).string let lines = selectedText.components(separatedBy: "\n") - var bulletedText = lines.map { "• \($0)" }.joined(separator: "\n") + let bulletedText = lines.map { "• \($0)" }.joined(separator: "\n") let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) mutableAttributedString.replaceCharacters(in: selectedRange, with: bulletedText) @@ -312,97 +605,285 @@ struct NoteEditorView: View { } func isBoldActive() -> Bool { + return checkTraitActive(.traitBold) + return checkTraitActive(.traitBold) + } + + func isItalicActive() -> Bool { + return checkTraitActive(.traitItalic) + return checkTraitActive(.traitItalic) + } + + func isUnderlineActive() -> Bool { + let underline = getCurrentUnderlineStyle() + return underline == NSUnderlineStyle.single.rawValue + } + + private func checkTraitActive(_ trait: UIFontDescriptor.SymbolicTraits) -> Bool { if selectedRange.length > 0 { - if let font = attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont { - return font.fontDescriptor.symbolicTraits.contains(.traitBold) + 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(.traitBold) + return font.fontDescriptor.symbolicTraits.contains(trait) } + return false } - return false } - - func isItalicActive() -> Bool { + + private func checkTraitActive(_ trait: UIFontDescriptor.SymbolicTraits) -> Bool { if selectedRange.length > 0 { - if let font = attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont { - return font.fontDescriptor.symbolicTraits.contains(.traitItalic) + 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(.traitItalic) + return font.fontDescriptor.symbolicTraits.contains(trait) } + return false } - return false } - - func isUnderlineActive() -> Bool { - if selectedRange.length > 0 { - if let underline = attributedText.attribute(.underlineStyle, at: selectedRange.location, effectiveRange: nil) as? Int { - return underline == NSUnderlineStyle.single.rawValue - } + + private func getCurrentFont() -> UIFont { + if selectedRange.length > 0 && selectedRange.location < attributedText.length { + return attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont ?? UIFont.systemFont(ofSize: 18) } else { - if let underline = typingAttributes[.underlineStyle] as? Int { - return underline == NSUnderlineStyle.single.rawValue - } + return typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + } + } + + private func getCurrentUnderlineStyle() -> Int { + if selectedRange.length > 0 && selectedRange.location < attributedText.length { + return attributedText.attribute(.underlineStyle, at: selectedRange.location, effectiveRange: nil) as? Int ?? 0 + } else { + return typingAttributes[.underlineStyle] as? Int ?? 0 } - return false } func applyFontFamily(_ font: UIFont) { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) - let newFont = UIFont(name: font.fontName, size: currentFont.pointSize) ?? font + 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) } func applyFontSize(_ size: CGFloat) { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) + let currentFont = getCurrentFont() let newFont = UIFont(descriptor: currentFont.fontDescriptor, size: size) applyAttribute(.font, value: newFont) } func toggleBold() { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitBold) { - traits.remove(.traitBold) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString } else { - traits.insert(.traitBold) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - applyAttribute(.font, value: newFont) + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString + } else { + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitBold) { + traits.remove(.traitBold) + } else { + traits.insert(.traitBold) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } } + hasUnsavedChanges = true + hasUnsavedChanges = true } func toggleItalic() { - let currentFont = (selectedRange.length > 0 ? attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.font]) as? UIFont ?? UIFont.systemFont(ofSize: 18) - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitItalic) { - traits.remove(.traitItalic) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString } else { - traits.insert(.traitItalic) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - applyAttribute(.font, value: newFont) + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in + if let font = value as? UIFont { + var traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) + mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) + } + } + } + attributedText = mutableAttributedString + } else { + let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) + var traits = currentFont.fontDescriptor.symbolicTraits + if traits.contains(.traitItalic) { + traits.remove(.traitItalic) + } else { + traits.insert(.traitItalic) + } + if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { + let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) + typingAttributes[.font] = newFont + } } + hasUnsavedChanges = true + hasUnsavedChanges = true } - + + func toggleUnderline() { - let currentUnderline = (selectedRange.length > 0 ? attributedText.attribute(.underlineStyle, at: selectedRange.location, effectiveRange: nil) : typingAttributes[.underlineStyle]) as? Int ?? 0 - let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue - applyAttribute(.underlineStyle, value: newUnderline) + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.underlineStyle, in: range, options: []) { value, subRange, _ in + let currentUnderline = value as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + mutableAttributedString.addAttribute(.underlineStyle, value: newUnderline, range: subRange) + } + attributedText = mutableAttributedString + } else { + let currentUnderline = typingAttributes[.underlineStyle] as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + typingAttributes[.underlineStyle] = newUnderline + } + hasUnsavedChanges = true + if selectedRange.length > 0 { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + + mutableAttributedString.enumerateAttribute(.underlineStyle, in: range, options: []) { value, subRange, _ in + let currentUnderline = value as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + mutableAttributedString.addAttribute(.underlineStyle, value: newUnderline, range: subRange) + } + attributedText = mutableAttributedString + } else { + let currentUnderline = typingAttributes[.underlineStyle] as? Int ?? 0 + let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue + typingAttributes[.underlineStyle] = newUnderline + } + hasUnsavedChanges = true } func applyAttribute(_ key: NSAttributedString.Key, value: Any) { if selectedRange.length > 0 { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) - mutableAttributedString.addAttribute(key, value: value, range: selectedRange) + let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) + let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) + mutableAttributedString.addAttribute(key, value: value, range: range) + 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 } + hasUnsavedChanges = true } } diff --git a/VITTY/VITTY/Academics/View/NotesHelper.swift b/VITTY/VITTY/Academics/View/NotesHelper.swift index 55938e9..7f21d8d 100644 --- a/VITTY/VITTY/Academics/View/NotesHelper.swift +++ b/VITTY/VITTY/Academics/View/NotesHelper.swift @@ -1,61 +1,398 @@ -// -// NotesHelper.swift -// VITTY -// -// Created by Rujin Devkota on 3/5/25. +import Foundation +import UIKit -// -import Down +//TODO : Will make a mark down parser in future updates -import Foundation -import UIKit extension NSAttributedString { - func toMarkdown() -> String { - let mutableString = NSMutableString() - self.enumerateAttributes(in: NSRange(location: 0, length: self.length), options: []) { (attributes, range, _) in - let substring = self.attributedSubstring(from: range).string - var markdownString = substring +// func toMarkdown() -> String { +// let mutableString = NSMutableString()login + +// let fullRange = NSRange(location: 0, length: self.length) +// +// self.enumerateAttributes(in: fullRange, options: []) { (attributes, range, _) in +// let substring = self.attributedSubstring(from: range).string +// +// // Check for font attributes +// if let font = attributes[.font] as? UIFont { +// let traits = font.fontDescriptor.symbolicTraits +// +// if traits.contains(.traitBold) && traits.contains(.traitItalic) { +// mutableString.append("***\(substring)***") +// } else if traits.contains(.traitBold) { +// mutableString.append("**\(substring)**") +// } else if traits.contains(.traitItalic) { +// mutableString.append("*\(substring)*") +// } else { +// mutableString.append(substring) +// } +// } +// +// // Check for underline +// if let underline = attributes[.underlineStyle] as? Int, underline == NSUnderlineStyle.single.rawValue { +// mutableString.insert("", at: mutableString.length - substring.count) +// mutableString.append("") +// } +// +// // Check for color +// if let color = attributes[.foregroundColor] as? UIColor, color != UIColor.white { +// let hex = color.hexString +// mutableString.insert("", at: mutableString.length - substring.count) +// mutableString.append("") +// } +// +// // Handle bullet points (simple implementation) +// if substring.hasPrefix("• ") { +// mutableString.append("\n- \(substring.dropFirst(2))") +// } +// } +// +// return mutableString as String +// } +} + +//// MARK: - Markdown Parser Extension +//extension NSAttributedString { +// +// /// Converts NSAttributedString to Markdown format +// /// Handles bold, italic, underline, font sizes, colors, and bullet points +// func toMarkdown() -> String { +// var md = "" +// let fullText = string as NSString +// let lines = fullText.components(separatedBy: "\n") +// var location = 0 +// +// for (i, line) in lines.enumerated() { +// let length = (line as NSString).length +// +// // 1) Empty line? just emit newline +// if length == 0 { +// md += "\n" +// location += 1 // account for the stripped '\n' +// continue +// } +// +// let lineRange = NSRange(location: location, length: length) +// +// // 2) Heading detection based on font size at start of line +// if let font = attribute(.font, at: location, effectiveRange: nil) as? UIFont { +// switch font.pointSize { +// case let s where s > 24: md += "# "; +// case let s where s > 20: md += "## "; +// case let s where s > 18: md += "### "; +// default: break +// } +// } +// +// // 3) Enumerate each run within that line +// enumerateAttributes(in: lineRange, options: []) { attrs, runRange, _ in +// var substr = attributedSubstring(from: runRange).string +// +// // Escape literal Markdown markers so we don't mangle user-typed '*' etc. +// substr = substr +// .replacingOccurrences(of: "\\", with: "\\\\") +// .replacingOccurrences(of: "*", with: "\\*") +// .replacingOccurrences(of: "_", with: "\\_") +// +// // Build wrappers +// var prefix = "", suffix = "" +// +// // Bold / Italic +// if let font = attrs[.font] as? UIFont { +// let traits = font.fontDescriptor.symbolicTraits +// if traits.contains(.traitBold) { prefix += "**"; suffix = "**" + suffix } +// if traits.contains(.traitItalic) { prefix += "*"; suffix = "*" + suffix } +// } +// +// // Underline +// if let u = attrs[.underlineStyle] as? Int, +// u == NSUnderlineStyle.single.rawValue { +// prefix = "" + prefix +// suffix += "" +// } +// +// // Color +// if let color = attrs[.foregroundColor] as? UIColor, +// !isDefaultTextColor(color) { +// let hex = colorToHex(color) +// substr = "\(substr)" +// } +// +// md += prefix + substr + suffix +// } +// +// // 4) Re-append the newline (except after the last line) +// if i < lines.count - 1 { +// md += "\n" +// } +// location += length + 1 +// } +// +// return md +// } +// +// // MARK: - Helper Methods +// +// private func colorToHex(_ color: UIColor) -> String { +// var red: CGFloat = 0 +// var green: CGFloat = 0 +// var blue: CGFloat = 0 +// var alpha: CGFloat = 0 +// +// color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) +// +// let rgb = Int(red * 255) << 16 | Int(green * 255) << 8 | Int(blue * 255) +// return String(format: "#%06x", rgb) +// } +// +// private func isDefaultTextColor(_ color: UIColor) -> Bool { +// // Check if color is white or default text color +// var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 +// color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) +// return red > 0.9 && green > 0.9 && blue > 0.9 // Close to white +// } +// +// private func isNextCharacterBold(at index: Int) -> Bool { +// guard index < self.length else { return false } +// if let font = self.attribute(.font, at: index, effectiveRange: nil) as? UIFont { +// return font.fontDescriptor.symbolicTraits.contains(.traitBold) +// } +// return false +// } +// +// private func isNextCharacterItalic(at index: Int) -> Bool { +// guard index < self.length else { return false } +// if let font = self.attribute(.font, at: index, effectiveRange: nil) as? UIFont { +// return font.fontDescriptor.symbolicTraits.contains(.traitItalic) +// } +// return false +// } +// +// private func isNextCharacterUnderlined(at index: Int) -> Bool { +// guard index < self.length else { return false } +// if let underline = self.attribute(.underlineStyle, at: index, effectiveRange: nil) as? Int { +// return underline == NSUnderlineStyle.single.rawValue +// } +// return false +// } +//} +// MARK: - Markdown to NSAttributedString Parser +extension String { + + + func fromMarkdown() -> NSMutableAttributedString { + let result = NSMutableAttributedString() + let lines = self.components(separatedBy: .newlines) + + for (index, line) in lines.enumerated() { + if index > 0 { + result.append(NSAttributedString(string: "\n")) + } - if let font = attributes[.font] as? UIFont, font.fontDescriptor.symbolicTraits.contains(.traitBold) { - markdownString = "**\(markdownString)**" + if line.trimmingCharacters(in: .whitespaces).isEmpty { + continue } + + let processedLine = processMarkdownLine(line) + result.append(processedLine) + } + + return result + } + + private func processMarkdownLine(_ line: String) -> NSAttributedString { + var workingLine = line + let result = NSMutableAttributedString() + - if let font = attributes[.font] as? UIFont, font.fontDescriptor.symbolicTraits.contains(.traitItalic) { - markdownString = "*\(markdownString)*" + var attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 18), + .foregroundColor: UIColor.white + ] + + + if workingLine.hasPrefix("### ") { + workingLine = String(workingLine.dropFirst(4)) + attributes[.font] = UIFont.boldSystemFont(ofSize: 20) + } else if workingLine.hasPrefix("## ") { + workingLine = String(workingLine.dropFirst(3)) + attributes[.font] = UIFont.boldSystemFont(ofSize: 24) + } else if workingLine.hasPrefix("# ") { + workingLine = String(workingLine.dropFirst(2)) + attributes[.font] = UIFont.boldSystemFont(ofSize: 28) + } + + + if workingLine.trimmingCharacters(in: .whitespaces).hasPrefix("- ") { + workingLine = workingLine.replacingOccurrences(of: "- ", with: "• ", options: [], range: workingLine.range(of: "- ")) + } + + + let processedString = processInlineFormatting(workingLine, baseAttributes: attributes) + result.append(processedString) + + return result + } + + private func processInlineFormatting(_ text: String, baseAttributes: [NSAttributedString.Key: Any]) -> NSAttributedString { + let result = NSMutableAttributedString() + var currentIndex = text.startIndex + var currentAttributes = baseAttributes + + while currentIndex < text.endIndex { + + if let colorRange = findColorSpan(in: text, from: currentIndex) { + + if currentIndex < colorRange.range.lowerBound { + let beforeText = String(text[currentIndex..") { + if let endIndex = text.range(of: "", range: currentIndex.. (range: Range, text: String, color: UIColor)? { + let searchText = String(text[startIndex...]) + let pattern = #"([^<]*)"# - if let underline = attributes[.underlineStyle] as? Int, underline == NSUnderlineStyle.single.rawValue { - markdownString = "__\(markdownString)__" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let nsRange = NSRange(searchText.startIndex.. (NSAttributedString, String.Index) { + var currentIndex = startIndex + var currentAttributes = attributes + let result = NSMutableAttributedString() + + + let remainingText = String(text[currentIndex...]) + let boldPattern = #"\*\*([^*]+)\*\*"# + let italicPattern = #"\*([^*]+)\*"# + + + if let boldRegex = try? NSRegularExpression(pattern: boldPattern), + let boldMatch = boldRegex.firstMatch(in: remainingText, range: NSRange(remainingText.startIndex.. remainingText.startIndex { + let beforeText = String(remainingText[remainingText.startIndex.. remainingText.startIndex { + let beforeText = String(remainingText[remainingText.startIndex.. NSAttributedString? { - let down = Down(markdownString: self) - return try? down.toAttributedString() + + private func colorFromHex(_ hex: String) -> UIColor? { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + return nil + } + + return UIColor( + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + alpha: Double(a) / 255 + ) } } diff --git a/VITTY/VITTY/Academics/View/RemindersData.swift b/VITTY/VITTY/Academics/View/RemindersData.swift index 75ef787..f6937a5 100644 --- a/VITTY/VITTY/Academics/View/RemindersData.swift +++ b/VITTY/VITTY/Academics/View/RemindersData.swift @@ -4,18 +4,29 @@ // // Created by Rujin Devkota on 2/27/25. // - import SwiftUI import SwiftData + struct RemindersView: View { @Environment(\.modelContext) private var modelContext @Query private var allReminders: [Remainder] + @Query private var timeTables: [TimeTable] + @Query private var timeTables: [TimeTable] @State private var searchText = "" @State private var selectedTab = 0 + @State private var showingSubjectSelection = false + @State private var showingReminderCreation = false + @State private var selectedCourse: Course? + @State private var availableCourses: [Course] = [] + @State private var isLoadingCourses = false - // Filtered reminders based on search text + + private var firstTimeTable: TimeTable? { + timeTables.first + } + private var filteredReminders: [Remainder] { if searchText.isEmpty { return allReminders @@ -28,7 +39,6 @@ struct RemindersView: View { } } - // Group reminders by date private var groupedReminders: [ReminderGroup] { let grouped = Dictionary(grouping: filteredReminders) { reminder in Calendar.current.startOfDay(for: reminder.date) @@ -57,10 +67,17 @@ struct RemindersView: View { }.sorted { $0.daysToGo < $1.daysToGo } } + // Extract courses from timetable + private var availableCourses: [Course] { + let courses = timeTables.first.map { extractCourses(from: $0) } ?? [] + return courses + } + var body: some View { ScrollView { VStack(spacing: 0) { - // Search Bar + + HStack { Image(systemName: "magnifyingglass") .foregroundColor(.gray) @@ -81,18 +98,27 @@ struct RemindersView: View { .padding(.horizontal) .padding(.top, 16) - // Status Tabs HStack(spacing: 16) { StatusTabView(isSelected: selectedTab == 0, title: "Pending") .onTapGesture { selectedTab = 0 } StatusTabView(isSelected: selectedTab == 1, title: "Completed") .onTapGesture { selectedTab = 1 } Spacer() + + Button { + // Load courses immediately when button is pressed + loadCoursesIfNeeded() + showingSubjectSelection = true + } label: { + Image(systemName: "plus") + .foregroundColor(.blue) + .font(.system(size: 16, weight: .medium)) + .frame(width: 32, height: 32) + } } .padding(.horizontal) .padding(.top, 16) - // Reminder Groups VStack(spacing: 24) { ForEach(groupedReminders, id: \.id) { group in if selectedTab == 0 && !group.items.filter({ !$0.isCompleted }).isEmpty { @@ -124,7 +150,7 @@ struct RemindersView: View { .padding(.top, 16) // Empty state - if groupedReminders.isEmpty { + if groupedReminders.isEmpty && !isLoadingCourses { VStack(spacing: 16) { Image(systemName: "calendar.badge.exclamationmark") .font(.system(size: 48)) @@ -146,6 +172,52 @@ struct RemindersView: View { } .scrollIndicators(.hidden) .background(Color("Background").edgesIgnoringSafeArea(.all)) + .onAppear { + // Load courses immediately on appear + loadCoursesIfNeeded() + } + .sheet(isPresented: $showingSubjectSelection) { + SubjectSelectionView( + courses: availableCourses, + isLoading: isLoadingCourses, + onCourseSelected: { course in + selectedCourse = course + showingSubjectSelection = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showingReminderCreation = true + } + } + ) + } + .sheet(isPresented: $showingReminderCreation) { + if let course = selectedCourse { + ReminderView( + courseName: course.title, + slot: course.slot, + courseCode: course.code + ) + } else { + // Fallback view in case selectedCourse is nil + VStack { + Text("Error: No course selected") + .foregroundColor(.red) + Button("Close") { + showingReminderCreation = false + } + } + .padding() + .background(Color("Background")) + } + } + .onChange(of: showingReminderCreation) { isPresented in + + if !isPresented { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + selectedCourse = nil + } + } + } } private func completeReminderItem(itemId: PersistentIdentifier) { @@ -156,8 +228,255 @@ struct RemindersView: View { } } } + + + private func loadCoursesIfNeeded() { + + guard availableCourses.isEmpty else { return } + + guard let firstTimeTable = firstTimeTable else { + + self.availableCourses = [] + self.isLoadingCourses = false + return + } + + isLoadingCourses = true + + // Use async dispatch to avoid blocking UI + DispatchQueue.global(qos: .userInitiated).async { + let courses = extractCourses(from: firstTimeTable) + + DispatchQueue.main.async { + self.availableCourses = courses + self.isLoadingCourses = false + } + } + } + + + private func extractCourses(from timetable: TimeTable) -> [Course] { + let allLectures = timetable.monday + timetable.tuesday + timetable.wednesday + + timetable.thursday + timetable.friday + timetable.saturday + + timetable.sunday + + let currentSemester = determineSemester(for: Date()) + + + 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 + ) + ) + } + + return result.sorted { $0.title < $1.title } + } + + private func determineSemester(for date: Date) -> String { + let month = Calendar.current.component(.month, from: date) + + switch month { + case 12, 1, 2: + return "Winter \(academicYear(for: date))" + case 3...6: + return "Summer \(academicYear(for: date))" + case 7...11: + return "Fall \(academicYear(for: date))" + default: + return "Unknown" + } + } + + private func academicYear(for date: Date) -> String { + let year = Calendar.current.component(.year, from: date) + let month = Calendar.current.component(.month, from: date) + if month < 3 { + return "\(year - 1)-\(String(format: "%02d", year % 100))" + } else { + return "\(year)-\(String(format: "%02d", (year + 1) % 100))" + } + } } +// MARK: - Optimized Subject Selection View + +struct SubjectSelectionView: View { + let courses: [Course] + let isLoading: Bool + let onCourseSelected: (Course) -> Void + + @Environment(\.presentationMode) var presentationMode + @State private var searchText = "" + + private var filteredCourses: [Course] { + if searchText.isEmpty { + return courses + } else { + return courses.filter { course in + course.title.localizedCaseInsensitiveContains(searchText) || + course.code.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + NavigationView { + ZStack { + Color("Background").edgesIgnoringSafeArea(.all) + + if isLoading { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + .progressViewStyle(CircularProgressViewStyle(tint: Color("Accent"))) + + Text("Loading subjects...") + .font(.system(size: 16)) + .foregroundColor(.gray) + } + .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) + + 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) + } + } + } + } + } + .navigationTitle("Select Subject") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .foregroundColor(.red) + } + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Subject Selection Card (Optimized) +struct SubjectSelectionCard: View { + let course: Course + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(course.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) + .font(.system(size: 14)) + } + .padding(.top, 16) + .padding(.horizontal, 16) + + HStack { + Text(course.code) + .font(.system(size: 14)) + .foregroundColor(Color("Accent")) + + Spacer() + + Text("Slot: \(course.slot)") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color("Secondary").opacity(0.5)) + .cornerRadius(8) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 16).fill(Color("Secondary"))) + } + .buttonStyle(PlainButtonStyle()) + } +} + + struct StatusTabView: View { let isSelected: Bool let title: String @@ -167,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) @@ -342,7 +661,6 @@ struct ReminderItemView: View { } } -// Updated models to work with SwiftData struct ReminderGroup: Identifiable { let id = UUID() let date: String diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index e41404b..5181cb3 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -11,17 +11,55 @@ 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 { var loggedInFirebaseUser: User? var loggedInBackendUser: AppUser? + + + + + + var isLoading: Bool = false var isLoadingApple: Bool = false let firebaseAuth = Auth.auth() @@ -59,10 +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...") + 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( @@ -71,13 +179,21 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { username: username ) ) + + + + } catch { logger.error("Signing into server error: \(error)") } - logger.info("Signed into server") + print("this is kinda empty : \(self.loggedInBackendUser?.name ?? "")") + logger.info("Signed into server \(self.loggedInBackendUser?.name ?? "empty")") + 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 { @@ -139,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)") @@ -164,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 38cd03f..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 @@ -154,7 +184,7 @@ struct CarouselItemView: View { .foregroundColor(Color.white) Text(item.subtitle) .font(.footnote) - .foregroundColor(Color("tfBlueLight")) + .foregroundColor(Color("Accent")) .multilineTextAlignment(.center) .frame(width: 400) .padding(.top, 1) diff --git a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift index fe5488c..10c4dfd 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift @@ -4,73 +4,127 @@ // // 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 - - @State private var isSearchViewPresented = false - - var body: some View { - NavigationStack { - ZStack { - BackgroundView() - VStack(alignment: .leading) { - 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) - Text("Your friend requests and suggested friends will be shown here") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundColor(Color.white) - Spacer() - } - } - } - .toolbar { - Button(action: { - isSearchViewPresented = true - }) { - Image(systemName: "magnifyingglass") - .foregroundColor(.white) - } - .navigationDestination( - isPresented: $isSearchViewPresented, - destination: { SearchView() } - ) - } - .navigationTitle("Add Friends") - } - .onAppear { - suggestedFriendsViewModel.fetchData( - from: "\(APIConstants.base_url)/api/v2/users/suggested/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - } - } + @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 { + 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() + } + } + } + } + .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) + } + Spacer() + Text("Add Friends") + .foregroundColor(.white) + .font(.system(size: 25, weight: .bold)) + Spacer() + Button(action: { + isSearchViewPresented = true + }) { + Image(systemName: "magnifyingglass") + .foregroundColor(.white) + .font(.title2) + } + .navigationDestination( + isPresented: $isSearchViewPresented, + destination: { SearchView() } + ) + } + .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 83d6642..7323d35 100644 --- a/VITTY/VITTY/Connect/Models/CircleModel.swift +++ b/VITTY/VITTY/Connect/Models/CircleModel.swift @@ -5,6 +5,10 @@ // Created by Rujin Devkota on 3/25/25. // +//TODO: the Circle doesnt have image in the endpoint + + + import Foundation struct CircleModel: Decodable { @@ -23,7 +27,6 @@ struct CircleResponse: Decodable { let data: [CircleModel] } - struct CircleMember: Identifiable { let id = UUID() let picture: String @@ -32,29 +35,71 @@ struct CircleMember: Identifiable { let venue: String? } -import Foundation - - -// TEMP beacuse the endpoint has to contain the status and venue need to update the db +// 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 + } +} -struct CircleUserTemp: Codable{ - +// MARK: - Updated CircleUserTemp Model +struct CircleUserTemp: Codable { let email: String let name: String let picture: String let username: String - + let currentStatus: CurrentStatus? + enum CodingKeys: String, CodingKey { 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 { + enum CodingKeys: String, CodingKey { case data } } + +// MARK: - Request Models +struct CircleRequest: Codable, Identifiable { + let id = UUID() + let circle_id: String + let circle_name: String + let from_username: String + let to_username: String + + enum CodingKeys: String, CodingKey { + case circle_id, circle_name, from_username, to_username + } +} +struct CircleRequestResponse: Codable { + let data: [CircleRequest] +} diff --git a/VITTY/VITTY/Connect/Models/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/Models/Friend.swift b/VITTY/VITTY/Connect/Models/Friend.swift index 8d0851c..e1a6008 100644 --- a/VITTY/VITTY/Connect/Models/Friend.swift +++ b/VITTY/VITTY/Connect/Models/Friend.swift @@ -51,17 +51,4 @@ struct Friend: Decodable { } } -extension Friend { - static var sampleFriend: Friend { - return Friend( - currentStatus: CurrentStatus(status: "free"), - friendStatus: "friends", - friendsCount: 2, - mutualFriendsCount: 2, - name: "Rudrank Basant", - picture: - "https://lh3.googleusercontent.com/a/ACg8ocK7g3mh79yuJOyaOWy4iM4WsFk81VYAeDty5W4A8ETrqbw=s96-c", - username: "rudrank" - ) - } -} + diff --git a/VITTY/VITTY/Connect/Search/Views/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 0f89a0b..0e3279b 100644 --- a/VITTY/VITTY/Connect/Search/Views/SearchView.swift +++ b/VITTY/VITTY/Connect/Search/Views/SearchView.swift @@ -7,114 +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) { - 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) - } + @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 + + 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) + } + 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() + } + + 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 = [] + } + } + } + } + } - Spacer() - } - } - .navigationTitle("Search") - } - } + // Update the cancelSearch function to work with Alamofire + func cancelSearch() { + currentSearchTask?.cancel() + currentSearchTask = nil + loading = false + rotationAngle = 0 + searchDebouncer?.invalidate() + } +} - 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 - } +// 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 f6a7a07..94420d9 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift @@ -8,12 +8,63 @@ import SwiftUI struct CirclesRow: View { - let circle: CircleModel + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + + + private var circleMembers: [CircleUserTemp] { + communityPageViewModel.circleMembers(for: circle.circleID) + } + + + private var busyCount: Int { + circleMembers.filter { + $0.status != nil && $0.status != "available" && $0.status != "free" + }.count + } + + private var availableCount: Int { + circleMembers.filter { + $0.status == nil || $0.status == "available" || $0.status == "free" + }.count + } + + private var isLoadingMembers: Bool { + communityPageViewModel.isLoadingCircleMembers(for: circle.circleID) + } + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + + + private var circleMembers: [CircleUserTemp] { + communityPageViewModel.circleMembers(for: circle.circleID) + } + + + private var busyCount: Int { + circleMembers.filter { + $0.status != nil && $0.status != "available" && $0.status != "free" + }.count + } + + private var availableCount: Int { + circleMembers.filter { + $0.status == nil || $0.status == "available" || $0.status == "free" + }.count + } + + private var isLoadingMembers: Bool { + communityPageViewModel.isLoadingCircleMembers(for: circle.circleID) + } var body: some View { HStack { - 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) { @@ -21,36 +72,74 @@ struct CirclesRow: View { .font(Font.custom("Poppins-SemiBold", size: 18)) .foregroundColor(Color.white) - HStack{ - - Image("inclass").resizable().frame(width: 20,height: 20) - - Text("3 busy").foregroundStyle(Color("Accent")) - Spacer().frame(width: 20) - - Image("available").resizable().frame(width: 20,height: 20) - - Text("2 available").foregroundStyle(Color("Accent")) - - - - + if isLoadingMembers { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Loading...") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + + if busyCount > 0 { + Image("inclass").resizable().frame(width: 20, height: 20) + Text("\(busyCount) busy").foregroundStyle(Color("Accent")) + + if availableCount > 0 { + Spacer().frame(width: 20) + } + } + + + if availableCount > 0 { + Image("available").resizable().frame(width: 20, height: 20) + Text("\(availableCount) available").foregroundStyle(Color("Accent")) + } + + + if circleMembers.isEmpty && !isLoadingMembers { + Text("No members") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent").opacity(0.7)) + } + } + } + if isLoadingMembers { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Loading...") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + + if busyCount > 0 { + Image("inclass").resizable().frame(width: 20, height: 20) + Text("\(busyCount) busy").foregroundStyle(Color("Accent")) + + if availableCount > 0 { + Spacer().frame(width: 20) + } + } + + + if availableCount > 0 { + Image("available").resizable().frame(width: 20, height: 20) + Text("\(availableCount) available").foregroundStyle(Color("Accent")) + } + + + if circleMembers.isEmpty && !isLoadingMembers { + Text("No members") + .font(Font.custom("Poppins-Regular", size: 12)) + .foregroundStyle(Color("Accent").opacity(0.7)) + } + } } - - -// if friend.currentStatus.status == "free" { -// HStack { -// Image("available").resizable().frame(width: 20, height: 20) -// Text("Available").foregroundStyle(Color("Accent")) -// } -// } else { -// HStack { -// Image("inclass") -// Text(friend.currentStatus.venue ?? "") -// .font(Font.custom("Poppins-Regular", size: 14)) -// .foregroundColor(Color("Accent")) -// } -// } } Spacer() } @@ -59,11 +148,23 @@ struct CirclesRow: View { RoundedRectangle(cornerRadius: 15) .fill(Color("Secondary")) ) + .onAppear { + + communityPageViewModel.fetchCircleMemberData( + from: "\(APIConstants.base_url)circles/\(circle.circleID)", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: true, + circleID: circle.circleID + ) + } + + } - + func cleanName(_ fullName: String) -> String { - let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" // + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" let regex = try? NSRegularExpression(pattern: pattern, options: []) let range = NSRange(location: 0, length: fullName.utf16.count) @@ -72,4 +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 b538944..1b2df7b 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -1,4 +1,12 @@ +// +// 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 @@ -8,7 +16,18 @@ struct CreateGroup: View { @State private var groupName: String = "" @State private var selectedImage: UIImage? = nil @State private var showImagePicker = false - @State private var selectedFriends: [String] = ["A", "B", "C", "D", "E"] + @State private var selectedFriends: [Friend] = [] + @State private var showFriendSelector = false + @State private var isCreatingGroup = false + @State private var showAlert = false + @State private var alertMessage = "" + @State private var circle_ID = "" + + @Environment(CommunityPageViewModel.self) private var viewModel + let token: String + let username: String + + @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 20) { @@ -19,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) - // Group Icon Picker + + Button(action: { showImagePicker = true }) { @@ -49,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)) @@ -67,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)) @@ -80,7 +121,8 @@ struct CreateGroup: View { Spacer() Button(action: { - + showFriendSelector = true + showFriendSelector = true }) { Image(systemName: "person.badge.plus") .foregroundColor(.white) @@ -89,50 +131,119 @@ struct CreateGroup: View { .padding(.trailing, 20) } - - HStack { - Spacer().frame(width : 90) - ZStack { - ForEach(Array(selectedFriends.prefix(3).enumerated()), id: \.element) { index, friend in - Circle() - .fill(Color.green.opacity(0.8)) - .frame(width: 40, height: 40) - .overlay(Text(friend).foregroundColor(.white)) - .offset(x: CGFloat(index * -25)) - } - } - Spacer() - if selectedFriends.count > 3 { - Text("+ \(selectedFriends.count - 3) more") + + if selectedFriends.isEmpty { + + VStack { + Image(systemName: "person.2") + .font(.system(size: 30)) + .foregroundColor(.gray) + Text("No friends selected") .foregroundColor(.gray) - .padding(.trailing, 20) + .font(.system(size: 14)) } + .frame(width: screenWidth * 0.9, height: 80) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 2) + ) + .background(Color.black.opacity(0.3)) + .cornerRadius(12) + .padding(.horizontal, 20) + } else { - + HStack { + if selectedFriends.count <= 3 { + + Spacer() + HStack(spacing: -15) { + ForEach(Array(selectedFriends.enumerated()), id: \.element.username) { index, friend in + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.green.opacity(0.8)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(.system(size: 16, weight: .bold)) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + } + Spacer() + } else { + + Spacer().frame(width: 30) + HStack(spacing: -15) { + ForEach(Array(selectedFriends.prefix(3).enumerated()), id: \.element.username) { index, friend in + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.green.opacity(0.8)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(.system(size: 16, weight: .bold)) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + } + Spacer() + Text("+ \(selectedFriends.count - 3) more") + .foregroundColor(.gray) + .font(.system(size: 14)) + Spacer().frame(width: 20) + } + } + .frame(width: screenWidth * 0.9, height: 80) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 2) + ) + .background(Color.black.opacity(0.3)) + .cornerRadius(12) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + .onTapGesture { + showFriendSelector = true + } } - .frame(width: screenWidth * 0.9, height: 80).overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color("Accent"), lineWidth: 2) - ) - .background(Color.black.opacity(0.3)) - .cornerRadius(12) - .padding(.horizontal, 20) Spacer() - + HStack { Spacer() Button(action: { - + createGroup() + createGroup() }) { - Text("Cretae ") - .font(.system(size: 18, weight: .bold)).foregroundStyle(Color.black) - - .frame(width: 90, height: 40) - .background(Color("Accent")) - .cornerRadius(10) + HStack { + if isCreatingGroup { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } + Text(isCreatingGroup ? "Creating..." : "Create") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + } + .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) @@ -140,10 +251,259 @@ struct CreateGroup: View { } .presentationDetents([.height(screenHeight * 0.65)]) .background(Color("Secondary")) + .sheet(isPresented: $showFriendSelector) { + FriendSelectorView( + friends: viewModel.friends, + selectedFriends: $selectedFriends, + loadingFriends: viewModel.loadingFreinds + ) + } + .alert("Group Creation", isPresented: $showAlert) { + Button("OK") { + if alertMessage.contains("successfully") { + dismiss() + } + } + } message: { + Text(alertMessage) + } + } + + // MARK: - Group Creation using ViewModel (Fixed Version) + + private func createGroup() { + guard !groupName.isEmpty else { return } + + isCreatingGroup = true + + viewModel.createCircle(name: groupName, token: token) { result in + switch result { + case .success(let circleId): + print("Successfully created circle with ID: \(circleId)") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if let circle = self.viewModel.circles.first(where: { $0.circleName == self.groupName }) { + + print("Found circle ID: \(circle.circleID) for name: \(self.groupName)") + + self.circle_ID = circle.circleID + + + 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 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 + } + } } -} -#Preview { - CreateGroup(groupCode: .constant("")) + private func sendInvitationsUsingViewModel(circleId: String) { + guard !selectedFriends.isEmpty else { + self.isCreatingGroup = false + self.alertMessage = "Group created successfully!" + self.showAlert = true + return + } + + // Extract usernames from selected friends + let usernames = selectedFriends.map { $0.username } + + print("Sending invitations for circle ID: \(circleId)") + print("Usernames: \(usernames)") + + // 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 = results.values.filter { $0 }.count + let totalCount = self.selectedFriends.count + + if successCount == totalCount { + self.alertMessage = "Group created successfully! All invitations sent." + } else if successCount > 0 { + self.alertMessage = "Group created successfully! \(successCount) out of \(totalCount) invitations sent." + } else { + self.alertMessage = "Group created successfully, but failed to send invitations." + } + + self.showAlert = true + } + } + + struct FriendSelectorView: View { + let friends: [Friend] + @Binding var selectedFriends: [Friend] + let loadingFriends: Bool + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack { + if loadingFriends { + ProgressView("Loading friends...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.white) + } else if friends.isEmpty { + VStack { + Image(systemName: "person.2.slash") + .font(.system(size: 50)) + .foregroundColor(.gray) + Text("No friends found") + .font(.title2) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(friends, id: \.username) { friend in + FriendRowView( + friend: friend, + isSelected: selectedFriends.contains { $0.username == friend.username } + ) { isSelected in + if isSelected { + selectedFriends.append(friend) + } else { + selectedFriends.removeAll { $0.username == friend.username } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + } + .background(Color("Background")) + .navigationTitle("Select Friends") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + leading: Button(action: { + dismiss() + }) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + }, + trailing: Button(action: { + dismiss() + }) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + } + ) + } + .background(Color("Background")) + } + } + + struct FriendRowView: View { + let friend: Friend + let isSelected: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack { + + AsyncImage(url: URL(string: friend.picture)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Circle() + .fill(Color.blue.opacity(0.3)) + .overlay( + Text(String(friend.name.prefix(1)).uppercased()) + .foregroundColor(.white) + .font(Font.custom("Poppins-SemiBold", size: 16)) + ) + } + .frame(width: 48, height: 48) + .clipShape(Circle()) + + Spacer().frame(width: 20) + + + VStack(alignment: .leading, spacing: 4) { + Text(friend.name) + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(Color.white) + + if friend.currentStatus.status == "free" { + HStack { + Image("available") + .resizable() + .frame(width: 20, height: 20) + Text("Available") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + Image("inclass") + .resizable() + .frame(width: 20, height: 20) + Text(friend.currentStatus.venue ?? "In Class") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + } + } + + Spacer() + + + Button(action: { + onToggle(!isSelected) + }) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? Color("Accent") : .gray) + .font(.system(size: 24)) + } + } + .padding() + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + ) + .contentShape(Rectangle()) + .onTapGesture { + onToggle(!isSelected) + } + } + } } +// MARK: - Response Models (if not already defined elsewhere) + +struct CreateCircleResponse: Decodable { + let circleId: String + let message: String + + enum CodingKeys: String, CodingKey { + case circleId = "circle_id" + case message + } +} diff --git a/VITTY/VITTY/Connect/View/Circles/Components/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 d4fd066..3ed9272 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -1,94 +1,534 @@ +// JoinGroup.swift +// VITTY +// +// 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 = "" - + @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 + + @State private var showingAlert = false + @State private var alertMessage = "" + @State private var isJoining = false + @State private var showToast = false + @State private var toastMessage = "" + @State private var circleName = "" + @State private var localGroupCode = "" + + @Environment(AuthViewModel.self) private var authViewModel + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(\.dismiss) private var dismiss + var body: some View { - VStack(spacing: 20) { - Capsule() - .fill(Color.gray.opacity(0.5)) - .frame(width: 50, height: 5) - .padding(.top, 10) - - Text("Join Group") - .font(.system(size: 21, weight: .bold)) - .foregroundColor(.white) - - VStack(alignment: .leading, spacing: 10) { - Text("Enter group code") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")) + ZStack { + VStack(spacing: 20) { + Capsule() + .fill(Color.gray.opacity(0.5)) + .frame(width: 50, height: 5) + .padding(.top, 10) - TextField("", text: $groupCode) - .padding() - .background(Color.black.opacity(0.3)) - .cornerRadius(8) + Spacer().frame(height: 7) + Text("Join Circle") + .font(.system(size: 21, weight: .bold)) .foregroundColor(.white) + + Spacer().frame(width: 20) + + VStack(alignment: .leading, spacing: 10) { + Text("Enter circle code") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(Color("Accent")) + + TextField("Enter circle code", text: $localGroupCode) + .padding() + .background(Color.black.opacity(0.3)) + .cornerRadius(8) + .foregroundColor(.white) + .onChange(of: localGroupCode) { oldValue, newValue in + let filtered = newValue.filter { $0.isLetter || $0.isNumber } + localGroupCode = filtered + groupCode = filtered + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) + ) + } + .padding(.horizontal, 20) + + HStack { + Rectangle() + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + Text("OR") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 10) + Rectangle() + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + } + .padding(.horizontal, 20) + + HStack { + Text("Scan QR Code") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(Color("Accent")) + .padding(.leading, 20) + Spacer() + } + + Button(action: { + isScanning = true + }) { + VStack { + if isScanning { + QRScannerView(scannedCode: $scannedCode, isScanning: $isScanning) + .frame(width: screenWidth * 0.8, height: screenHeight * 0.25) + } else { + Image(systemName: "qrcode.viewfinder") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .foregroundColor(Color.white) + + Text("Tap to scan QR code") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + } + .frame(width: screenWidth * 0.8, height: screenHeight * 0.25) + .background(Color.black.opacity(0.3)) + .cornerRadius(12) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 12) .stroke(Color.gray.opacity(0.5), lineWidth: 1) ) + } + .disabled(isJoining) + + Spacer() + + HStack { + Spacer() + Button(action: { + joinCircle() + }) { + HStack { + if isJoining { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } + Text(isJoining ? "JOINING..." : "JOIN") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + } + .frame(width: 100, height: 35) + .background(localGroupCode.isEmpty ? Color.gray : Color("Accent")) + .cornerRadius(10) + } + .disabled(isJoining || localGroupCode.isEmpty) + .padding(.trailing, 20) + } + .padding(.bottom, 20) } - .padding(.horizontal, 20) - + .presentationDetents([.height(screenHeight * 0.65)]) + .background(Color("Secondary")) + .onChange(of: scannedCode) { oldValue, newValue in + if !newValue.isEmpty { + handleScannedCode(newValue) + } + } + + if showToast { + VStack { + Spacer() + ToastView(message: toastMessage, isShowing: $showToast) + .padding(.bottom, 50) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + }.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") { + if alertMessage.contains("successfully") || alertMessage.contains("requested") { + dismiss() + } + } + } message: { + Text(alertMessage) + } + .onOpenURL { url in + handleDeepLink(url) + } + .onAppear { + localGroupCode = groupCode + } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + + // MARK: - Handle Deep Link + private func handleDeepLink(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value else { + return + } + + let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value ?? "Unknown Circle" + + showJoinAlert(circleId: circleId, circleName: circleName) + } + + // MARK: - Show Join Alert + private func showJoinAlert(circleId: String, circleName: String) { + let alert = UIAlertController( + title: "Join Circle", + message: "Do you want to join '\(circleName)'?", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Join", style: .default) { _ in + self.localGroupCode = circleId + self.groupCode = circleId + self.circleName = circleName + self.joinCircle() + }) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(alert, animated: true) + } + } + + private func handleScannedCode(_ code: String) { + if code.contains("vitty.app/invite") || code.contains("circleId=") { + if let components = URLComponents(string: code), + let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value { + localGroupCode = circleId + groupCode = circleId + if let name = components.queryItems?.first(where: { $0.name == "circleName" })?.value { + circleName = name + } + joinCircle() + } + } else { + localGroupCode = code + groupCode = code + joinCircle() + } + isScanning = false + } + + // MARK: - Join Circle + + private func joinCircle() { + guard !localGroupCode.isEmpty, + let username = authViewModel.loggedInBackendUser?.username, + let token = authViewModel.loggedInBackendUser?.token else { + showToast(message: "Error: Unable to get user information", isError: true) + return + } + + if localGroupCode.count < 3 { + showToast(message: "Error: Circle code must be at least 3 characters", isError: true) + return + } + + isJoining = true + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + + let urlString = "\(APIConstants.base_url)circles/join?code=\(localGroupCode)" + guard let url = URL(string: urlString) else { + showToast(message: "Error: Invalid URL", isError: true) + isJoining = false + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isJoining = false + + if let error = error { + showToast(message: "Network error: \(error.localizedDescription)", isError: true) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + showToast(message: "Error: Invalid response", isError: true) + return + } + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + showToast(message: "Successfully joined the circle! 🎉", isError: false) + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + + localGroupCode = "" + groupCode = "" + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + dismiss() + } + } else { + if let data = data, + let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = errorResponse["message"] as? String { + showToast(message: "Error: \(message)", isError: true) + } else { + switch httpResponse.statusCode { + case 400: + showToast(message: "Error: Invalid circle code", isError: true) + case 404: + showToast(message: "Error: Circle not found", isError: true) + case 409: + showToast(message: "Error: Already a member 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) + } + } + } + } + }.resume() + } + + // MARK: - Show Toast + private func showToast(message: String, isError: Bool) { + toastMessage = message + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showToast = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showToast = false + } + } + } +} + + +// MARK: - Toast View +struct ToastView: View { + let message: String + @Binding var isShowing: Bool + + var body: some View { + if isShowing { HStack { - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) - Text("OR") - .font(.system(size: 16, weight: .bold)) + Text(message) + .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) - .padding(.horizontal, 10) - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) + .multilineTextAlignment(.center) } .padding(.horizontal, 20) - - HStack{ - Text("Scan Qr Code").font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")).padding(.leading,20) - Spacer() - } - - VStack { - Image(systemName: "qrcode.viewfinder") - .resizable() - .scaledToFit() - .frame(width: 120, height: 120) - .foregroundColor(Color.gray) - } - .frame(width: screenWidth*0.8, height: screenHeight*0.25) - .background(Color.black.opacity(0.3)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray.opacity(0.5), lineWidth: 1) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(Color.black.opacity(0.8)) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) ) - - Spacer() - - HStack { - Spacer() - Text("JOIN") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")).padding(.trailing,20) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onTapGesture { + withAnimation { + isShowing = false + } } - .padding(.leading, 20) - .padding(.bottom, 20) } + } +} + + +struct QRScannerView: UIViewControllerRepresentable { + @Binding var scannedCode: String + @Binding var isScanning: Bool + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, QRScannerDelegate { + let parent: QRScannerView - .presentationDetents([.height(screenHeight * 0.65)]) - .background(Color("Secondary")) + init(_ parent: QRScannerView) { + self.parent = parent + } + + func didScanCode(_ code: String) { + parent.scannedCode = code + parent.isScanning = false + } + + func didFailWithError(_ error: Error) { + parent.isScanning = false + } } } -#Preview { - JoinGroup(groupCode: .constant("")) + +protocol QRScannerDelegate: AnyObject { + func didScanCode(_ code: String) + func didFailWithError(_ error: Error) +} + +class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + weak var delegate: QRScannerDelegate? + + private var captureSession: AVCaptureSession! + private var previewLayer: AVCaptureVideoPreviewLayer! + + override func viewDidLoad() { + super.viewDidLoad() + setupCamera() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if captureSession?.isRunning == false { + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if captureSession?.isRunning == true { + captureSession.stopRunning() + } + } + + private func setupCamera() { + captureSession = AVCaptureSession() + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -1, userInfo: [NSLocalizedDescriptionKey: "Camera not available"])) + return + } + + let videoInput: AVCaptureDeviceInput + + do { + videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice) + } catch { + delegate?.didFailWithError(error) + return + } + + if captureSession.canAddInput(videoInput) { + captureSession.addInput(videoInput) + } else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -2, userInfo: [NSLocalizedDescriptionKey: "Could not add video input"])) + return + } + + let metadataOutput = AVCaptureMetadataOutput() + + if captureSession.canAddOutput(metadataOutput) { + captureSession.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr] + } else { + delegate?.didFailWithError(NSError(domain: "QRScanner", code: -3, userInfo: [NSLocalizedDescriptionKey: "Could not add metadata output"])) + return + } + + previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.frame = view.layer.bounds + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + captureSession.stopRunning() + + if let metadataObject = metadataObjects.first { + guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } + guard let stringValue = readableObject.stringValue else { return } + + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + delegate?.didScanCode(stringValue) + } + } + + override var prefersStatusBarHidden: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift new file mode 100644 index 0000000..05bcd18 --- /dev/null +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -0,0 +1,139 @@ +// +// QrCode.swift +// VITTY +// +// Created by Rujin Devkota on 6/24/25. +// + +import CoreImage.CIFilterBuiltins +import SwiftUI + + +struct QRCodeModalView: View { + let groupCode: String + let circleName: String + let onDismiss: () -> Void + + @State private var showingShareSheet = false + + var body: some View { + VStack { + Spacer() + VStack(spacing: 20) { + + HStack { + Text("Circle QR Code") + .font(.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(.white) + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark") + .foregroundColor(.white) + .font(.system(size: 18)) + } + } + + + VStack(spacing: 8) { + Text(circleName) + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + + } + + + if let qrImage = generateQRCode(from: createInvitationLink()) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .background(Color.white) + .cornerRadius(12) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 200, height: 200) + .cornerRadius(12) + .overlay( + Text("QR Code\nGeneration Failed") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + ) + } + + + Text("Share this code for others to join your circle") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal) + + + Button(action: { + showingShareSheet = true + }) { + HStack { + Image(systemName: "square.and.arrow.up") + Text("Share Invitation") + } + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(Color("Background")) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color("Accent")) + .cornerRadius(8) + } + } + .frame(maxWidth: 300) + .padding(24) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $showingShareSheet) { + ShareSheetQr(items: [createInvitationLink(), "Join my circle '\(circleName)' on VITTY!"]) + } + } + + private func createInvitationLink() -> String { + + let baseURL = "https://vitty.app/invite" + let encodedCircleName = circleName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? circleName + return "\(baseURL)/circles/sendRequest/\(groupCode)&circleName=\(encodedCircleName)" + } + + private func generateQRCode(from string: String) -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + + filter.message = Data(string.utf8) + + if let outputImage = filter.outputImage { + let scaleX = 200 / outputImage.extent.size.width + let scaleY = 200 / outputImage.extent.size.height + let transformedImage = outputImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) + + if let cgImage = context.createCGImage(transformedImage, from: transformedImage.extent) { + return UIImage(cgImage: cgImage) + } + } + return nil + } +} + +struct ShareSheetQr: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift new file mode 100644 index 0000000..09bbc11 --- /dev/null +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift @@ -0,0 +1,269 @@ +// +// CircleRequests.swift +// VITTY +// +// Created by Rujin Devkota on 6/24/25. +// + + + +import SwiftUI + +struct CircleRequestRow: View { + let request: CircleRequest + let onAccept: () -> Void + let onDecline: () -> Void + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + + var body: some View { + HStack { + UserImage(url: "https://picsum.photos/200/300", height: 48, width: 48) + + Spacer().frame(width: 16) + + VStack(alignment: .leading, spacing: 4) { + Text("@\(request.from_username)") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text("wants you to join \(request.circle_name)") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + .lineLimit(2) + } + + Spacer() + + if communityPageViewModel.loadingRequestAction { + ProgressView() + .scaleEffect(0.8) + .padding(.trailing) + } else { + HStack(spacing: 8) { + Button(action: onDecline) { + Image(systemName: "xmark") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color.red.opacity(0.8)) + .cornerRadius(18) + } + + Button(action: onAccept) { + Image(systemName: "checkmark") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color.green.opacity(0.8)) + .cornerRadius(18) + } + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color("Secondary")) + ) + .animation(.easeInOut(duration: 0.2), value: communityPageViewModel.loadingRequestAction) + } +} + +struct CircleRequestsView: View { + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.presentationMode) var presentationMode + + @State private var showSuccessAlert = false + @State private var alertMessage = "" + @State private var searchText = "" + + private var filteredRequests: [CircleRequest] { + if searchText.isEmpty { + return communityPageViewModel.circleRequests + } else { + return communityPageViewModel.circleRequests.filter { request in + request.from_username.localizedCaseInsensitiveContains(searchText) || + request.circle_name.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.title2) + } + + Spacer() + + Text("Group Requests") + .font(.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(.white) + + Spacer() + + + Button(action: { + refreshRequests() + }) { + Image(systemName: "arrow.clockwise") + .foregroundColor(.white) + .font(.system(size: 16)) + } + } + .padding() + + + SearchBar(searchText: $searchText) + .padding(.horizontal) + + Spacer().frame(height: 16) + + + if communityPageViewModel.loadingCircleRequests { + Spacer() + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.2) + Text("Loading requests...") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + Spacer() + + } else if communityPageViewModel.errorCircleRequests { + Spacer() + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(.red) + + Text("Failed to load requests") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text("Please try again") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + + Button(action: refreshRequests) { + Text("Retry") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color("Accent")) + .cornerRadius(20) + } + .padding(.top, 8) + } + Spacer() + + } else if filteredRequests.isEmpty { + Spacer() + VStack(spacing: 12) { + Image(systemName: searchText.isEmpty ? "person.2" : "magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(Color("Accent")) + + Text(searchText.isEmpty ? "No pending requests" : "No matching requests") + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(.white) + + Text(searchText.isEmpty ? + "You're all caught up! No one is waiting to join your circles." : + "Try adjusting your search terms") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + Spacer() + + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(filteredRequests, id: \.id) { request in + CircleRequestRow( + request: request, + onAccept: { + acceptRequest(request) + }, + onDecline: { + declineRequest(request) + } + ) + } + } + .padding(.horizontal) + .padding(.bottom, 100) + } + } + + Spacer() + } + .background(Color("Background").edgesIgnoringSafeArea(.all)) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .onAppear { + refreshRequests() + } + .refreshable { + refreshRequests() + } + .alert("Request Processed", isPresented: $showSuccessAlert) { + Button("OK") { } + } message: { + Text(alertMessage) + } + } + + private func refreshRequests() { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + communityPageViewModel.fetchCircleRequests(token: token) + } + + private func acceptRequest(_ request: CircleRequest) { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + + communityPageViewModel.acceptCircleRequest(circleId: request.circle_id, token: token) { success in + if success { + alertMessage = "you have been added to \(request.circle_name)" + showSuccessAlert = true + + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + } + } else { + alertMessage = "Failed to accept the request. Please try again." + showSuccessAlert = true + } + } + } + + private func declineRequest(_ request: CircleRequest) { + guard let token = authViewModel.loggedInBackendUser?.token else { return } + + communityPageViewModel.declineCircleRequest(circleId: request.circle_id, token: token) { success in + if success { + alertMessage = "Request from @\(request.from_username) has been declined" + showSuccessAlert = true + } else { + alertMessage = "Failed to decline the request. Please try again." + showSuccessAlert = true + } + } + } +} diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index a99e29d..24f0fac 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -4,7 +4,6 @@ // // Created by Rujin Devkota on 3/26/25. - import SwiftUI struct LeaveCircleAlert: View { @@ -59,14 +58,321 @@ 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 { + VStack { + Spacer() + VStack(spacing: 0) { + Button(action: { + onCancel() + onLeaveGroup() + }) { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + .foregroundColor(.red) + Text("Leave Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.red) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + 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") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Background")) + } + } + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + + +struct 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 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 { isUserBusy($0) }.count + } + + private var availableCount: Int { + 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 { VStack(spacing: 0) { @@ -75,7 +381,8 @@ struct InsideCircle: View { presentationMode.wrappedValue.dismiss() }) { Image(systemName: "chevron.left") - .foregroundColor(.white) + .foregroundColor(.white).font(.title2) + .foregroundColor(.white).font(.title2) } Spacer() Text("Circle") @@ -83,10 +390,14 @@ struct InsideCircle: View { .foregroundColor(.white) Spacer() Button(action: { - showLeaveAlert = true + showCircleMenu = true + showCircleMenu = true }) { - Image(systemName: "rectangle.portrait.and.arrow.right") + Image(systemName: "ellipsis") + Image(systemName: "ellipsis") .foregroundColor(.white) + .font(.system(size: 18)) + .font(.system(size: 18)) } } .padding() @@ -101,31 +412,52 @@ struct InsideCircle: View { .font(.custom("Poppins-SemiBold", size: 20)) .foregroundColor(.white) Spacer() - Text(groupCode) - .font(.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) + + } Spacer().frame(height: 5) HStack { - HStack { - Image("inclass").resizable().frame(width: 18, height: 18) - Text("3 busy") - .foregroundStyle(Color("Accent")) + + if busyCount > 0 { + HStack { + Image("inclass").resizable().frame(width: 18, height: 18) + Text("\(busyCount) busy") + .foregroundStyle(Color("Accent")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color("Secondary")) + .cornerRadius(12) + + Spacer().frame(width: 10) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color("Secondary")) - .cornerRadius(12) - Spacer().frame(width: 10) - HStack { - Image("available").resizable().frame(width: 18, height: 18) - Text("2 available") - .foregroundStyle(Color("Accent")) + + + if availableCount > 0 { + HStack { + Image("available").resizable().frame(width: 18, height: 18) + Text("\(availableCount) available") + .foregroundStyle(Color("Accent")) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color("Secondary")) + .cornerRadius(12) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color("Secondary")) - .cornerRadius(12) + + Spacer() + + + DualIconMenu( + onQRCode: { + showQRCode = true + print("QR Code tapped") + }, + onGenerateCode: { + showGenerateJoinCode = true + print("Generate Code tapped") + } + ) } } .padding() @@ -133,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: "free", - venue: "318" + status: getDisplayStatus(for: member), + venue: getDisplayVenue(for: member) ) .padding(.horizontal) } @@ -155,6 +489,9 @@ struct InsideCircle: View { Spacer() } .background(Color("Background").edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $showGroupRequests, content: { + CircleRequestsView() + }) .onAppear { communityPageViewModel.fetchCircleMemberData( from: "\(APIConstants.base_url)circles/\(groupCode)", @@ -173,17 +510,116 @@ struct InsideCircle: View { communityPageViewModel.leaveCircle(from: url, token: token) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showLeaveAlert = false presentationMode.wrappedValue.dismiss() } }) } + + 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 + } + ) + } } ) - .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 87aada1..238cb83 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -4,31 +4,51 @@ // // Created by Rujin Devkota on 2/27/25. - import SwiftUI +enum SheetType: Identifiable { + case addCircleOptions + case createGroup + case joinGroup + case groupRequests + + var id: Int { + switch self { + case .addCircleOptions: return 0 + case .createGroup: return 1 + case .joinGroup: return 2 + case .groupRequests: return 3 + } + } +} + + struct ConnectPage: View { @Environment(AuthViewModel.self) private var authViewModel @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 var isAddCircleFunc = false - @State var showCreateGroupSheet = false - @State var showJoinGroupSheet = false + @State private var activeSheet: SheetType? + @State private var showCircleMenu = false + @Environment(\.dismiss) private var dismiss + @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 { BackgroundView() - - VStack(spacing: 0) { HStack { @@ -37,10 +57,8 @@ struct ConnectPage: View { isCircleView = false } AcademicsTabButton(title: "Circles", isActive: selectedTab == 1) { - selectedTab = 1 isCircleView = true - } } .padding(.top,20) @@ -53,94 +71,262 @@ 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, destination: { AddFriendsView() } - ).offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) - } else{ + ) + .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) + } else { + ) + .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) + } else { Button(action: { - isAddCircleFunc.toggle() - + showCircleMenu = true + showCircleMenu = true }) { - Image(systemName: "person.fill.badge.plus") + 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)) + .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } - - }.sheet(isPresented: $isAddCircleFunc){ - ZStack{ - Color("Background") - HStack(spacing: 40) { - - Button(action:{ - showJoinGroupSheet.toggle() - }) { - VStack { - Image("joingroup") - .resizable() - .frame(width: 55, height: 55) - Text("Join Group") - .font(.system(size: 15)) - .foregroundStyle(Color.white) - } - } - - Button(action:{ - showJoinGroupSheet.toggle() - }) { - VStack { - Image("creategroup") - .resizable() - .frame(width: 55, height: 55) - Text("Create Group") - .font(.system(size: 15)) - .foregroundStyle(Color.white) - } - } - }.presentationDetents([.height(200)]) - .padding(.top, 10) - }.background(Color("Background")) } - .sheet(isPresented: $showCreateGroupSheet) { - CreateGroup(groupCode:.constant("")) - } - .sheet(isPresented: $showJoinGroupSheet) { - JoinGroup(groupCode: .constant("")) + .overlay( + Group { + if showCircleMenu { + ConnectCircleMenuView( + onCreateGroup: { + activeSheet = .createGroup + }, + onJoinGroup: { + activeSheet = .joinGroup + }, + onGroupRequests: { + activeSheet = .groupRequests + }, + onCancel: { + showCircleMenu = false + } + ) + } + } + ) + .sheet(item: $activeSheet) { sheetType in + switch sheetType { + case .addCircleOptions: + AddCircleOptionsView(activeSheet: $activeSheet) + case .createGroup: + CreateGroup(groupCode: .constant(""), token:authViewModel.loggedInBackendUser?.token ?? "",username: authViewModel.loggedInBackendUser?.username ?? "" ) + case .joinGroup: + JoinGroup(groupCode: .constant("")) + case .groupRequests: + CircleRequestsView() + } } .onAppear { - - communityPageViewModel.fetchFriendsData( - from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", + let shouldShowLoading = !hasLoadedInitialData + + + requestsViewModel.fetchFriendRequests( token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)requests/")!, - authToken: authViewModel.loggedInBackendUser?.token ?? "", - loading: true + loading: shouldShowLoading ) - + if communityPageViewModel.friends.isEmpty || !hasLoadedInitialData { + communityPageViewModel.fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + if communityPageViewModel.circles.isEmpty || !hasLoadedInitialData { + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + if communityPageViewModel.circleRequests.isEmpty || !hasLoadedInitialData { + friendRequestViewModel.fetchFriendRequests( + from: URL(string: "\(APIConstants.base_url)requests/")!, + authToken: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + hasLoadedInitialData = true } } } +struct ConnectCircleMenuView: View { + let onCreateGroup: () -> Void + let onJoinGroup: () -> Void + let onGroupRequests: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 0) { + Button(action: { + onCancel() + onCreateGroup() + }) { + HStack { + Image("creategroup") + .resizable() + .frame(width: 24, height: 24) + Text("Create Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + onJoinGroup() + }) { + HStack { + Image("joingroup") + .resizable() + .frame(width: 24, height: 24) + Text("Join Group") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: { + onCancel() + onGroupRequests() + }) { + HStack { + Image(systemName: "person.badge.plus") + .foregroundColor(.white) + Text("Group Requests") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + Spacer() + } + .padding() + .background(Color("Background")) + } + + Divider() + .background(Color.gray.opacity(0.3)) + + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Background")) + } + } + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} +struct AddCircleOptionsView: View { + @Binding var activeSheet: SheetType? + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Color("Background") + HStack(spacing: 40) { + Button(action: { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + activeSheet = .joinGroup + } + }) { + VStack { + Image("joingroup") + .resizable() + .frame(width: 55, height: 55) + Text("Join Group") + .font(.system(size: 15)) + .foregroundStyle(Color.white) + } + } + + Button(action: { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + activeSheet = .createGroup + } + }) { + VStack { + Image("creategroup") + .resizable() + .frame(width: 55, height: 55) + Text("Create Group") + .font(.system(size: 15)) + .foregroundStyle(Color.white) + } + } + } + .padding(.top, 10) + } + .background(Color("Background")) + .presentationDetents([.height(150)]) + } +} struct FilterPill: View { let title: String @@ -161,5 +347,4 @@ struct FilterPill: View { ) ) } - } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index f56c353..5c2a02c 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -5,6 +5,8 @@ // Created by Rujin Devkota on 2/27/25. // import SwiftUI + + struct FriendsView: View { @State private var searchText = "" @State private var selectedFilterOption = 0 @@ -17,7 +19,10 @@ struct FriendsView: View { SearchBar(searchText: $searchText) Spacer().frame(height: 8) - // Filter pills - always visible + + + + HStack { FilterPill(title: "Available", isSelected: selectedFilterOption == 0) .onTapGesture { @@ -32,7 +37,8 @@ struct FriendsView: View { .padding(.horizontal) Spacer().frame(height: 7) - // Conditional content based on state + + if communityPageViewModel.errorFreinds { Spacer() VStack(spacing: 5) { @@ -51,27 +57,86 @@ struct FriendsView: View { ProgressView() Spacer() } else { - // Filter friends based on search text + + let filteredFriends = communityPageViewModel.friends.filter { friend in + + let matchesSearch: Bool + + let matchesSearch: Bool if searchText.isEmpty { - return true + matchesSearch = true + matchesSearch = true } else { - return friend.username.localizedCaseInsensitiveContains(searchText) || + 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: + matchesFilter = friend.currentStatus.status == "free" + case 1: + matchesFilter = true + default: + matchesFilter = true + } + + return matchesSearch && matchesFilter } if filteredFriends.isEmpty { Spacer() - Text("No friends match your search") - .font(Font.custom("Poppins-Regular", size: 16)) - .foregroundColor(.white) + VStack(spacing: 5) { + if selectedFilterOption == 0 && !searchText.isEmpty { + Text("No available friends match your search") + } else if selectedFilterOption == 0 { + Text("No friends are currently available") + } else if !searchText.isEmpty { + Text("No friends match your search") + } else { + Text("You don't have any friends yet") + } + } + .font(Font.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + VStack(spacing: 5) { + if selectedFilterOption == 0 && !searchText.isEmpty { + Text("No available friends match your search") + } else if selectedFilterOption == 0 { + Text("No friends are currently available") + } else if !searchText.isEmpty { + Text("No friends match your search") + } else { + Text("You don't have any friends yet") + } + } + .font(Font.custom("Poppins-Regular", size: 16)) + .foregroundColor(.white) + .multilineTextAlignment(.center) Spacer() } else { ScrollView { VStack(spacing: 10) { ForEach(filteredFriends, id: \.username) { friend in - NavigationLink(destination: TimeTableView(friend: friend)) { + NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { + NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { FriendRow(friend: friend) } } @@ -79,10 +144,11 @@ struct FriendsView: View { .padding(.horizontal) } .safeAreaPadding(.bottom, 100) - } } - }.refreshable { + } + .refreshable { + communityPageViewModel.fetchFriendsData( from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", token: authViewModel.loggedInBackendUser?.token ?? "", diff --git a/VITTY/VITTY/Connect/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 24fbeb9..186343a 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -4,119 +4,376 @@ // // Created by Chandram Dutta on 04/01/24. // +// +// import Foundation import Alamofire import OSLog - - @Observable class CommunityPageViewModel { var friends = [Friend]() var circles = [CircleModel]() + var circleRequests = [CircleRequest]() + + var 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] = [:] private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: CommunityPageViewModel.self) ) - func fetchFriendsData(from url: String, token: String, loading: Bool) { - self.loadingFreinds = loading + func fetchFriendsData(from url: String, token: String, loading: Bool = false) { + + if loading || friends.isEmpty { + self.loadingFreinds = true + } + + + self.errorFreinds = false + 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 - - switch response.result { + 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.loadingFreinds = false - - + self.errorFreinds = false + + self.errorFreinds = false + case .failure(let error): - self.logger.error("Error fetching data: \(error)") - self.loadingFreinds = false - self.errorFreinds.toggle() + self.logger.error("Error fetching friends: \(error)") + + if self.friends.isEmpty { + self.errorFreinds = true + } + } + self.logger.error("Error fetching friends: \(error)") + + if self.friends.isEmpty { + self.errorFreinds = true + } + } } } } //MARK: Circle DATA - func fetchCircleData(from url: String, token: String, loading: Bool) { - self.loadingCircle = loading + func fetchCircleData(from url: String, token: String, loading: Bool = false) { + + if loading || circles.isEmpty { + self.loadingCircle = true + } + + + self.errorCircle = false + + func fetchCircleData(from url: String, token: String, loading: Bool = false) { + + if loading || circles.isEmpty { + self.loadingCircle = true + } + + + self.errorCircle = false + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleResponse.self) { response in - print("***********") - print(response) - switch response.result { + DispatchQueue.main.async { + self.loadingCircle = false + + switch response.result { + DispatchQueue.main.async { + self.loadingCircle = false + + switch response.result { case .success(let data): self.circles = data.data - self.loadingCircle = false - print(data.data) - print("Successfully fetched circles:") - print(data.data) + self.errorCircle = false + print("Successfully fetched circles: \(data.data)") + + self.errorCircle = false + print("Successfully fetched circles: \(data.data)") + case .failure(let error): self.logger.error("Error fetching circles: \(error)") - self.loadingCircle = false - self.errorCircle.toggle() + + if self.circles.isEmpty { + self.errorCircle = true + } + } + } + } + } + + // MARK: - Circle Requests + + func fetchCircleRequests(token: String, loading: Bool = false) { + if loading || circleRequests.isEmpty { + self.loadingCircleRequests = true + } + + self.errorCircleRequests = false + + let url = "\(APIConstants.base_url)circles/requests/received" + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseData { response in + DispatchQueue.main.async { + self.loadingCircleRequests = false + + switch response.result { + case .success(let data): + do { + let decodedResponse = try JSONDecoder().decode(CircleRequestResponse.self, from: data) + self.circleRequests = decodedResponse.data + self.errorCircleRequests = false + self.logger.info("Successfully fetched circle requests: \(decodedResponse.data.count) requests") + } catch { + self.logger.error("Error decoding circle requests: \(error)") + + if let jsonString = String(data: data, encoding: .utf8) { + self.logger.info("Raw response: \(jsonString)") + } + + response + self.circleRequests = [] + self.errorCircleRequests = false + } + + case .failure(let error): + self.logger.error("Error fetching circle requests: \(error)") + if self.circleRequests.isEmpty { + self.errorCircleRequests = true + } + } } } } - //MARK : Circle Members NetwrokCall - func fetchCircleMemberData(from url: String, token: String, loading: Bool) { - self.loadingCircleMembers = loading + + 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() + .responseData { response in // Changed to responseData to get more details + DispatchQueue.main.async { + self.loadingRequestAction = false + + switch response.result { + 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) + } + } + } + } + + func declineCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { + self.loadingRequestAction = true + + let url = "\(APIConstants.base_url)circles/declineRequest/\(circleId)" + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .response { response in + DispatchQueue.main.async { + self.loadingRequestAction = false + + switch response.result { + case .success: + self.logger.info("Successfully declined circle request for circle: \(circleId)") + self.circleRequests.removeAll { $0.circle_id == circleId } + completion(true) + + case .failure(let error): + self.logger.error("Error declining circle request: \(error)") + completion(false) + } + } + } + } + + func fetchCircleMemberData(from url: String, token: String, loading: Bool = false, circleID: String? = nil) { + if let circleID = circleID { + if loading || circleMembersDict[circleID]?.isEmpty != false { + self.loadingCircleMembersDict[circleID] = true + } + } else { + if loading || circleMembers.isEmpty { + self.loadingCircleMembers = true + } + } AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in - print("***********") - - switch response.result { - + DispatchQueue.main.async { + switch response.result { + DispatchQueue.main.async { + switch response.result { case .success(let data): - self.circleMembers = data.data - self.loadingCircleMembers = false - print(data.data) - print("Successfully fetched circles members :") - print(data.data) + 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)") + + 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 circles members: \(error)") - self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + 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 { + self.loadingCircleMembers = false + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } + } + } } } } + + //MARK : Circle Leave - func fetchCircleLeave(from url: String, token: String, loading: Bool) { - self.loadingCircleMembers = loading + func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { + if loading { + self.loadingCircleMembers = true + } + func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { + if loading { + self.loadingCircleMembers = true + } AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in - print("***********") - - switch response.result { + DispatchQueue.main.async { + self.loadingCircleMembers = false + DispatchQueue.main.async { + self.loadingCircleMembers = false + switch response.result { + switch response.result { case .success(let data): - self.circleMembers = data.data - self.loadingCircleMembers = false - print(data.data) - print("Successfully fetched circles members :") - print(data.data) + self.circleMembers = data.data + print("Successfully fetched circle members after leave: \(data.data)") + + self.circleMembers = data.data + print("Successfully fetched circle members after leave: \(data.data)") + case .failure(let error): - self.logger.error("Error fetching circles members: \(error)") - self.loadingCircleMembers = false - self.errorCircleMembers.toggle() + self.logger.error("Error fetching circle members: \(error)") + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } + } + self.logger.error("Error fetching circle members: \(error)") + if self.circleMembers.isEmpty { + self.errorCircleMembers = true + } + } } } } @@ -129,20 +386,225 @@ class CommunityPageViewModel { AF.request(url, method: .delete, headers: ["Authorization": "Token \(token)"]) .validate() .response { response in - switch response.result { - case .success(let value): - if let json = value as? [String: Any], let detail = json["detail"] as? String { - self.logger.info("Success: \(detail)") - } + DispatchQueue.main.async { self.loadingCircleMembers = false - - case .failure(let error): - self.logger.error("Error leaving circle: \(error)") + + switch response.result { + case .success(let value): + if let json = value as? [String: Any], let detail = json["detail"] as? String { + self.logger.info("Success: \(detail)") + } + + case .failure(let error): + self.logger.error("Error leaving circle: \(error)") + self.errorCircleMembers = true + } + } + } + } + + //MARK: 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 - self.errorCircleMembers.toggle() + + switch response.result { + case .success(let value): + if let json = value as? [String: Any], let detail = json["detail"] as? String { + self.logger.info("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] { + return circleMembersDict[circleID] ?? [] + } + + func isLoadingCircleMembers(for circleID: String) -> Bool { + return loadingCircleMembersDict[circleID] ?? false + } + + func clearCircleMembers(for circleID: String) { + circleMembersDict.removeValue(forKey: circleID) + loadingCircleMembersDict.removeValue(forKey: circleID) + } + + // MARK: - Group Creation + + func createCircle(name: String, token: String, completion: @escaping (Result) -> Void) { + + 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)"]) + .validate() + .responseJSON { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + if let json = data as? [String: Any], + 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: 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)) + } + } + } + } + + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { + + 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 + DispatchQueue.main.async { + switch response.result { + case .success: + self.logger.info("Successfully sent invitation to \(username) for circle \(circleId)") + completion(true) + + case .failure(let error): + self.logger.error("Error sending invitation to \(username): \(error)") + completion(false) + } } } } - + func sendMultipleInvitations(circleId: String, usernames: [String], token: String, completion: @escaping ([String: Bool]) -> Void) { + let dispatchGroup = DispatchGroup() + var results: [String: Bool] = [:] + + for username in usernames { + dispatchGroup.enter() + + sendCircleInvitation(circleId: circleId, username: username, token: token) { success in + results[username] = success + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + completion(results) + } + } + + // MARK: - Refresh Methods + + func refreshAllData(token: String, username: String) { + + fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(username)/", + token: token, + loading: false + ) + + + fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) + + + fetchCircleRequests(token: token, loading: false) + } + + 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 c3efa81..8c48c1a 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -1,107 +1,144 @@ -import SwiftUI - +import Foundation import SwiftUI struct HomeView: View { @Environment(AuthViewModel.self) private var authViewModel @State private var selectedPage = 1 @State private var showProfileSidebar: Bool = false - @State private var isCreatingGroup = false - - + @StateObject private var tipManager = CustomTipManager() + var body: some View { NavigationStack { ZStack { BackgroundView() - + VStack(spacing: 0) { - HStack { - Text( - selectedPage == 3 ? "Academics" : - selectedPage == 2 ? "Connects" : - "Schedule" - ) - .font(Font.custom("Poppins-Bold", size: 26)) - - Spacer() - - if selectedPage != 2 { - Button { - withAnimation { - showProfileSidebar = true - } - } label: { - UserImage( - url: authViewModel.loggedInBackendUser?.picture ?? "", - height: 30, - width: 40 - ) - } - }else{ - - - - - - } - } - .padding(.horizontal) - .padding(.top, 20) - .padding(.bottom, 8) + + topBar - ZStack { - switch selectedPage { - case 1: - TimeTableView(friend: nil) - case 2: - ConnectPage(isCreatingGroup: $isCreatingGroup) - case 3: - Academics() - default: - Text("Error Lol") - } - } - .padding(.top, 4) + + mainContent Spacer() - + BottomBarView(presentTab: $selectedPage) .padding(.bottom, 24) } + + + profileSidebar - - // In your HomeView - if showProfileSidebar { - ZStack { - // Full screen overlay to darken the background - Color.black.opacity(0.3) - .edgesIgnoringSafeArea(.all) - .onTapGesture { - withAnimation { - showProfileSidebar = false - } - } - - - GeometryReader { geometry in - HStack(spacing: 0) { - Spacer() - - UserProfileSidebar(isPresented: $showProfileSidebar) - .frame(width: geometry.size.width * 0.75) - .transition(.move(edge: .trailing)) - .background(Color.clear) - .edgesIgnoringSafeArea(.all) - } - } + + 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() + + 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 } - .edgesIgnoringSafeArea(.all) + } 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/Info.plist b/VITTY/VITTY/Info.plist index cb3198c..3193b8a 100644 --- a/VITTY/VITTY/Info.plist +++ b/VITTY/VITTY/Info.plist @@ -47,5 +47,7 @@ UIViewControllerBasedStatusBarAppearance + NSUserNotificationUsageDescription + We use notifications to remind you about your academic events diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 335cc18..19fbd36 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -1,52 +1,404 @@ -// -// SettingsView.swift -// VITTY -// -// Created by Ananya George on 12/24/21. -// - import SwiftUI +import SwiftData + + + struct SettingsView: View { - let githubURL = URL(string: "https://github.com/GDGVIT/vitty-ios") - let gdscURL = URL(string: "https://dscvit.com/") - var body: some View { - ZStack { - BackgroundView() - List { - Section(header: Text("About")) { - HStack { - Image("github-icon") - .resizable() - .scaledToFit() - .frame(width: 35, height: 35) - Text("GitHub Repository") - } - .frame(height: 35) - .listRowBackground(Color("Secondary")) - .onTapGesture { - if let url = githubURL { - UIApplication.shared.open(url) - } - } - HStack { - Image("gdsc-logo") - .resizable() - .scaledToFit() - .frame(width: 30, height: 30) - Text("GDSC VIT") - } - .frame(height: 35) - .listRowBackground(Color("Secondary")) - .onTapGesture { - if let url = gdscURL { - UIApplication.shared.open(url) - } - } - } - } - .scrollContentBackground(.hidden) - } - .navigationTitle("Settings") - } + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query private var timeTables: [TimeTable] + + @StateObject private var viewModel = SettingsViewModel() + + + @State private var showDaySelection = false + @State private var selectedDay: String? = nil + @State private var showResetAlert = false + + + private let selectedDayKey = "SelectedSaturdayDay" + + var body: some View { + NavigationStack { + ZStack { + BackgroundView() + + VStack { + headerView + + List { + SettingsSectionView(title: "Account Details") { + HStack(spacing: 12) { + UserImage( + url: authViewModel.loggedInBackendUser?.picture ?? "", + height: 60, + width: 60 + ) + VStack(alignment: .leading, spacing: 4) { + Text(authViewModel.loggedInFirebaseUser?.displayName ?? "") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.white) + + Text(authViewModel.loggedInFirebaseUser?.email ?? "") + .font(.system(size: 13)) + .foregroundColor(.gray.opacity(0.8)) + } + } + } + + SettingsSectionView(title: "Class Settings") { + VStack(alignment: .leading, spacing: 12) { + Button { + showDaySelection.toggle() + } label: { + SettingsRowView( + icon: "calendar.badge.plus", + title: "Saturday Class", + subtitle: selectedDay == nil ? "Select a day to copy classes to Saturday" : "Copy \(selectedDay!) classes to Saturday" + ) + } + .buttonStyle(PlainButtonStyle()) + + if showDaySelection { + VStack(alignment: .leading, spacing: 8) { + ForEach(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], id: \.self) { day in + HStack(spacing: 12) { + Image(systemName: selectedDay == day ? "largecircle.fill.circle" : "circle") + .foregroundColor(.blue) + .font(.system(size: 16)) + Text(day) + .foregroundColor(.white) + .font(.system(size: 14)) + Spacer() + } + .padding(.leading, 16) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + selectedDay = day + UserDefaults.standard.set(day, forKey: selectedDayKey) + copyLecturesToSaturday(from: day) + showDaySelection = false + } + } + } + .padding(.top, 8) + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)), + removal: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)) + )) + } + + + Button { + showResetAlert = true + } label: { + SettingsRowView( + icon: "trash.circle.fill", + title: "Reset Saturday Classes", + subtitle: "Remove all classes from Saturday" + ) + } + .buttonStyle(PlainButtonStyle()) + Button { + if let url = URL(string: "https://vitty.dscvit.com") { + UIApplication.shared.open(url) + } + } label: { + SettingsRowView( + icon: "pencil.and.ellipsis.rectangle", + title: "Update Timetable", + subtitle: "Keep your timetable up-to-date. Don't miss a class." + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + + SettingsSectionView(title: "Notifications") { + Toggle(isOn: $viewModel.notificationsEnabled) { + HStack { + Image(systemName: "bell.badge.fill") + .foregroundColor(.white) + Text("Enable Notifications") + .foregroundColor(.white) + .font(.system(size: 15, weight: .semibold)) + } + } + .toggleStyle(SwitchToggleStyle(tint: .green)) + } + + SettingsSectionView(title: "About") { + AboutLinkView(image: "github-icon", title: "GitHub Repository", url: URL(string: "https://github.com/GDGVIT/vitty-ios")) + AboutLinkView(image: "gdsc-logo", title: "GDSC VIT", url: URL(string: "https://dscvit.com/")) + } + } + .scrollContentBackground(.hidden) + } + + + if showResetAlert { + ResetSaturdayAlert( + onCancel: { + showResetAlert = false + }, + onReset: { + resetSaturdayClasses() + showResetAlert = false + } + ) + .zIndex(1) + } + } + .navigationBarBackButtonHidden(true) + .interactiveDismissDisabled(true) + .onAppear { + viewModel.timetable = timeTables.first + viewModel.checkNotificationAuthorization() + loadSelectedDay() + print("Saturday before save:", timeTables[0].saturday.map { $0.name }) + + } + .alert("Notifications Disabled", isPresented: $viewModel.showNotificationDisabledAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("You will no longer receive class reminders.") + } + } + } + + + + private func loadSelectedDay() { + selectedDay = UserDefaults.standard.string(forKey: selectedDayKey) + } + + private func resetSaturdayClasses() { + guard let timeTable = timeTables.first else { return } + + + let newTimeTable = TimeTable( + monday: timeTable.monday, + tuesday: timeTable.tuesday, + wednesday: timeTable.wednesday, + thursday: timeTable.thursday, + friday: timeTable.friday, + saturday: [], // Empty Saturday + sunday: timeTable.sunday + ) + + + modelContext.delete(timeTable) + modelContext.insert(newTimeTable) + + + do { + try modelContext.save() + print("Successfully reset Saturday classes") + + + UserDefaults.standard.removeObject(forKey: selectedDayKey) + selectedDay = nil + + } catch { + print("Error saving context: \(error)") + } + } + + private func copyLecturesToSaturday(from day: String) { + guard let timeTable = timeTables.first else { return } + + let lecturesToCopy: [Lecture] + + switch day { + case "Monday": + lecturesToCopy = timeTable.monday + case "Tuesday": + lecturesToCopy = timeTable.tuesday + case "Wednesday": + lecturesToCopy = timeTable.wednesday + case "Thursday": + lecturesToCopy = timeTable.thursday + case "Friday": + lecturesToCopy = timeTable.friday + default: + lecturesToCopy = [] + } + + + let newTimeTable = TimeTable( + monday: timeTable.monday, + tuesday: timeTable.tuesday, + wednesday: timeTable.wednesday, + thursday: timeTable.thursday, + friday: timeTable.friday, + saturday: lecturesToCopy.map { lecture in + Lecture( + name: lecture.name, + code: lecture.code, + venue: lecture.venue, + slot: lecture.slot, + type: lecture.type, + startTime: lecture.startTime, + endTime: lecture.endTime + ) + }, + sunday: timeTable.sunday + ) + + + modelContext.delete(timeTable) + modelContext.insert(newTimeTable) + + + do { + try modelContext.save() + print("Successfully replaced timetable with copied lectures from \(day) to Saturday") + } catch { + print("Error saving context: \(error)") + } + } + + private var headerView: some View { + HStack { + Button(action: { + dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.title2) + } + Spacer() + Text("Settings") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + Spacer() + } + .padding(.horizontal) + .padding(.top) + } + + struct SettingsSectionView: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + Section(header: Text(title).foregroundColor(.white)) { + content() + .padding(.vertical, 6) + } + .listRowBackground(Color("Secondary")) + } + } + + struct SettingsRowView: View { + let icon: String + let title: String + let subtitle: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .foregroundColor(.white) + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + Text(subtitle) + .font(.system(size: 12)) + .foregroundColor(.gray.opacity(0.8)) + } + + Spacer() + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + } + + struct AboutLinkView: View { + let image: String + let title: String + let url: URL? + + var body: some View { + HStack(spacing: 12) { + Image(image) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.vertical, 6) + .onTapGesture { + if let url = url { + UIApplication.shared.open(url) + } + } + } + } +} + +// Custom Reset Alert Component +struct ResetSaturdayAlert: View { + let onCancel: () -> Void + let onReset: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Reset Saturday Classes?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("Are you sure you want to remove all classes from Saturday? This action cannot be undone.") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onReset) { + Text("Reset") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .onTapGesture { + + } + } } diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift new file mode 100644 index 0000000..af0eb8a --- /dev/null +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -0,0 +1,218 @@ +import Foundation +import SwiftUI +import UserNotifications + +class SettingsViewModel : ObservableObject{ + @Published var notificationsEnabled: Bool = false { + @Published var notificationsEnabled: Bool = false { + didSet { + UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") + if notificationsEnabled { + if let timetable = self.timetable { + self.scheduleAllNotifications(from: timetable) + } + } else { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + showNotificationDisabledAlert = true + } + } + } + + @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() + } + + func checkNotificationAuthorization() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + if settings.authorizationStatus != .authorized { + self.notificationsEnabled = false + } + } + } + } + + func requestNotificationPermission() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + if settings.authorizationStatus == .authorized { + if let timetable = self.timetable { + self.scheduleAllNotifications(from: timetable) + } + } else { + self.notificationsEnabled = false + } + } + } + } + + func scheduleAllNotifications(from timetable: TimeTable) { + // 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 + (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 + (2, timetable.monday), // Monday = 2 + (3, timetable.tuesday), // Tuesday = 3 + (4, timetable.wednesday), // Wednesday = 4 + (5, timetable.thursday), // Thursday = 5 + (6, timetable.friday), // Friday = 6 + (7, timetable.saturday), // Saturday = 7 + (1, timetable.sunday) // Sunday = 1 + ] + + for (weekday, lectures) in weekdays { + for lecture in lectures { + guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { + print("Failed to parse time for lecture: \(lecture.name) with time: \(lecture.startTime)") + continue + } + + + guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { + print("Failed to parse time for lecture: \(lecture.name) with time: \(lecture.startTime)") + continue + } + + + scheduleNotification(for: lecture.name, at: startDate, title: "Class Starting", minutesBefore: 0) + + + + + scheduleNotification(for: lecture.name, at: startDate, title: "Upcoming Class", minutesBefore: 10) + } + } + + print("Scheduled notifications for all lectures") + + print("Scheduled notifications for all lectures") + } + + private func scheduleNotification(for lectureName: String, at date: Date, title: String, minutesBefore: Int) { + let triggerDate = Calendar.current.date(byAdding: .minute, value: -minutesBefore, to: date) ?? date + + let content = UNMutableNotificationContent() + content.title = title + content.body = "\(lectureName) is starting soon." + content.sound = .default + + let triggerComponents = Calendar.current.dateComponents([.weekday, .hour, .minute], from: triggerDate) + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: true) + + let 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 + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error scheduling notification: \(error)") + } else { + 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) + + + if formattedTimeString == "Failed to parse the time string." { + return nil + } + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mm a" + timeFormatter.locale = Locale(identifier: "en_US_POSIX") + + guard let timeDate = timeFormatter.date(from: formattedTimeString) else { + print("Failed to parse formatted time: \(formattedTimeString)") + return nil + } + + + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute], from: timeDate) + + + let today = Date() + let currentWeekday = calendar.component(.weekday, from: today) + + + let daysFromToday = weekday - currentWeekday + let targetDate = calendar.date(byAdding: .day, value: daysFromToday, to: today) ?? today + + + var finalDateComponents = calendar.dateComponents([.year, .month, .day], from: targetDate) + finalDateComponents.hour = timeComponents.hour + finalDateComponents.minute = timeComponents.minute + finalDateComponents.second = 0 + + guard let lectureDate = calendar.date(from: finalDateComponents) else { + print("Failed to create lecture date") + return nil + } + + + if weekday == currentWeekday && lectureDate < today { + return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) + } + + + if lectureDate < today { + return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) + } + + return lectureDate + } + + // Your existing formatTime function + private func formatTime(time: String) -> String { + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + 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/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 0c37cd6..27d0a12 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -8,8 +8,17 @@ import Foundation class Constants { - static let url = "https://vitty-api.dscvit.com/api/v2/" + static let url = + +// "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/" -// "http://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" } diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index 941d1b2..d958067 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -4,6 +4,12 @@ // // Created by Chandram Dutta on 09/02/24. // +// +// TimeTable.swift +// VITTY +// +// Created by Chandram Dutta on 09/02/24. +// import Foundation import OSLog @@ -12,211 +18,229 @@ import SwiftData class TimeTableRaw: Codable { - let data: TimeTable + let data: TimeTable - enum CodingKeys: String, CodingKey { - case data - } + enum CodingKeys: String, CodingKey { + case data + } } @Model class TimeTable: Codable { - var monday: [Lecture] - var tuesday: [Lecture] - var wednesday: [Lecture] - var thursday: [Lecture] - var friday: [Lecture] - var saturday: [Lecture] - var sunday: [Lecture] + var monday: [Lecture] + var tuesday: [Lecture] + var wednesday: [Lecture] + var thursday: [Lecture] + var friday: [Lecture] + var saturday: [Lecture] + var sunday: [Lecture] @Transient - var logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: TimeTable.self - ) - ) - init( - monday: [Lecture], - tuesday: [Lecture], - wednesday: [Lecture], - thursday: [Lecture], - friday: [Lecture], - saturday: [Lecture], - sunday: [Lecture] - ) { - self.monday = monday - self.tuesday = tuesday - self.wednesday = wednesday - self.thursday = thursday - self.friday = friday - self.saturday = saturday - self.sunday = sunday - } + var logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String( + describing: TimeTable.self + ) + ) + init( + monday: [Lecture], + tuesday: [Lecture], + wednesday: [Lecture], + thursday: [Lecture], + friday: [Lecture], + saturday: [Lecture], + sunday: [Lecture] + ) { + self.monday = monday + self.tuesday = tuesday + self.wednesday = wednesday + self.thursday = thursday + self.friday = friday + self.saturday = saturday + self.sunday = sunday + } enum CodingKeys: String, CodingKey,Codable { - case monday = "Monday" - case tuesday = "Tuesday" - case wednesday = "Wednesday" - case thursday = "Thursday" - case friday = "Friday" - case saturday = "Saturday" - case sunday = "Sunday" - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - do { - monday = try container.decode([Lecture].self, forKey: .monday) - } - catch { - logger.error("Error decoding Monday lectures: \(error)") - monday = [] - } - - do { - tuesday = try container.decode([Lecture].self, forKey: .tuesday) - } - catch { - logger.error("Error decoding Tuesday lectures: \(error)") - tuesday = [] - } - - do { - wednesday = try container.decode([Lecture].self, forKey: .wednesday) - } - catch { - logger.error("Error decoding Wednesday lectures: \(error)") - wednesday = [] - } - - do { - thursday = try container.decode([Lecture].self, forKey: .thursday) - } - catch { - logger.error("Error decoding Thursday lectures: \(error)") - thursday = [] - } - - do { - friday = try container.decode([Lecture].self, forKey: .friday) - } - catch { - logger.error("Error decoding Friday lectures: \(error)") - friday = [] - } - - do { - saturday = try container.decode([Lecture].self, forKey: .saturday) - } - catch { - logger.error("Error decoding Saturday lectures: \(error)") - saturday = [] - } - - do { - sunday = try container.decode([Lecture].self, forKey: .sunday) - } - catch { - logger.error("Error decoding Sunday lectures: \(error)") - sunday = [] - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(monday, forKey: .monday) - try container.encode(tuesday, forKey: .tuesday) - try container.encode(wednesday, forKey: .wednesday) - try container.encode(thursday, forKey: .thursday) - try container.encode(friday, forKey: .friday) - try container.encode(saturday, forKey: .saturday) - try container.encode(sunday, forKey: .sunday) - } + case monday = "Monday" + case tuesday = "Tuesday" + case wednesday = "Wednesday" + case thursday = "Thursday" + case friday = "Friday" + case saturday = "Saturday" + case sunday = "Sunday" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + do { + monday = try container.decode([Lecture].self, forKey: .monday) + } + catch { + logger.error("Error decoding Monday lectures: \(error)") + monday = [] + } + + do { + tuesday = try container.decode([Lecture].self, forKey: .tuesday) + } + catch { + logger.error("Error decoding Tuesday lectures: \(error)") + tuesday = [] + } + + do { + wednesday = try container.decode([Lecture].self, forKey: .wednesday) + } + catch { + logger.error("Error decoding Wednesday lectures: \(error)") + wednesday = [] + } + + do { + thursday = try container.decode([Lecture].self, forKey: .thursday) + } + catch { + logger.error("Error decoding Thursday lectures: \(error)") + thursday = [] + } + + do { + friday = try container.decode([Lecture].self, forKey: .friday) + } + catch { + logger.error("Error decoding Friday lectures: \(error)") + friday = [] + } + + do { + saturday = try container.decode([Lecture].self, forKey: .saturday) + } + catch { + logger.error("Error decoding Saturday lectures: \(error)") + saturday = [] + } + + do { + sunday = try container.decode([Lecture].self, forKey: .sunday) + } + catch { + logger.error("Error decoding Sunday lectures: \(error)") + sunday = [] + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(monday, forKey: .monday) + try container.encode(tuesday, forKey: .tuesday) + try container.encode(wednesday, forKey: .wednesday) + try container.encode(thursday, forKey: .thursday) + try container.encode(friday, forKey: .friday) + try container.encode(saturday, forKey: .saturday) + try container.encode(sunday, forKey: .sunday) + } } @Model class Lecture: Codable, Identifiable, Comparable { - static func == (lhs: Lecture, rhs: Lecture) -> Bool { - return lhs.name == rhs.name - } + static func == (lhs: Lecture, rhs: Lecture) -> Bool { + return lhs.name == rhs.name + } - static func < (lhs: Lecture, rhs: Lecture) -> Bool { - return lhs.startTime < rhs.startTime - } + static func < (lhs: Lecture, rhs: Lecture) -> Bool { + return lhs.startTime < rhs.startTime + } - var name: String + var name: String var code: String var venue: String var slot: String var type: String var startTime: String - var endTime: String - - init( - name: String, - code: String, - venue: String, - slot: String, - type: String, - startTime: String, - endTime: String - ) { - self.name = name - self.code = code - self.venue = venue - self.slot = slot - self.type = type - self.startTime = startTime - self.endTime = endTime - } + var endTime: String + + init( + name: String, + code: String, + venue: String, + slot: String, + type: String, + startTime: String, + endTime: String + ) { + self.name = name + self.code = code + self.venue = venue + self.slot = slot + self.type = type + self.startTime = startTime + self.endTime = endTime + } enum CodingKeys: String, CodingKey,Codable { - case name, code, venue, slot, type - case startTime = "start_time" - case endTime = "end_time" - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - code = try container.decode(String.self, forKey: .code) - venue = try container.decode(String.self, forKey: .venue) - slot = try container.decode(String.self, forKey: .slot) - type = try container.decode(String.self, forKey: .type) - startTime = try container.decode(String.self, forKey: .startTime) - endTime = try container.decode(String.self, forKey: .endTime) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(code, forKey: .code) - try container.encode(venue, forKey: .venue) - try container.encode(slot, forKey: .slot) - try container.encode(type, forKey: .type) - try container.encode(startTime, forKey: .startTime) - try container.encode(endTime, forKey: .endTime) - } + case name, code, venue, slot, type + case startTime = "start_time" + case endTime = "end_time" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + code = try container.decode(String.self, forKey: .code) + venue = try container.decode(String.self, forKey: .venue) + slot = try container.decode(String.self, forKey: .slot) + type = try container.decode(String.self, forKey: .type) + startTime = try container.decode(String.self, forKey: .startTime) + endTime = try container.decode(String.self, forKey: .endTime) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(code, forKey: .code) + try container.encode(venue, forKey: .venue) + try container.encode(slot, forKey: .slot) + try container.encode(type, forKey: .type) + try container.encode(startTime, forKey: .startTime) + try container.encode(endTime, forKey: .endTime) + } } + + extension TimeTable { var isEmpty: Bool { monday.isEmpty && tuesday.isEmpty && wednesday.isEmpty && thursday.isEmpty && friday.isEmpty && saturday.isEmpty && sunday.isEmpty } - private func extractStartDate(from timeString: String) -> Date? { - let components = timeString.components(separatedBy: " - ") - guard let startTimeString = components.first else { return nil } + + private func extractStartTime(from lecture: Lecture) -> Date? { + let formattedTime = formatTime(time: lecture.startTime) + + + guard formattedTime != "Failed to parse the time string." else { return nil } + + 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) + } - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter.date(from: startTimeString) - } func classesFor(date: Date) -> [Classes] { let calendar = Calendar.current @@ -242,29 +266,79 @@ extension TimeTable { ) } - return mapped.sorted { - guard let d1 = extractStartDate(from: $0.time), - let d2 = extractStartDate(from: $1.time) else { + // Sort using the original lecture objects instead of formatted strings + return lectures.sorted { lecture1, lecture2 in + guard let time1 = extractStartTime(from: lecture1), + let time2 = extractStartTime(from: lecture2) else { + // Sort using the original lecture objects instead of formatted strings + return lectures.sorted { lecture1, lecture2 in + guard let time1 = extractStartTime(from: lecture1), + let time2 = extractStartTime(from: lecture2) else { return false } - return d1 < d2 + return time1 < time2 + }.map { + Classes( + title: $0.name, + time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", + slot: $0.slot + ) + } + } + 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 ?? "" + 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." + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } + } + + func isDifferentFrom(_ other: TimeTable) -> Bool { + return monday != other.monday || + tuesday != other.tuesday || + wednesday != other.wednesday || + thursday != other.thursday || + friday != other.friday || + saturday != other.saturday || + sunday != other.sunday + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } } + + func isDifferentFrom(_ other: TimeTable) -> Bool { + return monday != other.monday || + tuesday != other.tuesday || + wednesday != other.wednesday || + thursday != other.thursday || + friday != other.friday || + saturday != other.saturday || + sunday != other.sunday } } diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index 97b6bf9..7eb83d0 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -5,76 +5,266 @@ // Created by Chandram Dutta on 09/02/24. // + import Foundation import OSLog import SwiftData public enum Stage { - case loading - case error - case data + case loading + case error + case data + case loading + case error + case data } -extension TimeTableView { - - @Observable - class TimeTableViewModel { - - var timeTable: TimeTable? - var stage: Stage = .loading - var lectures = [Lecture]() - var dayNo = Date.convertToMondayWeek() - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: TimeTableViewModel.self - ) - ) - - func changeDay() { - switch dayNo { - case 0: - self.lectures = timeTable?.monday ?? [] - case 1: - self.lectures = timeTable?.tuesday ?? [] - case 2: - self.lectures = timeTable?.wednesday ?? [] - case 3: - self.lectures = timeTable?.thursday ?? [] - case 4: - self.lectures = timeTable?.friday ?? [] - case 5: - self.lectures = timeTable?.saturday ?? [] - case 6: - self.lectures = timeTable?.sunday ?? [] - default: - self.lectures = [] - } - } +extension TimeTableView { + @Observable + class TimeTableViewModel { - - func fetchTimeTable(username: String, authToken: String) async { - logger.info("Fetching TimeTable Started") - do { - stage = .loading - let data = try await TimeTableAPIService.shared.getTimeTable( - with: username, - authToken: authToken - ) - logger.info("TimeTable Fetched from API") - timeTable = data - changeDay() - stage = .data - } - catch { - logger.error("\(error)") - stage = .error - } - logger.info("Fetching TimeTable Ended") - } + var timeTable: TimeTable? + var stage: Stage = .loading + var lectures = [Lecture]() + var dayNo = Date.convertToMondayWeek() + + private var hasSyncedThisSession = false + private var isSyncing = false + private var currentContext: ModelContext? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String( + describing: TimeTableViewModel.self + ) + ) + + func changeDay() { + switch dayNo { + case 0: + self.lectures = timeTable?.monday ?? [] + case 1: + self.lectures = timeTable?.tuesday ?? [] + case 2: + self.lectures = timeTable?.wednesday ?? [] + case 3: + self.lectures = timeTable?.thursday ?? [] + case 4: + self.lectures = timeTable?.friday ?? [] + case 5: + self.lectures = timeTable?.saturday ?? [] + case 6: + self.lectures = timeTable?.sunday ?? [] + default: + self.lectures = [] + } + } - + @MainActor + func loadTimeTable( + existingTimeTable: TimeTable?, + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Starting timetable loading process") + + // Store context for later use + currentContext = context + + if let existing = existingTimeTable { + logger.debug("Using existing local timetable") + timeTable = existing + changeDay() + stage = .data + print("\(existing)") + + // Start background sync if not already done + if !hasSyncedThisSession && !isSyncing { + Task { + await backgroundSync( + localTimeTable: existing, + username: username, + authToken: authToken, + context: context + ) + } + } + } else { + logger.debug("No local timetable, fetching from API") + await fetchTimeTableFromAPI( + username: username, + authToken: authToken, + context: context + ) + } + } - } + private func backgroundSync( + localTimeTable: TimeTable, + username: String, + authToken: String, + context: ModelContext + ) async { + guard !isSyncing else { return } + + isSyncing = true + hasSyncedThisSession = true + + logger.info("Starting background sync") + + do { + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + logger.info("Background sync: Fetched remote timetable") + + if shouldUpdateLocalTimeTable(local: localTimeTable, remote: remoteTimeTable) { + logger.info("Background sync: Timetables differ, updating local data") + await updateLocalTimeTableWithPersistence( + oldTimeTable: localTimeTable, + newTimeTable: remoteTimeTable, + context: context + ) + } else { + logger.info("Background sync: Timetables are identical, no update needed") + } + + } catch { + logger.error("Background sync failed: \(error)") + } + + isSyncing = false + } + + private func shouldUpdateLocalTimeTable(local: TimeTable, remote: TimeTable) -> Bool { + let daysToCompare = [ + (local.monday, remote.monday), + (local.tuesday, remote.tuesday), + (local.wednesday, remote.wednesday), + (local.thursday, remote.thursday), + (local.friday, remote.friday), + (local.saturday, remote.saturday), + (local.sunday, remote.sunday) + ] + + for (localDay, remoteDay) in daysToCompare { + if !areLectureArraysEqual(localDay, remoteDay) { + return true + } + } + + return false + } + + private func areLectureArraysEqual(_ local: [Lecture], _ remote: [Lecture]) -> Bool { + guard local.count == remote.count else { return false } + + let sortedLocal = local.sorted { $0.startTime < $1.startTime } + let sortedRemote = remote.sorted { $0.startTime < $1.startTime } + + for (localLecture, remoteLecture) in zip(sortedLocal, sortedRemote) { + if !areLecturesEqual(localLecture, remoteLecture) { + return false + } + } + + return true + } + + private func areLecturesEqual(_ local: Lecture, _ remote: Lecture) -> Bool { + return local.name == remote.name && + local.code == remote.code && + local.venue == remote.venue && + local.slot == remote.slot && + local.type == remote.type && + local.startTime == remote.startTime && + local.endTime == remote.endTime + } + + @MainActor + private func 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 + private func fetchTimeTableFromAPI( + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Fetching TimeTable from API") + + do { + stage = .loading + let data = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + logger.info("TimeTable fetched from API") + + timeTable = data + changeDay() + stage = .data + + context.insert(data) + try context.save() + hasSyncedThisSession = true + + } catch { + logger.error("API fetch failed: \(error)") + stage = .error + } + } + + var updatedTimeTable: TimeTable? { + timeTable + } + var updatedTimeTable: TimeTable? { + timeTable + } + + func resetSyncStatus() { + hasSyncedThisSession = false + 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 8aa7a11..4140f97 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift @@ -89,19 +89,31 @@ struct LectureDetailView: View { } } - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + private func formatTime(time: String) -> String { + var timeComponents = time.components(separatedBy: "T").last ?? "" + timeComponents = timeComponents.components(separatedBy: "+").first ?? "" + 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.") - } - } + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } + } + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + if let date = dateFormatter.date(from: timeComponents) { + dateFormatter.dateFormat = "h:mm a" + let formattedTime = dateFormatter.string(from: date) + return (formattedTime) + } + else { + return ("Failed to parse the time string.") + } + } } diff --git a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift index b557ed3..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,15 +90,166 @@ 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() + } + } + + // 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) + } + + 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() @@ -73,12 +263,3 @@ struct LectureItemView: View { } } } - - -#Preview { - LectureItemView( - lecture: Lecture(name: "hello", code: "qww", venue: "123", slot: "asd", type: "asad", startTime: "time1", endTime: "time") - , onTap: {} - ) -} - diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 38dc13a..f7ec92a 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -1,31 +1,68 @@ + + import OSLog import SwiftData import SwiftUI - struct TimeTableView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase + @Environment(\.scenePhase) private var scenePhase private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - + + @State private var viewModel = TimeTableViewModel() @State private var selectedLecture: Lecture? = nil - @Query private var timetableItem : [TimeTable] + @Query private var timetableItem: [TimeTable] + @Environment(\.dismiss) private var dismiss + @Query private var timetableItem: [TimeTable] + @Environment(\.dismiss) private var dismiss let friend: Friend? - + + var isFriendsTimeTable: Bool + + + var isFriendsTimeTable: Bool + private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String( describing: TimeTableView.self ) ) - + + var body: some View { - NavigationStack{ + NavigationStack { + NavigationStack { ZStack { BackgroundView() - switch viewModel.stage { + VStack { + if isFriendsTimeTable { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + } + Spacer() + }.padding(8) + } + + switch viewModel.stage { + VStack { + if isFriendsTimeTable { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")).font(.title2) + } + Spacer() + }.padding(8) + } + + switch viewModel.stage { case .loading: VStack { Spacer() @@ -48,11 +85,13 @@ struct TimeTableView: View { ForEach(daysOfWeek, id: \.self) { day in Text(day) .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) + ? 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 + ? Color("Accent") : Color.clear ) .onTapGesture { withAnimation { @@ -71,7 +110,7 @@ struct TimeTableView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) - + if viewModel.lectures.isEmpty { Spacer() Text("No classes today!") @@ -82,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) @@ -93,51 +138,35 @@ struct TimeTableView: View { } } } - } - } - .sheet(item: $selectedLecture) { lecture in - LectureDetailView(lecture: lecture) - } - .onAppear { - print(authViewModel.loggedInBackendUser?.token ?? "auth auth token") - logger.debug("onAppear triggered") - if let existing = timetableItem.first { - logger.debug("exixting") - - if existing.isEmpty { - - logger.debug("is empty") - Task { - await viewModel.fetchTimeTable( - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), - authToken: authViewModel.loggedInBackendUser?.token ?? "" - ) - if let fetched = viewModel.timeTable { - context.insert(fetched) - } - } - } else { - - - viewModel.timeTable = existing - viewModel.changeDay() - viewModel.stage = .data - } - } else { - logger.debug("fetching") - Task { - await viewModel.fetchTimeTable( - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), - authToken: authViewModel.loggedInBackendUser?.token ?? "" - ) - if let fetched = viewModel.timeTable { - context.insert(fetched) - - } } } } - } + .sheet(item: $selectedLecture) { lecture in + LectureDetailView(lecture: lecture) + } + .navigationBarBackButtonHidden(true) + .onAppear { + logger.debug("onAppear triggered") + loadTimetable() + } + .onChange(of: scenePhase) { _, newPhase in + + if newPhase == .active { + viewModel.resetSyncStatus() + } + } + } + + private func loadTimetable() { + Task { + await viewModel.loadTimeTable( + existingTimeTable: timetableItem.first, + username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), + authToken: authViewModel.loggedInBackendUser?.token ?? "", + context: context + ) + } + print("this is users token is \(authViewModel.loggedInBackendUser?.token ?? "")") } } diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index e0aff32..8bc6646 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -1,44 +1,48 @@ import SwiftUI +import OSLog +import SwiftData + + + struct UserProfileSidebar: View { @Environment(AuthViewModel.self) private var authViewModel @Binding var isPresented: Bool @State private var ghostMode: Bool = false + @State private var isUpdatingGhostMode: Bool = false + @Environment(\.modelContext) private var modelContext var body: some View { ZStack(alignment: .topTrailing) { - Button { - withAnimation { - isPresented = false - } + isPresented = false + isPresented = false } label: { Image(systemName: "xmark") .foregroundColor(.white) .padding() } + VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 8) { UserImage( url: authViewModel.loggedInBackendUser?.picture ?? "", height: 60, width: 60 ) - Text(authViewModel.loggedInBackendUser?.name ?? "User") .font(Font.custom("Poppins-Bold", size: 18)) .foregroundColor(.white) - Text("@\(authViewModel.loggedInBackendUser?.username ?? "")") .font(Font.custom("Poppins-Regular", size: 14)) .foregroundColor(.white.opacity(0.8)) } .padding(.top, 40) - Divider() - .background(Color.clear) + + Divider().background(Color.clear) + NavigationLink { EmptyClassRoom() @@ -46,51 +50,99 @@ struct UserProfileSidebar: View { MenuOption(icon: "emptyclassroom", title: "Find Empty Classroom") } + NavigationLink { SettingsView() } label: { MenuOption(icon: "settings", title: "Settings") } - Divider() - .background(Color.clear) - MenuOption(icon: "share", title: "Share") + 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: "support", title: "Support") +// 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: "about", title: "About") + Divider().background(Color.clear) - Divider() - .background(Color.clear) VStack(alignment: .leading, spacing: 4) { Text("Ghost Mode") .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) - Text("(your timetable will be visible only to you)") .font(Font.custom("Poppins-Regular", size: 12)) .foregroundColor(.white.opacity(0.7)) - Toggle("", isOn: $ghostMode) - .labelsHidden() - .toggleStyle(SwitchToggleStyle(tint: Color("Accent"))) - .padding(.top, 4) + 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) + } + } + + 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() - // Logout button + 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") - .foregroundColor(Color.red) + .foregroundColor(.red) Text("log out") .font(Font.custom("Poppins-Medium", size: 16)) - .foregroundColor(Color.red) + .foregroundColor(.red) } } .padding(.bottom, 32) @@ -99,25 +151,148 @@ struct UserProfileSidebar: View { .frame(width: UIScreen.main.bounds.width * 0.75, alignment: .leading) .frame(maxHeight: .infinity) .background(Color("Background")) - + .transition(.move(edge: .trailing)) + } + .animation(.easeInOut(duration: 0.3), value: isPresented) + .onAppear { + loadGhostModeState() } } + + // MARK: - Ghost Mode Functions + + private func loadGhostModeState() { + + let username = authViewModel.loggedInBackendUser?.username ?? "" + ghostMode = UserDefaults.standard.bool(forKey: "ghostMode_\(username)") + } + + private func updateGhostMode(enabled: Bool) { + guard let username = authViewModel.loggedInBackendUser?.username, + let token = authViewModel.loggedInBackendUser?.token else { + return + } + + isUpdatingGhostMode = true + + + let endpoint = enabled ? "ghost" : "alive" + let urlString = "\(APIConstants.base_url)friends/\(endpoint)/\(username)" + + guard let url = URL(string: urlString) else { + isUpdatingGhostMode = false + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isUpdatingGhostMode = false + + if let error = error { + print("Ghost mode update failed: \(error.localizedDescription)") + + ghostMode = !enabled + return + } + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + + UserDefaults.standard.set(enabled, forKey: "ghostMode_\(username)") + print("Ghost mode \(enabled ? "enabled" : "disabled") successfully") + } else { + print("Ghost mode update failed with status code: \(httpResponse.statusCode)") + + ghostMode = !enabled + } + } + } + }.resume() + .transition(.move(edge: .trailing)) + } + .animation(.easeInOut(duration: 0.3), value: isPresented) + .onAppear { + loadGhostModeState() + } + } + + // MARK: - Ghost Mode Functions + + private func loadGhostModeState() { + + let username = authViewModel.loggedInBackendUser?.username ?? "" + ghostMode = UserDefaults.standard.bool(forKey: "ghostMode_\(username)") + } + + private func updateGhostMode(enabled: Bool) { + guard let username = authViewModel.loggedInBackendUser?.username, + let token = authViewModel.loggedInBackendUser?.token else { + return + } + + isUpdatingGhostMode = true + + + let endpoint = enabled ? "ghost" : "alive" + let urlString = "\(APIConstants.base_url)friends/\(endpoint)/\(username)" + + guard let url = URL(string: urlString) else { + isUpdatingGhostMode = false + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isUpdatingGhostMode = false + + if let error = error { + print("Ghost mode update failed: \(error.localizedDescription)") + + ghostMode = !enabled + return + } + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + + UserDefaults.standard.set(enabled, forKey: "ghostMode_\(username)") + print("Ghost mode \(enabled ? "enabled" : "disabled") successfully") + } else { + print("Ghost mode update failed with status code: \(httpResponse.statusCode)") + + ghostMode = !enabled + } + } + } + }.resume() + } } struct MenuOption: View { let icon: String let title: String + + + var body: some View { HStack(spacing: 16) { Image(icon) .foregroundColor(.white) .frame(width: 24) - Text(title) .font(Font.custom("Poppins-Medium", size: 16)) .foregroundColor(.white) } } } - diff --git a/VITTY/VITTY/Username/Views/UsernameView.swift b/VITTY/VITTY/Username/Views/UsernameView.swift index 090130a..4aa7ef9 100644 --- a/VITTY/VITTY/Username/Views/UsernameView.swift +++ b/VITTY/VITTY/Username/Views/UsernameView.swift @@ -18,12 +18,15 @@ struct UsernameView: View { @State private var isLoading = false @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ZStack { BackgroundView() VStack(alignment: .leading) { + headerView + Text("Enter username and your registration number below.") .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) @@ -63,7 +66,7 @@ struct UsernameView: View { TextField("Username", text: $username) .padding() } - .background(Color("tfBlue")) + .cornerRadius(18) .padding(.top) Text(userNameErrorString) @@ -73,7 +76,7 @@ struct UsernameView: View { TextField("Registration No.", text: $regNo) .padding() } - .background(Color("tfBlue")) + .cornerRadius(18) .padding(.top) Text(regNoErrorString) @@ -108,17 +111,43 @@ struct UsernameView: View { } } - .background(Color("brightBlue")) + .cornerRadius(18) } .padding(.horizontal) } - .navigationTitle("Let's Sign You In") + .navigationBarBackButtonHidden(true) } .accentColor(.white) } - + private var headerView: some View { + VStack{ + HStack { + Button(action: { + dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .font(.title2) + } + Spacer() + + + } + + .padding(.top) + + HStack{ + Text("Let's Sign you in ") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + Spacer() + }.padding([.top,.bottom]) + } + } + func checkUserExists(completion: @escaping (Result) -> Void) { guard let url = URL(string: "\(Constants.url)auth/check-username") else { completion(.failure(AuthAPIServiceError.invalidUrl)) @@ -160,7 +189,3 @@ struct UsernameView: View { } } -#Preview { - UsernameView() - .preferredColorScheme(.dark) -} diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 8b1202d..1cbadeb 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -5,8 +5,16 @@ // 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/" + static let declineRequest = "circles/declineRequest/" + static let circleRequests = "circles/requests/received" + static let friends = "friends" } diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index 6bf71dc..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,40 +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 - ) - ) - - init() { - setupFirebase() - } - - var body: some Scene { - WindowGroup { - ContentView() - .preferredColorScheme(.dark) - }.modelContainer(sharedModelContainer) - } + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String( + describing: VITTYApp.self + ) + ) + + @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) + .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]) - 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 + } +}