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()
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) -> ()) {
+ 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..