From 239fd7dc46b759fc83e2ee7f65ca9acc3f936eeb Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sat, 19 Jul 2025 00:42:43 +0530 Subject: [PATCH 1/8] feat: circle members timetable and campus fix --- .gitignore | 1 + VITTY/VITTY.xcodeproj/project.pbxproj | 8 + VITTY/VITTY/Auth/Service/AuthAPIService.swift | 16 + .../VITTY/Auth/ViewModels/AuthViewModel.swift | 9 +- .../AddFriends/View/AddFriendsView.swift | 1 + .../View/Components/FreindRequestCard.swift | 2 + .../View/Circles/View/CircleTimetable.swift | 395 ++++++++++++++++++ .../View/Circles/View/InsideCircle.swift | 20 +- .../Connect/View/Freinds/View/Freinds.swift | 5 +- .../ViewModel/CommunityPageViewModel.swift | 47 +++ .../EmptyClassroom/View/EmptyClass.swift | 2 +- VITTY/VITTY/Home/View/HomeView.swift | 2 +- .../Service/TimeTableAPIService.swift | 49 ++- .../Views/FriendsTimetableView.swift | 380 +++++++++++++++++ .../VITTY/TimeTable/Views/TimeTableView.swift | 12 +- 15 files changed, 905 insertions(+), 44 deletions(-) create mode 100644 VITTY/VITTY/Connect/View/Circles/View/CircleTimetable.swift create mode 100644 VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift diff --git a/.gitignore b/.gitignore index d49a7ea..49b09dc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ xcuserdata/ GoogleService-Info.plist Confidential.swift VITTY/VITTY/Firebase/Dev/GoogleService-Info-6.plist +GoogleService-Info.plist diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index a1ef1e5..3722e0c 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ 4B7DA5E72D71AC54007354A3 /* CirclesRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5E62D71AC51007354A3 /* CirclesRow.swift */; }; 4B7DA5F22D7228F9007354A3 /* JoinGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5F12D7228E5007354A3 /* JoinGroup.swift */; }; 4B7DA5F52D7237BE007354A3 /* CreateGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5F42D7237B8007354A3 /* CreateGroup.swift */; }; + 4B80972B2E2947A300FF2F63 /* FriendsTimetableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B80972A2E29479800FF2F63 /* FriendsTimetableView.swift */; }; + 4B80972D2E2ABD8500FF2F63 /* CircleTimetable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B80972C2E2ABD8100FF2F63 /* CircleTimetable.swift */; }; 4B8B32CA2D6D75F4004F01BA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B8B32C92D6D75F4004F01BA /* WidgetKit.framework */; }; 4B8B32CB2D6D75F4004F01BA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3105871C27A3ECBB00C2FC41 /* SwiftUI.framework */; }; 4B8B32DC2D6D75F6004F01BA /* VittyWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -222,6 +224,8 @@ 4B7DA5E62D71AC51007354A3 /* CirclesRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CirclesRow.swift; sourceTree = ""; }; 4B7DA5F12D7228E5007354A3 /* JoinGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinGroup.swift; sourceTree = ""; }; 4B7DA5F42D7237B8007354A3 /* CreateGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroup.swift; sourceTree = ""; }; + 4B80972A2E29479800FF2F63 /* FriendsTimetableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsTimetableView.swift; sourceTree = ""; }; + 4B80972C2E2ABD8100FF2F63 /* CircleTimetable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTimetable.swift; sourceTree = ""; }; 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VittyWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4B8B32C92D6D75F4004F01BA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 4B8B33742D7029A3004F01BA /* SideBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideBar.swift; sourceTree = ""; }; @@ -543,6 +547,7 @@ 4B7DA5EC2D71E0FB007354A3 /* View */ = { isa = PBXGroup; children = ( + 4B80972C2E2ABD8100FF2F63 /* CircleTimetable.swift */, 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B7DA5E42D70B2C8007354A3 /* Circles.swift */, 4BF0C79C2D94680A00016202 /* InsideCircle.swift */, @@ -865,6 +870,7 @@ 527E3E042B7662760086F23D /* Views */ = { isa = PBXGroup; children = ( + 4B80972A2E29479800FF2F63 /* FriendsTimetableView.swift */, 4B40E1DB2E28D0B9004F8447 /* EmptyTimeTable.swift */, 527E3E072B7662920086F23D /* TimeTableView.swift */, 525F759C2B809F8400E3B418 /* LectureDetailView.swift */, @@ -1159,6 +1165,7 @@ 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */, 5DC0AF552AD2B586006B081D /* UserImage.swift in Sources */, 5238C7F42B4AB07400413946 /* FriendReqCard.swift in Sources */, + 4B80972D2E2ABD8500FF2F63 /* CircleTimetable.swift in Sources */, 4B1BDBCC2E1396B1008C2DE9 /* ToolTip.swift in Sources */, 4B7DA5DC2D708BD3007354A3 /* LectureItemView.swift in Sources */, 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */, @@ -1221,6 +1228,7 @@ 521562AC2B70B0FD0054F051 /* InstructionView.swift in Sources */, 522B8BAD2B47297A00EE686E /* CommunityPageViewModel.swift in Sources */, 4BF0C77D2D932B8E00016202 /* CircleModel.swift in Sources */, + 4B80972B2E2947A300FF2F63 /* FriendsTimetableView.swift in Sources */, 528CF1762B769E22007298A0 /* TimeTableViewModel.swift in Sources */, 4B7DA5F52D7237BE007354A3 /* CreateGroup.swift in Sources */, 52D5AB8F2B6FE82E00B2E66D /* AuthAPIService.swift in Sources */, diff --git a/VITTY/VITTY/Auth/Service/AuthAPIService.swift b/VITTY/VITTY/Auth/Service/AuthAPIService.swift index 647dc22..dc61a63 100644 --- a/VITTY/VITTY/Auth/Service/AuthAPIService.swift +++ b/VITTY/VITTY/Auth/Service/AuthAPIService.swift @@ -30,6 +30,22 @@ class AuthAPIService { let appUser = try decoder.decode(AppUser.self, from: data.0) return appUser } + + func signInUserWhenExists( + with authRequestBody: FirebaseAuthRequest + ) async throws -> AppUser { + print(authRequestBody.uuid) + let url = URL(string: "\(Constants.url)auth/firebase/")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(authRequestBody) + let data = try await URLSession.shared.data(for: request) + let decoder = JSONDecoder() + let appUser = try decoder.decode(AppUser.self, from: data.0) + return appUser + } func checkUserExists(with authID: String) async throws -> Bool { let url = URL(string: "\(Constants.url)auth/check-user-exists")! diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index c6b30c0..ccd9b00 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -211,13 +211,8 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { do { if (try await AuthAPIService.shared.checkUserExists(with: self.loggedInFirebaseUser!.uid)) { - self.loggedInBackendUser = try await AuthAPIService.shared.signInUser( - with: AuthRequestBody( - uuid: self.loggedInFirebaseUser!.uid, - reg_no: "", - username: "", - campus: "" - ) + self.loggedInBackendUser = try await AuthAPIService.shared.signInUserWhenExists( + with: FirebaseAuthRequest(uuid: self.loggedInFirebaseUser?.uid ?? "") ) UserDefaults.standard.set( diff --git a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift index 10c4dfd..11620e8 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/AddFriendsView.swift @@ -10,6 +10,7 @@ struct AddFriendsView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel @Environment(RequestsViewModel.self) private var friendRequestsViewModel + @Environment(CommunityPageViewModel.self) private var communityViewModel @Environment(\.dismiss) private var dismiss @State private var isSearchViewPresented = false diff --git a/VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift b/VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift index 7d2f353..e260725 100644 --- a/VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift +++ b/VITTY/VITTY/Connect/AddFriends/View/Components/FreindRequestCard.swift @@ -12,6 +12,7 @@ struct FriendRequestCard: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(RequestsViewModel.self) private var friendRequestsViewModel @Environment(SuggestedFriendsViewModel.self) private var suggestedFriendsViewModel + @Environment(CommunityPageViewModel.self) private var communityViewModel let request: FriendRequest @State private var isAccepting = false @@ -121,6 +122,7 @@ struct FriendRequestCard: View { } isAccepting = false } + communityViewModel.fetchFriendsData(from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", token: authViewModel.loggedInBackendUser?.token ?? "") } } diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleTimetable.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleTimetable.swift new file mode 100644 index 0000000..8fbf8fa --- /dev/null +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleTimetable.swift @@ -0,0 +1,395 @@ +// +// CircleTimetable.swift +// VITTY +// +// Created by Rujin Devkota on 7/18/25. +// + +// +// CircleMemberTimetableView.swift +// VITTY +// +// Created by Rujin Devkota on 7/18/25. +// + +import OSLog +import SwiftData +import SwiftUI + +struct CircleMemberTimetableView: View { + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss + + private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + @State private var viewModel = CircleMemberTimetableViewModel() + @State private var selectedLecture: Lecture? = nil + @State private var isRefreshing = false + @State private var showingRefreshAlert = false + + let member: CircleUserTemp + let circleId: String + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: CircleMemberTimetableView.self) + ) + + var body: some View { + NavigationStack { + ZStack { + BackgroundView() + VStack { + + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + .font(.title2) + } + + Spacer() + + Text("\(member.name)'s Timetable") + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Spacer() + + + } + .padding(.horizontal) + .padding(.bottom, 8) + + switch viewModel.stage { + case .loading: + VStack { + Spacer() + ProgressView() + .scaleEffect(1.2) + Text("Loading \(member.name)'s timetable...") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + Spacer() + } + + case .error: + VStack { + Spacer() + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundColor(.orange) + .padding(.bottom, 16) + + Text("Couldn't load timetable") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("Unable to fetch \(member.name)'s timetable") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.bottom, 20) + + Button(action: { + showingRefreshAlert = true + }) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Try Again") + } + .foregroundColor(.white) + .padding() + .background(Color("Accent")) + .cornerRadius(10) + } + .disabled(isRefreshing) + + Spacer() + } + + case .empty: + VStack { + Spacer() + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + .padding(.bottom, 16) + + Text("No timetable available") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("\(member.name) hasn't shared their timetable yet") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer() + } + + case .data: + VStack(spacing: 0) { + // Day selector + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack { + ForEach(daysOfWeek, id: \.self) { day in + Text(day) + .foregroundStyle(daysOfWeek[viewModel.dayNo] == day + ? Color("Background") : Color("Accent")) + .frame(width: 60, height: 54) + .background( + daysOfWeek[viewModel.dayNo] == day + ? Color("Accent") : Color.clear + ) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.dayNo = daysOfWeek.firstIndex(of: day)! + viewModel.changeDay() + proxy.scrollTo(day, anchor: .center) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + .id(day) + } + } + .padding(.horizontal, 8) + } + .scrollIndicators(.hidden) + .onAppear { + let currentDay = daysOfWeek[viewModel.dayNo] + proxy.scrollTo(currentDay, anchor: .center) + } + .onChange(of: viewModel.dayNo) { oldValue, newValue in + let selectedDay = daysOfWeek[newValue] + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(selectedDay, anchor: .center) + } + } + } + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + + + if viewModel.lectures.isEmpty { + Spacer() + VStack(spacing: 16) { + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + + Text("No classes today!") + .font(Font.custom("Poppins-Bold", size: 24)) + + Text("\(member.name) has no classes on \(daysOfWeek[viewModel.dayNo])") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + Spacer() + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(viewModel.lectures.sorted()) { lecture in + LectureItemView( + lecture: lecture, + selectedDayIndex: viewModel.dayNo, + allLectures: viewModel.lectures + ) { + selectedLecture = lecture + } + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) + } + } + } + } + } + } + } + .sheet(item: $selectedLecture) { lecture in + LectureDetailView(lecture: lecture) + } + .alert("Refresh Timetable", isPresented: $showingRefreshAlert) { + Button("Cancel", role: .cancel) { } + Button("Refresh", role: .destructive) { + Task { + await refreshTimetable() + } + } + } message: { + Text("This will fetch fresh data from the server. Continue?") + } + .navigationBarBackButtonHidden(true) + .onAppear { + logger.debug("CircleMemberTimetableView appeared for member: \(member.username)") + loadCircleMemberTimetable() + } + } + + private func loadCircleMemberTimetable() { + logger.debug("Loading circle member's timetable from API") + + + let calendar = Calendar.current + let today = calendar.component(.weekday, from: Date()) + let dayIndex = (today == 1) ? 6 : today - 2 + + if dayIndex >= 0 && dayIndex < daysOfWeek.count { + viewModel.dayNo = dayIndex + } else { + viewModel.dayNo = 0 + } + + Task { + await viewModel.loadCircleMemberTimetable( + circleId: circleId, + memberUsername: member.username, + authToken: authViewModel.loggedInBackendUser?.token ?? "" + ) + } + } + + private func refreshTimetable() async { + await MainActor.run { + isRefreshing = true + } + + await viewModel.refreshCircleMemberTimetable( + circleId: circleId, + memberUsername: member.username, + authToken: authViewModel.loggedInBackendUser?.token ?? "" + ) + + await MainActor.run { + isRefreshing = false + } + } +} + +// MARK: - Circle Member Timetable ViewModel +extension CircleMemberTimetableView { + @Observable + class CircleMemberTimetableViewModel { + 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: CircleMemberTimetableViewModel.self) + ) + + func changeDay() { + guard let timeTable = timeTable else { + self.lectures = [] + return + } + + 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 loadCircleMemberTimetable( + circleId: String, + memberUsername: String, + authToken: String + ) async { + logger.info("Loading timetable for circle member: \(memberUsername) in circle: \(circleId)") + + stage = .loading + + guard !circleId.isEmpty && !memberUsername.isEmpty && !authToken.isEmpty else { + logger.error("Missing circle ID, member username, or auth token") + stage = .error + return + } + + await fetchCircleMemberTimetableFromAPI( + circleId: circleId, + memberUsername: memberUsername, + authToken: authToken + ) + } + + @MainActor + func refreshCircleMemberTimetable( + circleId: String, + memberUsername: String, + authToken: String + ) async { + logger.info("Refreshing timetable for circle member: \(memberUsername) in circle: \(circleId)") + + stage = .loading + + await fetchCircleMemberTimetableFromAPI( + circleId: circleId, + memberUsername: memberUsername, + authToken: authToken + ) + } + + @MainActor + private func fetchCircleMemberTimetableFromAPI( + circleId: String, + memberUsername: String, + authToken: String + ) async { + do { + logger.info("Fetching circle member's timetable from API") + + let memberTimeTable = try await TimeTableAPIService.shared.getCircleMemberTimeTable( + circleId: circleId, + memberUsername: memberUsername, + authToken: authToken + ) + + if isTimeTableEmpty(memberTimeTable) { + logger.info("Circle member's timetable is empty") + self.timeTable = memberTimeTable + self.lectures = [] + self.stage = .empty + return + } + + + self.timeTable = memberTimeTable + changeDay() + stage = .data + + logger.info("Successfully fetched circle member's timetable from API") + + } catch { + logger.error("Failed to fetch circle member's timetable: \(error.localizedDescription)") + stage = .error + } + } + + private func isTimeTableEmpty(_ timeTable: TimeTable) -> Bool { + return timeTable.monday.isEmpty && + timeTable.tuesday.isEmpty && + timeTable.wednesday.isEmpty && + timeTable.thursday.isEmpty && + timeTable.friday.isEmpty && + timeTable.saturday.isEmpty && + timeTable.sunday.isEmpty + } + } +} diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index a5605a3..82f5b2c 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -479,13 +479,19 @@ struct InsideCircle: View { ScrollView { VStack(spacing: 10) { ForEach(filteredMembers, id: \.username) { member in - InsideCircleRow( - picture: member.picture, - name: member.name, - status: getDisplayStatus(for: member), - - venue: getDisplayVenue(for: member) - ) + + NavigationLink(destination: CircleMemberTimetableView( + member: member, + circleId: circle_id + )) { + InsideCircleRow( + picture: member.picture, + name: member.name, + status: getDisplayStatus(for: member), + venue: getDisplayVenue(for: member) + ) + } + .buttonStyle(PlainButtonStyle()) .padding(.horizontal) } } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index f477d6a..67db375 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -1,5 +1,5 @@ // -// Freinds.swift +// Friends.swift // VITTY // // Created by Rujin Devkota on 2/27/25. @@ -99,7 +99,8 @@ struct FriendsView: View { ScrollView { VStack(spacing: 10) { ForEach(filteredFriends, id: \.username) { friend in - NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { + // Updated to use FriendsTimeTableView instead of TimeTableView + NavigationLink(destination: FriendsTimeTableView(friend: friend)) { FriendRow(friend: friend) } } diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index b665af1..7970104 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -30,6 +30,11 @@ class CommunityPageViewModel { var circleMembersDict: [String: [CircleUserTemp]] = [:] var loadingCircleMembersDict: [String: Bool] = [:] + + // MARK: - New Member Timetable Properties + var memberTimetable: TimeTable? + var loadingMemberTimetable = false + var errorMemberTimetable = false private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, @@ -135,6 +140,48 @@ class CommunityPageViewModel { } } + // MARK: - New Member Timetable Function + + func fetchMemberTimetable(circleId: String, username: String, token: String, loading: Bool = true) { + if loading { + self.loadingMemberTimetable = true + } + + self.errorMemberTimetable = false + + let url = "\(APIConstants.base_url)circles/\(circleId)/\(username)" + + print("Fetching member timetable from: \(url)") + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: TimeTableRaw.self) { response in + DispatchQueue.main.async { + self.loadingMemberTimetable = false + + switch response.result { + case .success(let data): + self.memberTimetable = data.data + self.errorMemberTimetable = false + self.logger.info("Successfully fetched member timetable for \(username)") + + case .failure(let error): + self.logger.error("Error fetching member timetable for \(username): \(error)") + self.errorMemberTimetable = true + self.memberTimetable = nil + } + } + } + } + + // MARK: - Clear Member Timetable + + func clearMemberTimetable() { + self.memberTimetable = nil + self.loadingMemberTimetable = false + self.errorMemberTimetable = false + } + // MARK: - Circle Requests func fetchCircleRequests(token: String, loading: Bool = false) { diff --git a/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift b/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift index 03e2df0..acdd70b 100644 --- a/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift +++ b/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift @@ -149,7 +149,7 @@ struct EmptyClassRoom: View { .foregroundColor(.blue.opacity(0.8)) .font(.subheadline) - Text("Please try reloading in a moment") + Text("Please check again after a while") .foregroundColor(.white.opacity(0.6)) .font(.caption) } diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index e041b0f..1ee84bd 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -349,7 +349,7 @@ struct HomeView: View { ZStack { switch selectedPage { case 1: - TimeTableView(friend: nil, isFriendsTimeTable: false) + TimeTableView(friend: nil) case 2: ConnectPage(isCreatingGroup: $isCreatingGroup) case 3: diff --git a/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift b/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift index e7ba917..782f472 100644 --- a/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift +++ b/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift @@ -8,21 +8,38 @@ import Foundation class TimeTableAPIService { - static let shared = TimeTableAPIService() - - func getTimeTable( - with username: String, - authToken: String - ) async throws -> TimeTable { + static let shared = TimeTableAPIService() + + func getTimeTable( + with username: String, + authToken: String + ) async throws -> TimeTable { + + let url = URL(string: "\(Constants.url)timetable/\(username)")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization") + let data = try await URLSession.shared.data(for: request) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let timeTableRaw = try decoder.decode(TimeTableRaw.self, from: data.0) + return timeTableRaw.data + } + + func getCircleMemberTimeTable( + circleId: String, + memberUsername: String, + authToken: String + ) async throws -> TimeTable { - let url = URL(string: "\(Constants.url)timetable/\(username)")! - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization") - let data = try await URLSession.shared.data(for: request) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let timeTableRaw = try decoder.decode(TimeTableRaw.self, from: data.0) - return timeTableRaw.data - } + let url = URL(string: "\(Constants.url)circles/\(circleId)/\(memberUsername)")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization") + let data = try await URLSession.shared.data(for: request) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let timeTableRaw = try decoder.decode(TimeTableRaw.self, from: data.0) + return timeTableRaw.data + } } diff --git a/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift new file mode 100644 index 0000000..feff9b5 --- /dev/null +++ b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift @@ -0,0 +1,380 @@ +// +// FriendsTimetableView.swift +// VITTY +// +// Created by Rujin Devkota on 7/17/25. +// + +import OSLog +import SwiftData +import SwiftUI + +struct FriendsTimeTableView: View { + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss + + private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + @State private var viewModel = FriendsTimeTableViewModel() + @State private var selectedLecture: Lecture? = nil + @State private var isRefreshing = false + @State private var showingRefreshAlert = false + + let friend: Friend + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: FriendsTimeTableView.self) + ) + + var body: some View { + NavigationStack { + ZStack { + BackgroundView() + VStack { + + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + .font(.title2) + } + + Spacer() + + Text("\(friend.name ?? friend.username)'s Timetable") + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Spacer() + + + } + .padding(.horizontal) + .padding(.bottom, 8) + + switch viewModel.stage { + case .loading: + VStack { + Spacer() + ProgressView() + .scaleEffect(1.2) + Text("Loading \(friend.name ?? friend.username)'s timetable...") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + Spacer() + } + + case .error: + VStack { + Spacer() + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundColor(.orange) + .padding(.bottom, 16) + + Text("Couldn't load timetable") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("Unable to fetch \(friend.name ?? friend.username)'s timetable") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.bottom, 20) + + Button(action: { + showingRefreshAlert = true + }) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Try Again") + } + .foregroundColor(.white) + .padding() + .background(Color("Accent")) + .cornerRadius(10) + } + .disabled(isRefreshing) + + Spacer() + } + + case .empty: + VStack { + Spacer() + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + .padding(.bottom, 16) + + Text("No timetable available") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("\(friend.name ?? friend.username) hasn't shared their timetable yet") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer() + } + + case .data: + VStack(spacing: 0) { + // Day selector + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack { + ForEach(daysOfWeek, id: \.self) { day in + Text(day) + .foregroundStyle(daysOfWeek[viewModel.dayNo] == day + ? Color("Background") : Color("Accent")) + .frame(width: 60, height: 54) + .background( + daysOfWeek[viewModel.dayNo] == day + ? Color("Accent") : Color.clear + ) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.dayNo = daysOfWeek.firstIndex(of: day)! + viewModel.changeDay() + proxy.scrollTo(day, anchor: .center) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + .id(day) + } + } + .padding(.horizontal, 8) + } + .scrollIndicators(.hidden) + .onAppear { + let currentDay = daysOfWeek[viewModel.dayNo] + proxy.scrollTo(currentDay, anchor: .center) + } + .onChange(of: viewModel.dayNo) { oldValue, newValue in + let selectedDay = daysOfWeek[newValue] + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(selectedDay, anchor: .center) + } + } + } + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + + // Lectures list + if viewModel.lectures.isEmpty { + Spacer() + VStack(spacing: 16) { + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + + Text("No classes today!") + .font(Font.custom("Poppins-Bold", size: 24)) + + Text("Your friend has no classes on \(daysOfWeek[viewModel.dayNo])") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + Spacer() + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(viewModel.lectures.sorted()) { lecture in + LectureItemView( + lecture: lecture, + selectedDayIndex: viewModel.dayNo, + allLectures: viewModel.lectures + ) { + selectedLecture = lecture + } + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) + } + } + } + } + } + } + } + .sheet(item: $selectedLecture) { lecture in + LectureDetailView(lecture: lecture) + } + .alert("Refresh Timetable", isPresented: $showingRefreshAlert) { + Button("Cancel", role: .cancel) { } + Button("Refresh", role: .destructive) { + Task { + await refreshTimetable() + } + } + } message: { + Text("This will fetch fresh data from the server. Continue?") + } + .navigationBarBackButtonHidden(true) + .onAppear { + logger.debug("FriendsTimeTableView appeared for friend: \(friend.username)") + loadFriendsTimetable() + } + } + + private func loadFriendsTimetable() { + logger.debug("Loading friend's timetable from API") + + + let calendar = Calendar.current + let today = calendar.component(.weekday, from: Date()) + let dayIndex = (today == 1) ? 6 : today - 2 + + if dayIndex >= 0 && dayIndex < daysOfWeek.count { + viewModel.dayNo = dayIndex + } else { + viewModel.dayNo = 0 + } + + Task { + await viewModel.loadFriendsTimetable( + friendUsername: friend.username, + authToken: authViewModel.loggedInBackendUser?.token ?? "" + ) + } + } + + private func refreshTimetable() async { + await MainActor.run { + isRefreshing = true + } + + await viewModel.refreshFriendsTimetable( + friendUsername: friend.username, + authToken: authViewModel.loggedInBackendUser?.token ?? "" + ) + + await MainActor.run { + isRefreshing = false + } + } +} + +// MARK: - Friends Timetable ViewModel +extension FriendsTimeTableView { + @Observable + class FriendsTimeTableViewModel { + 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: FriendsTimeTableViewModel.self) + ) + + func changeDay() { + guard let timeTable = timeTable else { + self.lectures = [] + return + } + + 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 loadFriendsTimetable( + friendUsername: String, + authToken: String + ) async { + logger.info("Loading timetable for friend: \(friendUsername)") + + stage = .loading + + guard !friendUsername.isEmpty && !authToken.isEmpty else { + logger.error("Missing friend username or auth token") + stage = .error + return + } + + await fetchFriendsTimetableFromAPI( + friendUsername: friendUsername, + authToken: authToken + ) + } + + @MainActor + func refreshFriendsTimetable( + friendUsername: String, + authToken: String + ) async { + logger.info("Refreshing timetable for friend: \(friendUsername)") + + stage = .loading + + await fetchFriendsTimetableFromAPI( + friendUsername: friendUsername, + authToken: authToken + ) + } + + @MainActor + private func fetchFriendsTimetableFromAPI( + friendUsername: String, + authToken: String + ) async { + do { + logger.info("Fetching friend's timetable from API") + + + let friendTimeTable = try await TimeTableAPIService.shared.getTimeTable( + with: friendUsername, + authToken: authToken + ) + + if isTimeTableEmpty(friendTimeTable) { + logger.info("Friend's timetable is empty") + self.timeTable = friendTimeTable + self.lectures = [] + self.stage = .empty + return + } + + + self.timeTable = friendTimeTable + changeDay() + stage = .data + + logger.info("Successfully fetched friend's timetable from API") + + } catch { + logger.error("Failed to fetch friend's timetable: \(error.localizedDescription)") + stage = .error + } + } + + private func isTimeTableEmpty(_ timeTable: TimeTable) -> Bool { + return timeTable.monday.isEmpty && + timeTable.tuesday.isEmpty && + timeTable.wednesday.isEmpty && + timeTable.thursday.isEmpty && + timeTable.friday.isEmpty && + timeTable.saturday.isEmpty && + timeTable.sunday.isEmpty + } + } +} diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 709582e..c091469 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -18,7 +18,7 @@ struct TimeTableView: View { @Environment(\.dismiss) private var dismiss let friend: Friend? - var isFriendsTimeTable: Bool + private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, @@ -32,15 +32,7 @@ struct TimeTableView: View { ZStack { BackgroundView() VStack { - if isFriendsTimeTable { - HStack { - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")).font(.title2) - } - Spacer() - }.padding(8) - } + switch viewModel.stage { case .loading: From 1bbefc1ffdb6a8cffc2af75ee20feb294c1bda5b Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sat, 19 Jul 2025 12:07:54 +0530 Subject: [PATCH 2/8] fix: nscamera and photo description --- VITTY/VITTY/Info.plist | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/VITTY/VITTY/Info.plist b/VITTY/VITTY/Info.plist index d1aca8e..827548b 100644 --- a/VITTY/VITTY/Info.plist +++ b/VITTY/VITTY/Info.plist @@ -57,6 +57,10 @@ fetch remote-notification + NSCameraUsageDescription + Allow camera access to enable capturing notes and information + NSPhotoLibraryUsageDescription + Allow photo library access to save or attach notes, files . UIViewControllerBasedStatusBarAppearance From f2a3cbeee7cf5deac56867cc18e22c86888578f8 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sat, 19 Jul 2025 12:30:13 +0530 Subject: [PATCH 3/8] feat: review feature --- VITTY/ContentView.swift | 108 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/VITTY/ContentView.swift b/VITTY/ContentView.swift index 498ed34..68e0920 100644 --- a/VITTY/ContentView.swift +++ b/VITTY/ContentView.swift @@ -5,9 +5,8 @@ // Created by Ananya George on 11/7/21. // - - import SwiftUI +import StoreKit struct ContentView: View { @State private var communityPageViewModel = CommunityPageViewModel() @@ -15,20 +14,21 @@ struct ContentView: View { @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.loggedInBackendUser != nil { HomeView() + .onAppear { + + ReviewManager.shared.trackAppUsage() + ReviewManager.shared.requestReviewIfAppropriate() + } } - else if authViewModel.loggedInFirebaseUser != nil { InstructionView() } - else { LoginView() } @@ -39,7 +39,103 @@ struct ContentView: View { .environment(friendRequestViewModel) .environment(academicsViewModel) .environment(requestViewModel) + } +} + +// MARK: - Review Manager +class ReviewManager: ObservableObject { + static let shared = ReviewManager() + + private let reviewRequestKey = "LastReviewRequestDate" + private let hasReviewedKey = "HasUserReviewed" + private let appUsageCountKey = "AppUsageCount" + private let firstLaunchDateKey = "FirstLaunchDate" + + // Configuration + private let minimumUsageCount = 10 // Minimum number of app uses before review + private let minimumDaysOfUsage = 7 // Minimum days since first launch + private let monthsInterval: TimeInterval = 30 * 24 * 60 * 60 // 30 days between requests + + private init() { + + if UserDefaults.standard.object(forKey: firstLaunchDateKey) == nil { + UserDefaults.standard.set(Date(), forKey: firstLaunchDateKey) + } + } + + func trackAppUsage() { + let currentCount = UserDefaults.standard.integer(forKey: appUsageCountKey) + UserDefaults.standard.set(currentCount + 1, forKey: appUsageCountKey) + } + + func requestReviewIfAppropriate() { + + if UserDefaults.standard.bool(forKey: hasReviewedKey) { + return + } + + + guard meetsUsageRequirements() else { + return + } + + + guard hasEnoughTimePassed() else { + return + } + + DispatchQueue.main.async { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + SKStoreReviewController.requestReview(in: windowScene) + } + } + + + UserDefaults.standard.set(Date(), forKey: reviewRequestKey) + } + + private func meetsUsageRequirements() -> Bool { + let usageCount = UserDefaults.standard.integer(forKey: appUsageCountKey) + guard let firstLaunchDate = UserDefaults.standard.object(forKey: firstLaunchDateKey) as? Date else { + return false + } + + let daysSinceFirstLaunch = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 + + return usageCount >= minimumUsageCount && daysSinceFirstLaunch >= minimumDaysOfUsage + } + + private func hasEnoughTimePassed() -> Bool { + guard let lastRequestDate = UserDefaults.standard.object(forKey: reviewRequestKey) as? Date else { + return true + } + + let now = Date() + return now.timeIntervalSince(lastRequestDate) >= monthsInterval + } + + func markAsReviewed() { + UserDefaults.standard.set(true, forKey: hasReviewedKey) + } + + func resetReviewStatus() { + UserDefaults.standard.removeObject(forKey: hasReviewedKey) + UserDefaults.standard.removeObject(forKey: reviewRequestKey) + UserDefaults.standard.removeObject(forKey: appUsageCountKey) + UserDefaults.standard.removeObject(forKey: firstLaunchDateKey) + } + + // MARK: - Debug/Testing Methods + func getCurrentUsageCount() -> Int { + return UserDefaults.standard.integer(forKey: appUsageCountKey) + } + + func getDaysSinceFirstLaunch() -> Int { + guard let firstLaunchDate = UserDefaults.standard.object(forKey: firstLaunchDateKey) as? Date else { + return 0 + } + return Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 } } From 72dee041e76047da47a04c36cc66fb0ebe784b5c Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Mon, 21 Jul 2025 10:39:32 +0530 Subject: [PATCH 4/8] feat: app store reviews and updates alerts --- VITTY/ContentView.swift | 21 +- VITTY/VITTY.xcodeproj/project.pbxproj | 4 + .../Views/Components/FriendReqCard.swift | 8 +- .../Views/FriendRequestView.swift | 2 +- .../Connect/Search/Views/SearchView.swift | 76 ++--- .../Views/SuggestedFriendsView.swift | 2 +- .../View/Circles/Components/CreateGroup.swift | 10 +- .../View/Circles/View/CircleRequests.swift | 2 +- .../Connect/View/Circles/View/Circles.swift | 37 +-- .../View/Circles/View/InsideCircle.swift | 8 +- VITTY/VITTY/Connect/View/ConnectPage.swift | 6 +- .../ViewModel/CommunityPageViewModel.swift | 128 +++++++-- .../Service/EmptyClassAPIService.swift | 2 +- VITTY/VITTY/Home/View/HomeView.swift | 2 +- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 2 +- .../Utilities/Constants/APIConstants.swift | 8 + VITTY/VITTY/Utilities/UpdateManager.swift | 264 ++++++++++++++++++ VITTY/VITTYApp.swift | 2 +- 18 files changed, 478 insertions(+), 106 deletions(-) create mode 100644 VITTY/VITTY/Utilities/UpdateManager.swift diff --git a/VITTY/ContentView.swift b/VITTY/ContentView.swift index 68e0920..55cbd4a 100644 --- a/VITTY/ContentView.swift +++ b/VITTY/ContentView.swift @@ -38,7 +38,26 @@ struct ContentView: View { .environment(suggestedFriendsViewModel) .environment(friendRequestViewModel) .environment(academicsViewModel) - .environment(requestViewModel) + .environment(requestViewModel).alert("Update Available", isPresented: .constant(UpdateManager.shared.showUpdateAlert)) { + Button("Update Now") { + UpdateManager.shared.openAppStore() + UpdateManager.shared.dismissUpdateAlert() + } + + if let updateInfo = UpdateManager.shared.updateInfo, !updateInfo.isForced { + Button("Skip This Version") { + UpdateManager.shared.skipThisVersion() + } + + Button("Later") { + UpdateManager.shared.dismissUpdateAlert() + } + } + } message: { + if let updateInfo = UpdateManager.shared.updateInfo { + Text("Version \(updateInfo.latestVersion) is available.\n\n\(updateInfo.releaseNotes)") + } + } } } diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 3722e0c..0fb20d6 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 4B7DA5F52D7237BE007354A3 /* CreateGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5F42D7237B8007354A3 /* CreateGroup.swift */; }; 4B80972B2E2947A300FF2F63 /* FriendsTimetableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B80972A2E29479800FF2F63 /* FriendsTimetableView.swift */; }; 4B80972D2E2ABD8500FF2F63 /* CircleTimetable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B80972C2E2ABD8100FF2F63 /* CircleTimetable.swift */; }; + 4B8097552E2B7B6300FF2F63 /* UpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8097542E2B7B5F00FF2F63 /* UpdateManager.swift */; }; 4B8B32CA2D6D75F4004F01BA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B8B32C92D6D75F4004F01BA /* WidgetKit.framework */; }; 4B8B32CB2D6D75F4004F01BA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3105871C27A3ECBB00C2FC41 /* SwiftUI.framework */; }; 4B8B32DC2D6D75F6004F01BA /* VittyWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -226,6 +227,7 @@ 4B7DA5F42D7237B8007354A3 /* CreateGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroup.swift; sourceTree = ""; }; 4B80972A2E29479800FF2F63 /* FriendsTimetableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsTimetableView.swift; sourceTree = ""; }; 4B80972C2E2ABD8100FF2F63 /* CircleTimetable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTimetable.swift; sourceTree = ""; }; + 4B8097542E2B7B5F00FF2F63 /* UpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManager.swift; sourceTree = ""; }; 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VittyWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4B8B32C92D6D75F4004F01BA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 4B8B33742D7029A3004F01BA /* SideBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideBar.swift; sourceTree = ""; }; @@ -471,6 +473,7 @@ 314A40A127383C0A0058082F /* Utilities */ = { isa = PBXGroup; children = ( + 4B8097542E2B7B5F00FF2F63 /* UpdateManager.swift */, 31128D0A2773003C0084C9EA /* Constants */, 31128D052772FA030084C9EA /* Extensions */, 31128CF12772F55D0084C9EA /* Fonts */, @@ -1211,6 +1214,7 @@ 4BF03C992D7819E30098C803 /* Notes.swift in Sources */, 52DBBE882B47B6B30014C57A /* FriendCard.swift in Sources */, 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */, + 4B8097552E2B7B6300FF2F63 /* UpdateManager.swift in Sources */, 317715DE279F1431009A532E /* IndexedCollection.swift in Sources */, 4B7DA5E72D71AC54007354A3 /* CirclesRow.swift in Sources */, 4B7DA5E52D70B2CA007354A3 /* Circles.swift in Sources */, diff --git a/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift b/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift index 45fba68..908abf5 100644 --- a/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift +++ b/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift @@ -38,7 +38,7 @@ struct FriendReqCard: View { Task { let url = URL( string: - "\(APIConstants.base_url)/api/v2/requests/\(friend.username)/accept/" + "\(APIConstants.base_url)requests/\(friend.username)/accept/" )! var request = URLRequest(url: url) @@ -50,7 +50,7 @@ struct FriendReqCard: View { do { let (_, _) = try await URLSession.shared.data(for: request) friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)/api/v2/requests/")!, + from: URL(string: "\(APIConstants.base_url)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) @@ -68,7 +68,7 @@ struct FriendReqCard: View { Task { let url = URL( string: - "\(APIConstants.base_url)/api/v2/requests/\(friend.username)/decline/" + "\(APIConstants.base_url)requests/\(friend.username)/decline/" )! var request = URLRequest(url: url) @@ -80,7 +80,7 @@ struct FriendReqCard: View { do { let (_, _) = try await URLSession.shared.data(for: request) friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)/api/v2/requests/")!, + from: URL(string: "\(APIConstants.base_url)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) diff --git a/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift b/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift index d7dc865..1b33578 100644 --- a/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift +++ b/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift @@ -32,7 +32,7 @@ struct FriendRequestView: View { .scrollContentBackground(.hidden) .refreshable { friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)/api/v2/requests/")!, + from: URL(string: "\(APIConstants.base_url)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) diff --git a/VITTY/VITTY/Connect/Search/Views/SearchView.swift b/VITTY/VITTY/Connect/Search/Views/SearchView.swift index 0e3279b..a3fdf40 100644 --- a/VITTY/VITTY/Connect/Search/Views/SearchView.swift +++ b/VITTY/VITTY/Connect/Search/Views/SearchView.swift @@ -82,48 +82,54 @@ struct SearchView: View { Spacer() } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if !hasSearched { - VStack(spacing: 20) { + + HStack { 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) - + VStack(spacing: 20) { + 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() } + .frame(maxHeight: .infinity) + } else if searchedFriends.isEmpty && !searchText.isEmpty { - VStack(spacing: 20) { + + HStack { 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) - + VStack(spacing: 20) { + 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() } + .frame(maxHeight: .infinity) + } else { List($searchedFriends, id: \.username) { searchfriend in AddFriendCardSearch(friend: searchfriend , search: searchText) @@ -222,7 +228,7 @@ struct SearchView: View { cancelSearch() - // Reset all states + searchText = "" searchedFriends = [] hasSearched = false @@ -334,7 +340,7 @@ struct SearchView: View { } } - // Update the cancelSearch function to work with Alamofire + func cancelSearch() { currentSearchTask?.cancel() currentSearchTask = nil diff --git a/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift b/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift index 566e022..6bcccce 100644 --- a/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift +++ b/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift @@ -34,7 +34,7 @@ struct SuggestedFriendsView: View { .scrollContentBackground(.hidden) .refreshable { suggestedFriendsViewModel.fetchData( - from: "\(APIConstants.base_url)/api/v2/users/suggested/", + from: "\(APIConstants.base_url)users/suggested/", token: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift index 61567ce..c597a15 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -36,7 +36,7 @@ struct CreateGroup: View { .frame(width: 80, height: 5) .padding(.top, 10) - Text("Create Group") + Text("Create Circle") .font(.system(size: 23, weight: .semibold)) .foregroundColor(.white) @@ -72,11 +72,11 @@ struct CreateGroup: View { VStack(alignment: .leading, spacing: 10) { - Text("Enter group name") + Text("Enter circle name") .font(.system(size: 18, weight: .bold)) .foregroundColor(Color("Accent")) - TextField("Group Name", text: $groupName) + TextField("Circle Name", text: $groupName) .padding() .background(Color.black.opacity(0.3)) .cornerRadius(8) @@ -93,7 +93,7 @@ struct CreateGroup: View { } - if groupName.count > 20 { + if groupName.count > 50 { groupName = String(groupName.prefix(20)) } } @@ -101,7 +101,7 @@ struct CreateGroup: View { .textInputAutocapitalization(.never) - Text("No spaces allowed • Max 20 characters") + Text("No spaces allowed • Max 50 characters") .font(.system(size: 12)) .foregroundColor(.gray) .padding(.leading, 5) diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift index 09bbc11..692ac08 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift @@ -103,7 +103,7 @@ struct CircleRequestsView: View { Spacer() - Text("Group Requests") + Text("Circle Requests") .font(.custom("Poppins-SemiBold", size: 20)) .foregroundColor(.white) diff --git a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift index 86231d6..77cf93d 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift @@ -7,14 +7,12 @@ import SwiftUI - struct CirclesView: View { @Binding var isCreatingGroup: Bool @State private var searchText = "" @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(AuthViewModel.self) private var authViewModel - @EnvironmentObject private var navigationCoordinator: NavigationCoordinator var body: some View { @@ -56,7 +54,6 @@ struct CirclesView: View { ScrollView { VStack(spacing: 10) { ForEach(filteredCircles, id: \.circleID) { circle in - NavigationLink(destination: InsideCircle(circleName: circle.circleName, circle_id: circle.circleID, circle_join_code: circle.circleJoinCode, circle_role: circle.circleRole)) { CirclesRow(circle: circle) } @@ -70,30 +67,34 @@ struct CirclesView: View { } } .refreshable { - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) + fetchCircleData() } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("CircleJoinedSuccessfully"))) { _ in - - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) + fetchCircleData() } - .onAppear { + + fetchCircleData() + if let pendingInvite = navigationCoordinator.pendingCircleInvite { - print("CirclesView appeared with pending invite: \(pendingInvite.code)") - - } } } } + + // MARK: - Helper Methods + private func fetchCircleData() { + guard let token = authViewModel.loggedInBackendUser?.token else { + print("No authentication token available") + return + } + + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_urlv3)circles", + token: token, + loading: true + ) + } } diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index 82f5b2c..2aa8f51 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -506,7 +506,7 @@ struct InsideCircle: View { }) .onAppear { communityPageViewModel.fetchCircleMemberData( - from: "\(APIConstants.base_url)circles/\(circle_id)", + from: "\(APIConstants.base_urlv3)circles/\(circle_id)", token: authViewModel.loggedInBackendUser?.token ?? "", loading: true ) @@ -517,13 +517,13 @@ struct InsideCircle: View { LeaveCircleAlert(circleName: "\(circleName)", onCancel: { showLeaveAlert = false }, onLeave: { - let url = "\(APIConstants.base_url)circles/leave/\(circle_id)" + let url = "\(APIConstants.base_urlv3)circles/leave/\(circle_id)" let token = authViewModel.loggedInBackendUser?.token ?? "" communityPageViewModel.leaveCircle(from: url, token: token) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - communityPageViewModel.fetchCircleData(from:"\(APIConstants.base_url)circles" , token: token) + communityPageViewModel.fetchCircleData(from:"\(APIConstants.base_urlv3)circles" , token: token) showLeaveAlert = false presentationMode.wrappedValue.dismiss() } @@ -534,7 +534,7 @@ struct InsideCircle: View { DeleteCircleAlert(circleName: "\(circleName)", onCancel: { showDeleteAlert = false }, onDelete: { - let url = "\(APIConstants.base_url)circles/\(circle_id)" + let url = "\(APIConstants.base_urlv3)circles/\(circle_id)" let token = authViewModel.loggedInBackendUser?.token ?? "" communityPageViewModel.deleteCircle(from: url, token: token) diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 4e23323..ee29412 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -233,7 +233,7 @@ struct ConnectCircleMenuView: View { Image("joingroup") .resizable() .frame(width: 24, height: 24) - Text("Join Group") + Text("Join Circle") .font(.custom("Poppins-Regular", size: 16)) .foregroundColor(.white) Spacer() @@ -252,7 +252,7 @@ struct ConnectCircleMenuView: View { HStack { Image(systemName: "person.badge.plus") .foregroundColor(.white) - Text("Group Requests") + Text("Circle Requests") .font(.custom("Poppins-Regular", size: 16)) .foregroundColor(.white) Spacer() @@ -301,7 +301,7 @@ struct AddCircleOptionsView: View { Image("joingroup") .resizable() .frame(width: 55, height: 55) - Text("Join Group") + Text("Join Circle") .font(.system(size: 15)) .foregroundStyle(Color.white) } diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 7970104..83498d9 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -114,7 +114,7 @@ class CommunityPageViewModel { for circle in circles { dispatchGroup.enter() - let url = "\(APIConstants.base_url)circles/\(circle.circleID)" + let url = "\(APIConstants.base_urlv3)circles/\(circle.circleID)" AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() @@ -149,7 +149,7 @@ class CommunityPageViewModel { self.errorMemberTimetable = false - let url = "\(APIConstants.base_url)circles/\(circleId)/\(username)" + let url = "\(APIConstants.base_urlv3)circles/\(circleId)/\(username)" print("Fetching member timetable from: \(url)") @@ -191,7 +191,7 @@ class CommunityPageViewModel { self.errorCircleRequests = false - let url = "\(APIConstants.base_url)circles/requests/received" + let url = "\(APIConstants.base_urlv3)circles/requests/received" AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() @@ -230,7 +230,7 @@ class CommunityPageViewModel { func acceptCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { self.loadingRequestAction = true - let url = "\(APIConstants.base_url)circles/acceptRequest/\(circleId)" + let url = "\(APIConstants.base_urlv3)circles/acceptRequest/\(circleId)" logger.info("Attempting to accept circle request with URL: \(url)") @@ -247,7 +247,7 @@ class CommunityPageViewModel { 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)") } @@ -257,7 +257,7 @@ class CommunityPageViewModel { self.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, loading: false ) @@ -285,7 +285,7 @@ class CommunityPageViewModel { func declineCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { self.loadingRequestAction = true - let url = "\(APIConstants.base_url)circles/declineRequest/\(circleId)" + let url = "\(APIConstants.base_urlv3)circles/declineRequest/\(circleId)" AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() @@ -420,7 +420,7 @@ class CommunityPageViewModel { self.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, loading: false ) @@ -464,7 +464,7 @@ class CommunityPageViewModel { return } - let url = "\(APIConstants.base_url)circles/create/\(encodedName)" + let url = "\(APIConstants.base_urlv3)circles/create/\(encodedName)" AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() @@ -477,7 +477,7 @@ class CommunityPageViewModel { // Now fetch the updated circles data and wait for completion self.fetchCircleDataWithCompletion( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, circleName: name, completion: completion @@ -510,7 +510,7 @@ class CommunityPageViewModel { self.errorCircle = false print("Successfully fetched circles after creation: \(data.data)") - // Fetch member data for all circles after successfully fetching circles + self.fetchAllCircleMemberData(token: token) if let createdCircle = self.circles.first(where: { $0.circleName == circleName }) { @@ -530,9 +530,11 @@ class CommunityPageViewModel { } } + // MARK: - Updated Circle Invitations with New Endpoint + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { - let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" + let url = "\(APIConstants.base_urlv3)circles/sendRequest/\(circleId)/\(username)" print("this is the endpoint \(url)") AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) @@ -552,35 +554,102 @@ class CommunityPageViewModel { } } + + + struct SendInvitationsRequest: Codable { + let usernames: [String] + } + + struct SendInvitationsResponse: Codable { + let data: [InvitationResult]? + let detail: String? + let message: String? + } + struct InvitationResult: Codable { + let request_status: String + let username: String + } + 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() - } + guard !usernames.isEmpty else { + completion([:]) + return } - dispatchGroup.notify(queue: .main) { - completion(results) + let url = "\(APIConstants.base_urlv3)circles/sendRequest/\(circleId)" + let requestBody = SendInvitationsRequest(usernames: usernames) + + print("Sending multiple invitations to endpoint: \(url)") + print("Usernames: \(usernames)") + + AF.request( + url, + method: .post, + parameters: requestBody, + encoder: JSONParameterEncoder.default, + headers: ["Authorization": "Token \(token)", "Content-Type": "application/json"] + ) + .validate() + .responseData { response in + DispatchQueue.main.async { + var results: [String: Bool] = [:] + + + for username in usernames { + results[username] = false + } + + switch response.result { + case .success(let data): + self.logger.info("Multiple invitations response received") + + + do { + let decodedResponse = try JSONDecoder().decode(SendInvitationsResponse.self, from: data) + + if let invitationResults = decodedResponse.data { + for result in invitationResults { + + results[result.username] = (result.request_status == "added") + self.logger.info("User \(result.username): \(result.request_status)") + } + } + + } catch { + self.logger.error("Error decoding multiple invitations response: \(error)") + + + if let responseString = String(data: data, encoding: .utf8) { + self.logger.info("Raw response: \(responseString)") + } + + + + return + } + + completion(results) + + case .failure(let error): + self.logger.error("Error sending multiple invitations: \(error)") + + + } + } } } - + // MARK: - Refresh Methods func refreshAllData(token: String, username: String) { fetchFriendsData( - from: "\(APIConstants.base_url)friends/\(username)/", + from: "\(APIConstants.base_urlv3)friends/\(username)/", token: token, loading: false ) fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, loading: false ) @@ -601,7 +670,7 @@ class CommunityPageViewModel { } func generateJoinCode(circleId: String, token: String, completion: @escaping (Result) -> Void) { - let url = "\(APIConstants.base_url)circles/\(circleId)/generateJoinCode" + let url = "\(APIConstants.base_urlv3)circles/\(circleId)/generateJoinCode" print("Generating join code for circle: \(circleId)") print("Request URL: \(url)") @@ -631,5 +700,6 @@ class CommunityPageViewModel { } } } + } } -} + diff --git a/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift b/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift index 203041c..a3de98f 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)users/emptyClassRooms?slot=\(slot)")! + let url = URL(string: "\(APIConstants.base_urlv3)users/emptyClassRooms?slot=\(slot)")! var request = URLRequest(url: url) request.httpMethod = "GET" print(authToken) diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 1ee84bd..44b2b01 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -21,7 +21,7 @@ class CampusUpdateService { private init() {} func updateCampus(campus: String, token: String) async throws { - guard let url = URL(string: "\(APIConstants.base_url)users/campus") else { + guard let url = URL(string: "\(APIConstants.base_urlv3)users/campus") else { throw URLError(.badURL) } diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 886c3b7..057dc4f 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -151,7 +151,7 @@ struct UserProfileSidebar: View { let endpoint = enabled ? "ghost" : "alive" - let urlString = "\(APIConstants.base_url)friends/\(endpoint)/\(username)" + let urlString = "\(APIConstants.base_urlv3)friends/\(endpoint)/\(username)" guard let url = URL(string: urlString) else { isUpdatingGhostMode = false diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 6fd55c8..74bf1c7 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -10,7 +10,15 @@ import Foundation struct APIConstants { + + + static let base_url = "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + + static let base_urlv3 = "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v3/" + + + static let createCircle = "circles/create/" static let sendRequest = "circles/sendRequest/" static let acceptRequest = "circles/acceptRequest/" diff --git a/VITTY/VITTY/Utilities/UpdateManager.swift b/VITTY/VITTY/Utilities/UpdateManager.swift new file mode 100644 index 0000000..3e0e091 --- /dev/null +++ b/VITTY/VITTY/Utilities/UpdateManager.swift @@ -0,0 +1,264 @@ +// +// UpdateManager.swift +// VITTY +// +// In-App Update Check Manager +// + +import Foundation +import StoreKit +import SwiftUI + +// MARK: - Update Manager +class UpdateManager: ObservableObject { + static let shared = UpdateManager() + + @Published var isUpdateAvailable = false + @Published var updateInfo: AppUpdateInfo? + @Published var showUpdateAlert = false + + private let appStoreURL = "https://apps.apple.com/app/id1611750267" + + private let lastUpdateCheckKey = "LastUpdateCheckDate" + private let skipVersionKey = "SkippedVersion" + + // Configuration + private let checkInterval: TimeInterval = 24 * 60 * 60 + private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + + struct AppUpdateInfo { + let latestVersion: String + let releaseNotes: String + let isForced: Bool + let downloadURL: String + } + + private init() {} + + // MARK: - Public Methods + + func checkForUpdates(forced: Bool = false) { + + if !forced && !shouldCheckForUpdates() { + return + } + + guard let appID = getAppID() else { + print("App ID not found") + return + } + + fetchAppStoreVersion(appID: appID) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let updateInfo): + self?.handleUpdateInfo(updateInfo) + case .failure(let error): + print("Update check failed: \(error)") + } + } + } + + // Update last check date + UserDefaults.standard.set(Date(), forKey: lastUpdateCheckKey) + } + + func presentUpdateAlert() { + showUpdateAlert = true + } + + func skipThisVersion() { + if let version = updateInfo?.latestVersion { + UserDefaults.standard.set(version, forKey: skipVersionKey) + } + dismissUpdateAlert() + } + + func dismissUpdateAlert() { + showUpdateAlert = false + } + + func openAppStore() { + guard let url = URL(string: appStoreURL) else { return } + UIApplication.shared.open(url) + } + + // MARK: - Private Methods + + private func shouldCheckForUpdates() -> Bool { + guard let lastCheck = UserDefaults.standard.object(forKey: lastUpdateCheckKey) as? Date else { + return true + } + + return Date().timeIntervalSince(lastCheck) >= checkInterval + } + + private func getAppID() -> String? { + + return "1611750267" + } + + private func fetchAppStoreVersion(appID: String, completion: @escaping (Result) -> Void) { + let urlString = "https://itunes.apple.com/lookup?id=\(appID)" + guard let url = URL(string: urlString) else { + completion(.failure(UpdateError.invalidURL)) + return + } + + URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data else { + completion(.failure(UpdateError.noData)) + return + } + + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + let results = json?["results"] as? [[String: Any]] + + guard let appInfo = results?.first else { + completion(.failure(UpdateError.noAppInfo)) + return + } + + let latestVersion = appInfo["version"] as? String ?? "1.0" + let releaseNotes = appInfo["releaseNotes"] as? String ?? "Bug fixes and improvements" + let downloadURL = appInfo["trackViewUrl"] as? String ?? self.appStoreURL + + let updateInfo = AppUpdateInfo( + latestVersion: latestVersion, + releaseNotes: releaseNotes, + isForced: self.isCriticalUpdate(latestVersion), + downloadURL: downloadURL + ) + + completion(.success(updateInfo)) + + } catch { + completion(.failure(error)) + } + }.resume() + } + + private func handleUpdateInfo(_ updateInfo: AppUpdateInfo) { + self.updateInfo = updateInfo + + // Check if update is available and not skipped + if isNewerVersion(updateInfo.latestVersion, than: currentVersion) { + let skippedVersion = UserDefaults.standard.string(forKey: skipVersionKey) + + // Show alert if it's a forced update or user hasn't skipped this version + if updateInfo.isForced || skippedVersion != updateInfo.latestVersion { + isUpdateAvailable = true + presentUpdateAlert() + } + } + } + + private func isNewerVersion(_ version1: String, than version2: String) -> Bool { + return version1.compare(version2, options: .numeric) == .orderedDescending + } + + private func isCriticalUpdate(_ version: String) -> Bool { + // Define logic for critical updates + // For example, major version changes or security updates + let currentMajor = currentVersion.components(separatedBy: ".").first ?? "1" + let latestMajor = version.components(separatedBy: ".").first ?? "1" + + return currentMajor != latestMajor + } +} + +// MARK: - Update Error +enum UpdateError: Error, LocalizedError { + case invalidURL + case noData + case noAppInfo + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid App Store URL" + case .noData: + return "No data received from App Store" + case .noAppInfo: + return "App information not found" + } + } +} + +// MARK: - Update Alert View +struct UpdateAlertView: View { + @Environment(\.dismiss) private var dismiss + let updateInfo: UpdateManager.AppUpdateInfo + let onUpdate: () -> Void + let onSkip: (() -> Void)? + let onLater: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Update Available") + .font(.title2) + .fontWeight(.bold) + + Text("Version \(updateInfo.latestVersion) is now available") + .font(.subheadline) + .foregroundColor(.secondary) + + if !updateInfo.releaseNotes.isEmpty { + ScrollView { + Text("What's New:") + .font(.headline) + .padding(.bottom, 5) + + Text(updateInfo.releaseNotes) + .font(.body) + .multilineTextAlignment(.leading) + } + .frame(maxHeight: 150) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + } + + VStack(spacing: 10) { + Button("Update Now") { + onUpdate() + dismiss() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + if !updateInfo.isForced { + HStack(spacing: 20) { + if let onSkip = onSkip { + Button("Skip This Version") { + onSkip() + dismiss() + } + .foregroundColor(.secondary) + } + + Button("Later") { + onLater() + dismiss() + } + .foregroundColor(.secondary) + } + .font(.subheadline) + } + } + } + .padding() + .frame(maxWidth: 350) + } +} + diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index d5ac2b3..84046c8 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -277,7 +277,7 @@ extension VITTYApp { return } - let urlString = "\(APIConstants.base_url)circles/join?code=\(invite.code)" + let urlString = "\(APIConstants.base_urlv3)circles/join?code=\(invite.code)" guard let url = URL(string: urlString) else { logger.error("Invalid URL: \(urlString)") showToast(message: "Error: Invalid URL", isError: true) From bc487ce0d275c6a2828cb1edd1de4f33c4d264de Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Mon, 21 Jul 2025 21:30:18 +0530 Subject: [PATCH 5/8] fix: timetable parser and support email --- VITTY/VITTY/Connect/View/ConnectPage.swift | 6 +- VITTY/VITTY/Home/View/HomeView.swift | 90 ++-- VITTY/VITTY/Settings/View/SettingsView.swift | 34 +- VITTY/VITTY/TimeTable/Models/TimeTable.swift | 228 ++++++++- .../TimeTable/Views/LectureDetailView.swift | 231 ++++++++- .../TimeTable/Views/LectureItemView.swift | 265 ++++++++-- .../VITTY/TimeTable/Views/TimeTableView.swift | 473 +++++++++++------- VITTY/VittyWidget/Views/LargeWidget.swift | 1 + 8 files changed, 1044 insertions(+), 284 deletions(-) diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index ee29412..e8418ff 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -149,7 +149,7 @@ struct ConnectPage: View { selectedTab = 0 } communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: authViewModel.loggedInBackendUser?.token ?? "", loading: true ) @@ -177,7 +177,7 @@ struct ConnectPage: View { if communityPageViewModel.circles.isEmpty || !hasLoadedInitialData { communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: authViewModel.loggedInBackendUser?.token ?? "", loading: shouldShowLoading ) @@ -185,7 +185,7 @@ struct ConnectPage: View { if communityPageViewModel.circleRequests.isEmpty || !hasLoadedInitialData { friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)requests/")!, + from: URL(string: "\(APIConstants.base_urlv3)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: shouldShowLoading ) diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 44b2b01..844ff25 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -48,7 +48,7 @@ class CampusUpdateService { } } -// MARK: - Campus Selection Dialog + import SwiftUI struct CampusSelectionDialog: View { @@ -59,7 +59,6 @@ struct CampusSelectionDialog: View { @State private var showError: Bool = false @State private var errorMessage: String = "" - private let campusOptions = [ ("VIT Chennai", "chennai"), ("VIT Vellore", "vellore"), @@ -68,22 +67,26 @@ struct CampusSelectionDialog: View { var body: some View { ZStack { - Color.black.opacity(0.4) + Color.black.opacity(0.5) .ignoresSafeArea() + .onTapGesture { + + } - VStack(spacing: 20) { - + VStack(spacing: 24) { + // Header Section VStack(spacing: 8) { Text("Select Your Campus") .font(.custom("Poppins-Bold", size: 20)) - .foregroundColor(.primary) + .foregroundColor(.white) Text("Please select your campus to continue") .font(.custom("Poppins-Regular", size: 14)) - .foregroundColor(.secondary) + .foregroundColor(.white.opacity(0.7)) + .multilineTextAlignment(.center) } - + VStack(spacing: 12) { ForEach(campusOptions, id: \.0) { campus in Button(action: { @@ -92,23 +95,31 @@ struct CampusSelectionDialog: View { HStack { Text(campus.0) .font(.custom("Poppins-Medium", size: 16)) - .foregroundColor(.primary) + .foregroundColor(.white) Spacer() if selectedCampus == campus.1 { Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) + .foregroundColor(Color("Accent")) + .font(.system(size: 20)) } } .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.vertical, 14) .background( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 12) .fill(selectedCampus == campus.1 ? - Color.blue.opacity(0.1) : Color.gray.opacity(0.1)) + Color("Accent").opacity(0.15) : Color("Secondary").opacity(0.6)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(selectedCampus == campus.1 ? + Color("Accent") : Color.clear, lineWidth: 1) ) } + .buttonStyle(PlainButtonStyle()) + .disabled(isUpdating) } } @@ -118,39 +129,61 @@ struct CampusSelectionDialog: View { .font(.custom("Poppins-Regular", size: 12)) .foregroundColor(.red) .padding(.horizontal) + .multilineTextAlignment(.center) } - - HStack(spacing: 16) { - Button("Update") { + + if isUpdating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + } + + + HStack(spacing: 12) { + + Button("Skip for now") { + isPresented = false + } + .disabled(isUpdating) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color("Secondary").opacity(0.8)) + ) + .foregroundColor(.white.opacity(0.8)) + .font(.custom("Poppins-Medium", size: 14)) + + + Button("Update Campus") { Task { await updateCampus() } } .disabled(selectedCampus.isEmpty || isUpdating) - .padding(.horizontal, 32) + .padding(.horizontal, 20) .padding(.vertical, 12) .background( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 10) .fill(selectedCampus.isEmpty || isUpdating ? - Color.gray.opacity(0.3) : Color.blue) + Color.gray.opacity(0.3) : Color("Accent")) ) .foregroundColor(.white) - .font(.custom("Poppins-Medium", size: 16)) - } - - if isUpdating { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + .font(.custom("Poppins-Medium", size: 14)) } + .padding(.top, 8) } .padding(24) .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(UIColor.systemBackground)) + RoundedRectangle(cornerRadius: 20) + .fill(Color("Background")) + .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 10) ) - .padding(.horizontal, 40) + .padding(.horizontal, 32) } + .animation(.easeInOut(duration: 0.3), value: isUpdating) + .animation(.easeInOut(duration: 0.3), value: showError) } private func updateCampus() async { @@ -168,7 +201,6 @@ struct CampusSelectionDialog: View { token: token ) - DispatchQueue.main.async { authViewModel.updateUserCampus(selectedCampus) isPresented = false diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 1e122d3..1409ee0 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -8,7 +8,7 @@ struct SettingsView: View { @Query private var timeTables: [TimeTable] @StateObject private var viewModel = SettingsViewModel() - @StateObject private var settingsTipManager = SettingsTipManager() + @StateObject private var settingsTipManager = SettingsTipManager() @State private var showDaySelection = false @State private var selectedDay: String? = nil @@ -160,8 +160,35 @@ struct SettingsView: View { } 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/")) + VStack(alignment: .leading, spacing: 12) { + 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/")) + + // Support Email + HStack(spacing: 12) { + Image(systemName: "envelope.fill") + .foregroundColor(.white) + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 4) { + Text("Support") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + Text("dscvit.vitty@gmail.com") + .font(.system(size: 12)) + .foregroundColor(.gray.opacity(0.8)) + } + + Spacer() + } + .padding(.vertical, 6) + .onTapGesture { + if let url = URL(string: "mailto:dscvit.vitty@gmail.com") { + UIApplication.shared.open(url) + } + } + } } } .scrollContentBackground(.hidden) @@ -842,6 +869,7 @@ struct DeleteUserAlert: View { } } + struct SyncAlert: View { let message: String let isSuccess: Bool diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index a19c746..bcaf102 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -246,21 +246,227 @@ extension TimeTable { } private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + + if let formattedTime = parseWithISO8601(time: time) { + return formattedTime + } else if let formattedTime = parseWithCustomFormat(time: time) { + return formattedTime + } else { + + return parseTimeOnlyFallback(time: time) + } + } + + private func parseWithISO8601(time: String) -> String? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseWithCustomFormat(time: String) -> String? { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + + if let date = dateFormatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseTimeOnlyFallback(time: String) -> String { + + var timeComponents = time.components(separatedBy: "T").last ?? time + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + + let timePattern = "\\d{2}:\\d{2}" + if let range = timeComponents.range(of: timePattern, options: .regularExpression) { + let timeOnly = String(timeComponents[range]) + dateFormatter.dateFormat = "HH:mm" + + if let date = dateFormatter.date(from: timeOnly) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + } + + return "Invalid Time" + } + - 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) + + private func parseTime(_ timeString: String) -> Int? { + + if let minutes = parseTimeWithISO8601(timeString) { + return minutes + } + + + return parseTimeCustom(timeString) + } + + private func parseTimeWithISO8601(_ timeString: String) -> Int? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: timeString) { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: date) + if let hour = components.hour, let minute = components.minute { + return hour * 60 + minute } - else { - return ("Failed to parse the time string.") + } + + return nil + } + + private func parseTimeCustom(_ timeString: String) -> Int? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] } } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + 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? { + + if let date = parseTimeToDateISO8601(timeString) { + return date + } + + + return parseTimeToDateCustom(timeString) + } + + private func parseTimeToDateISO8601(_ timeString: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let originalDate = formatter.date(from: timeString) { + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: originalDate) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date()) + + 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 parseTimeToDateCustom(_ timeString: String) -> Date? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let time = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let now = Date() + + let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + let timeComps = calendar.dateComponents([.hour, .minute, .second], from: time) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComps.hour + combinedComponents.minute = timeComps.minute + combinedComponents.second = timeComps.second + + return calendar.date(from: combinedComponents) + } + + return nil + } func isDifferentFrom(_ other: TimeTable) -> Bool { return monday != other.monday || diff --git a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift index 989713c..2883195 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift @@ -88,21 +88,228 @@ struct LectureDetailView: View { return CLLocationCoordinate2D(latitude: 12.96972, longitude: 79.15658) } } - + private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + + if let formattedTime = parseWithISO8601(time: time) { + return formattedTime + } else if let formattedTime = parseWithCustomFormat(time: time) { + return formattedTime + } else { + + return parseTimeOnlyFallback(time: time) + } + } + + private func parseWithISO8601(time: String) -> String? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseWithCustomFormat(time: String) -> String? { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + + if let date = dateFormatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseTimeOnlyFallback(time: String) -> String { + + var timeComponents = time.components(separatedBy: "T").last ?? time + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + + let timePattern = "\\d{2}:\\d{2}" + if let range = timeComponents.range(of: timePattern, options: .regularExpression) { + let timeOnly = String(timeComponents[range]) + dateFormatter.dateFormat = "HH:mm" + + if let date = dateFormatter.date(from: timeOnly) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + } + + return "Invalid Time" + } + - 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) + + private func parseTime(_ timeString: String) -> Int? { + + if let minutes = parseTimeWithISO8601(timeString) { + return minutes + } + + + return parseTimeCustom(timeString) + } + + private func parseTimeWithISO8601(_ timeString: String) -> Int? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: timeString) { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: date) + if let hour = components.hour, let minute = components.minute { + return hour * 60 + minute } - else { - return ("Failed to parse the time string.") + } + + return nil + } + + private func parseTimeCustom(_ timeString: String) -> Int? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + 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? { + + if let date = parseTimeToDateISO8601(timeString) { + return date + } + + + return parseTimeToDateCustom(timeString) + } + + private func parseTimeToDateISO8601(_ timeString: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let originalDate = formatter.date(from: timeString) { + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: originalDate) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date()) + + 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 parseTimeToDateCustom(_ timeString: String) -> Date? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] } } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let time = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let now = Date() + + let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + let timeComps = calendar.dateComponents([.hour, .minute, .second], from: time) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComps.hour + combinedComponents.minute = timeComps.minute + combinedComponents.second = timeComps.second + + return calendar.date(from: combinedComponents) + } + + return nil + } + } diff --git a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift index 4673ac5..0b540a9 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift @@ -199,67 +199,232 @@ struct LectureItemView: View { // 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 ?? "" + + + + + + + private func formatTime(time: String) -> String { + + if let formattedTime = parseWithISO8601(time: time) { + return formattedTime + } else if let formattedTime = parseWithCustomFormat(time: time) { + return formattedTime + } else { + + return parseTimeOnlyFallback(time: time) + } + } - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" + private func parseWithISO8601(time: String) -> String? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } - 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 + private func parseWithCustomFormat(time: String) -> String? { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + + if let date = dateFormatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil } - 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 ?? "" + private func parseTimeOnlyFallback(time: String) -> String { + + var timeComponents = time.components(separatedBy: "T").last ?? time + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + + let timePattern = "\\d{2}:\\d{2}" + if let range = timeComponents.range(of: timePattern, options: .regularExpression) { + let timeOnly = String(timeComponents[range]) + dateFormatter.dateFormat = "HH:mm" + + if let date = dateFormatter.date(from: timeOnly) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + } + + return "Invalid Time" + } - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" + - if let time = dateFormatter.date(from: timeComponents) { - let calendar = Calendar.current - let now = Date() + private func parseTime(_ timeString: String) -> Int? { + + if let minutes = parseTimeWithISO8601(timeString) { + return minutes + } - // 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) + + return parseTimeCustom(timeString) + } + + private func parseTimeWithISO8601(_ timeString: String) -> Int? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] - 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 + if let date = formatter.date(from: timeString) { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: date) + if let hour = components.hour, let minute = components.minute { + return hour * 60 + minute + } + } - return calendar.date(from: combinedComponents) + return nil + } + + private func parseTimeCustom(_ timeString: String) -> Int? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + 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 } - return nil - } - - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return formattedTime - } else { - return "Failed to parse the time string." + + private func parseTimeToDate(_ timeString: String) -> Date? { + + if let date = parseTimeToDateISO8601(timeString) { + return date + } + + + return parseTimeToDateCustom(timeString) + } + + private func parseTimeToDateISO8601(_ timeString: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let originalDate = formatter.date(from: timeString) { + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: originalDate) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date()) + + 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 parseTimeToDateCustom(_ timeString: String) -> Date? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let time = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let now = Date() + + let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + let timeComps = calendar.dateComponents([.hour, .minute, .second], from: time) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComps.hour + combinedComponents.minute = timeComps.minute + combinedComponents.second = timeComps.second + + return calendar.date(from: combinedComponents) + } + + return nil } - } } diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index c091469..0782046 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -13,13 +13,12 @@ struct TimeTableView: View { @State private var selectedLecture: Lecture? = nil @State private var isRefreshing = false @State private var showingRefreshAlert = false + @State private var scrollPosition: Int? = 0 @Query private var timetableItem: [TimeTable] @Environment(\.dismiss) private var dismiss let friend: Friend? - - private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String( @@ -31,160 +30,9 @@ struct TimeTableView: View { NavigationStack { ZStack { BackgroundView() + VStack { - - - switch viewModel.stage { - case .loading: - VStack { - Spacer() - ProgressView() - .scaleEffect(1.2) - Text("Loading timetable...") - .font(.caption) - .foregroundColor(.secondary) - .padding(.top, 8) - Spacer() - } - case .error: - VStack { - Spacer() - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 50)) - .foregroundColor(.orange) - .padding(.bottom, 16) - - Text("Something went wrong!") - .font(Font.custom("Poppins-Bold", size: 24)) - .padding(.bottom, 8) - - Text("Sorry if you are late for your class!") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.bottom, 20) - - Button(action: { - showingRefreshAlert = true - }) { - HStack { - Image(systemName: "arrow.clockwise") - Text("Refresh Timetable") - } - .foregroundColor(.white) - .padding() - .background(Color("Accent")) - .cornerRadius(10) - } - .disabled(isRefreshing) - - Spacer() - } - case .empty: - // Show empty timetable view with reload functionality - EmptyTimetableView( - onReload: { - Task { - await refreshTimetable() - } - }, - isRefreshing: isRefreshing - ) - case .data: - if viewModel.isEmpty{ - EmptyTimetableView( - onReload: { - Task { - await refreshTimetable() - } - }, - isRefreshing: isRefreshing - ) - } else{ - VStack(spacing: 0) { - - ScrollViewReader { proxy in - ScrollView(.horizontal) { - HStack { - ForEach(daysOfWeek, id: \.self) { day in - Text(day) - .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) - .frame(width: 60, height: 54) - .background( - daysOfWeek[viewModel.dayNo] == day - ? Color("Accent") : Color.clear - ) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.dayNo = daysOfWeek.firstIndex( - of: day - )! - viewModel.changeDay() - - proxy.scrollTo(day, anchor: .center) - } - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - .id(day) - } - } - .padding(.horizontal, 8) - } - .scrollIndicators(.hidden) - .onAppear { - let currentDay = daysOfWeek[viewModel.dayNo] - proxy.scrollTo(currentDay, anchor: .center) - } - .onChange(of: viewModel.dayNo) { oldValue, newValue in - let selectedDay = daysOfWeek[newValue] - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(selectedDay, anchor: .center) - } - } - } - .background(Color("Secondary")) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - - if viewModel.lectures.isEmpty { - Spacer() - VStack(spacing: 16) { - Image(systemName: "calendar.badge.exclamationmark") - .font(.system(size: 50)) - .foregroundColor(.secondary) - - Text("No classes today!") - .font(Font.custom("Poppins-Bold", size: 24)) - - Text(StringConstants.noClassQuotesOffline.randomElement() ?? "Enjoy your free time!") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - Spacer() - - } else { - ScrollView { - VStack(spacing: 12) { - ForEach(viewModel.lectures.sorted()) { lecture in - LectureItemView( - lecture: lecture, - selectedDayIndex: viewModel.dayNo, - allLectures: viewModel.lectures - ) { - selectedLecture = lecture - } - } - } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 100) - } - } - } - } - } + contentView } } } @@ -192,12 +40,7 @@ struct TimeTableView: View { LectureDetailView(lecture: lecture) } .alert("Refresh Timetable", isPresented: $showingRefreshAlert) { - Button("Cancel", role: .cancel) { } - Button("Refresh", role: .destructive) { - Task { - await refreshTimetable() - } - } + alertButtons } message: { Text("This will clear your local timetable and fetch fresh data from the server. Continue?") } @@ -207,27 +50,303 @@ struct TimeTableView: View { loadTimetable() } .onChange(of: timetableItem) { oldValue, newValue in - logger.debug("Timetable data changed, reloading view.") + handleTimetableChange(oldValue: oldValue, newValue: newValue) + } + .onChange(of: scenePhase) { _, newPhase in + handleScenePhaseChange(newPhase) + } + } + + @ViewBuilder + private var contentView: some View { + switch viewModel.stage { + case .loading: + loadingView + case .error: + errorView + case .empty: + emptyView + case .data: + dataView + } + } + + @ViewBuilder + private var loadingView: some View { + VStack { + Spacer() + ProgressView() + .scaleEffect(1.2) + Text("Loading timetable...") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + Spacer() + } + } + + @ViewBuilder + private var errorView: some View { + VStack { + Spacer() - // Simplified change detection - if oldValue.count != newValue.count { - // Data was added or removed - loadTimetable() - } else if let newTable = newValue.first { - // Data content changed - viewModel.refreshFromDatabase(newTable) + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundColor(.orange) + .padding(.bottom, 16) + + Text("Something went wrong!") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("Sorry if you are late for your class!") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.bottom, 20) + + refreshButton + + Spacer() + } + } + + @ViewBuilder + private var refreshButton: some View { + Button(action: { + showingRefreshAlert = true + }) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Refresh Timetable") } + .foregroundColor(.white) + .padding() + .background(Color("Accent")) + .cornerRadius(10) } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - viewModel.resetSyncStatus() - - // Reload if in error state or empty state - if viewModel.stage == .error || viewModel.stage == .empty { - loadTimetable() + .disabled(isRefreshing) + } + + @ViewBuilder + private var emptyView: some View { + EmptyTimetableView( + onReload: { + Task { + await refreshTimetable() + } + }, + isRefreshing: isRefreshing + ) + } + + @ViewBuilder + private var dataView: some View { + if viewModel.isEmpty { + emptyView + } else { + timetableContentView + } + } + + @ViewBuilder + private var timetableContentView: some View { + VStack(spacing: 0) { + daysSelectorView + lecturesContentView + } + } + + @ViewBuilder + private var daysSelectorView: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in + dayTabView(day: day, index: index) + } + } + } + .scrollTargetBehavior(.paging) + .scrollIndicators(.hidden) + .scrollPosition(id: $scrollPosition) + .onChange(of: scrollPosition) { oldValue, newValue in + handleScrollPositionChange(newValue: newValue) + } + .onAppear { + if let currentPosition = scrollPosition { + proxy.scrollTo(currentPosition, anchor: .center) } } } + .frame(height: 54) + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + + @ViewBuilder + private func dayTabView(day: String, index: Int) -> some View { + let isSelected = viewModel.dayNo == index + + GeometryReader { geometry in + Text(day) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(isSelected ? Color("Background") : Color("Accent")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(isSelected ? Color("Accent") : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .onTapGesture { + handleDayTap(index: index) + } + } + + .frame(width: UIScreen.main.bounds.width / 6, height: 54) + .id(index) + } + + @ViewBuilder + private var lecturesContentView: some View { + if viewModel.lectures.isEmpty { + noClassesView + } else { + lecturesListView + } + } + + @ViewBuilder + private var noClassesView: some View { + Spacer() + + VStack(spacing: 16) { + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + + Text("No classes today!") + .font(Font.custom("Poppins-Bold", size: 24)) + + Text(StringConstants.noClassQuotesOffline.randomElement() ?? "Enjoy your free time!") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + Spacer() + } + + @ViewBuilder + private var lecturesListView: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 12) { + ForEach(viewModel.lectures.sorted()) { lecture in + LectureItemView( + lecture: lecture, + selectedDayIndex: viewModel.dayNo, + allLectures: viewModel.lectures + ) { + selectedLecture = lecture + } + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) + } + + .gesture( + DragGesture() + .onEnded { value in + handleSwipeGesture(value) + } + ) + } + + @ViewBuilder + private var alertButtons: some View { + Button("Cancel", role: .cancel) { } + Button("Refresh", role: .destructive) { + Task { + await refreshTimetable() + } + } + } + + // MARK: - Helper Methods + + private func handleSwipeGesture(_ value: DragGesture.Value) { + let horizontalMovement = value.translation.width + let minimumSwipeDistance: CGFloat = 50 + + + if abs(horizontalMovement) > minimumSwipeDistance { + if horizontalMovement > 0 { + + switchToPreviousDay() + } else { + + switchToNextDay() + } + } + } + + private func switchToNextDay() { + let nextDay = min(viewModel.dayNo + 1, daysOfWeek.count - 1) + if nextDay != viewModel.dayNo { + withAnimation(.easeInOut(duration: 0.3)) { + scrollPosition = nextDay + viewModel.dayNo = nextDay + viewModel.changeDay() + } + } + } + + private func switchToPreviousDay() { + let previousDay = max(viewModel.dayNo - 1, 0) + if previousDay != viewModel.dayNo { + withAnimation(.easeInOut(duration: 0.3)) { + scrollPosition = previousDay + viewModel.dayNo = previousDay + viewModel.changeDay() + } + } + } + + private func handleDayTap(index: Int) { + withAnimation(.easeInOut(duration: 0.3)) { + scrollPosition = index + viewModel.dayNo = index + viewModel.changeDay() + } + } + + private func handleScrollPositionChange(newValue: Int?) { + guard let newValue = newValue, newValue != viewModel.dayNo else { return } + + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.dayNo = newValue + viewModel.changeDay() + } + } + + private func handleTimetableChange(oldValue: [TimeTable], newValue: [TimeTable]) { + logger.debug("Timetable data changed, reloading view.") + + if oldValue.count != newValue.count { + loadTimetable() + } else if let newTable = newValue.first { + viewModel.refreshFromDatabase(newTable) + } + } + + private func handleScenePhaseChange(_ newPhase: ScenePhase) { + if newPhase == .active { + viewModel.resetSyncStatus() + + if viewModel.stage == .error || viewModel.stage == .empty { + loadTimetable() + } + } } private func loadTimetable() { @@ -240,8 +359,10 @@ struct TimeTableView: View { if dayIndex >= 0 && dayIndex < daysOfWeek.count { viewModel.dayNo = dayIndex + scrollPosition = dayIndex } else { viewModel.dayNo = 0 + scrollPosition = 0 } Task { diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index a4ada53..c2686dc 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -4,6 +4,7 @@ // // Created by Rujin Devkota on 2/25/25. // + import SwiftUI import WidgetKit From a61aecc5b9bb599891c8b3707809e107859e3277 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sun, 27 Jul 2025 09:06:08 +0530 Subject: [PATCH 6/8] feat: timetable scene change bug fix --- VITTY/VITTY.xcodeproj/project.pbxproj | 2 + VITTY/VITTY/Academics/View/CourseRefs.swift | 4 +- .../VITTY/Academics/View/CreateReminder.swift | 86 +++++++++++++------ VITTY/VITTY/Academics/View/Notes.swift | 3 +- .../Connect/View/Freinds/View/Freinds.swift | 2 +- .../Connect/View/Freinds/View/FriendRow.swift | 1 + VITTY/VITTY/Settings/View/SettingsView.swift | 43 ++++++++-- .../ViewModel/TimeTableViewModel.swift | 8 +- VITTY/VITTYApp.swift | 62 +++++++++---- .../Providers/ScheduleProvider.swift | 57 +++++++----- VITTY/VittyWidget/Views/LargeWidget.swift | 77 ++--------------- 11 files changed, 199 insertions(+), 146 deletions(-) diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 0fb20d6..91fc5e8 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ 4B8B32DC2D6D75F6004F01BA /* VittyWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4B8B33752D7029AA004F01BA /* SideBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8B33742D7029A3004F01BA /* SideBar.swift */; }; 4BA03C1D2D7584F3000756B0 /* AddFriend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA03C1C2D7584EA000756B0 /* AddFriend.swift */; }; + 4BA6DFE92E33D5CE00B0411A /* NotesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */; }; 4BBB00312D955163003B8FE2 /* AcademicsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBB00302D95515C003B8FE2 /* AcademicsViewModel.swift */; }; 4BBB00332D957A6A003B8FE2 /* NotesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */; }; 4BC853C32DF693780092B2E2 /* SaveTimeTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC853C22DF6936E0092B2E2 /* SaveTimeTableView.swift */; }; @@ -1248,6 +1249,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4BA6DFE92E33D5CE00B0411A /* NotesModel.swift in Sources */, 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */, 4BC853C42DF6DA7A0092B2E2 /* TimeTable.swift in Sources */, 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */, diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index 615a5eb..926a3a6 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -268,10 +268,10 @@ struct OCourseRefs: View { // 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", diff --git a/VITTY/VITTY/Academics/View/CreateReminder.swift b/VITTY/VITTY/Academics/View/CreateReminder.swift index 069b142..d6e6d26 100644 --- a/VITTY/VITTY/Academics/View/CreateReminder.swift +++ b/VITTY/VITTY/Academics/View/CreateReminder.swift @@ -43,6 +43,28 @@ struct ReminderView: View { Spacer() Button("Add") { + // Combine selected date with start and end times + let calendar = Calendar.current + let dateComponents = calendar.dateComponents([.year, .month, .day], from: selectedDate) + let startTimeComponents = calendar.dateComponents([.hour, .minute], from: startTime) + let endTimeComponents = calendar.dateComponents([.hour, .minute], from: endTime) + + let finalStartTime = calendar.date(from: DateComponents( + year: dateComponents.year, + month: dateComponents.month, + day: dateComponents.day, + hour: startTimeComponents.hour, + minute: startTimeComponents.minute + )) ?? startTime + + let finalEndTime = calendar.date(from: DateComponents( + year: dateComponents.year, + month: dateComponents.month, + day: dateComponents.day, + hour: endTimeComponents.hour, + minute: endTimeComponents.minute + )) ?? endTime + let newReminder = Remainder( title: title, subject: courseName, @@ -51,8 +73,8 @@ struct ReminderView: View { date: selectedDate, isCompleted: false, subjectDescription: description, - startTime: startTime, - endTime: endTime + startTime: finalStartTime, + endTime: finalEndTime ) do { @@ -60,10 +82,9 @@ struct ReminderView: View { try modelContext.save() print("Saved successfully") - NotificationManager.shared.scheduleReminderNotifications( title: title, - date: startTime, + date: finalStartTime, subject: courseName ) @@ -84,6 +105,14 @@ struct ReminderView: View { .font(.system(size: 32, weight: .bold)) .foregroundColor(.white) .padding(.bottom) + .onTapGesture { + // Close pickers when tapping on title + withAnimation(.easeInOut(duration: 0.3)) { + showDatePicker = false + showStartTimePicker = false + showEndTimePicker = false + } + } TextField("Title", text: $title) .padding() @@ -114,16 +143,15 @@ struct ReminderView: View { Spacer() Text(selectedDate, style: .date) .foregroundColor(.gray) + .id(selectedDate) 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() @@ -131,27 +159,34 @@ struct ReminderView: View { } if showDatePicker { - DatePicker( - "Select Date", - selection: $selectedDate, - displayedComponents: [.date] - ) - .datePickerStyle(.graphical) - .colorScheme(.dark) - .labelsHidden() - .onChange(of: selectedDate) { + VStack(spacing: 12) { + DatePicker( + "Select Date", + selection: $selectedDate, + displayedComponents: [.date] + ) + .datePickerStyle(.graphical) + .colorScheme(.dark) + .labelsHidden() + .onChange(of: selectedDate) { oldValue, newValue in + print("Date changed from \(oldValue) to \(newValue)") + } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Button("Done") { withAnimation(.easeInOut(duration: 0.3)) { showDatePicker = false } } + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .trailing) } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) .transition(.opacity.combined(with: .scale)) } } - VStack(alignment: .leading, spacing: 8) { HStack { Text("Start Time") @@ -167,7 +202,6 @@ struct ReminderView: View { .cornerRadius(10) .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { - showDatePicker = false showEndTimePicker = false showStartTimePicker.toggle() @@ -195,11 +229,13 @@ struct ReminderView: View { .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .trailing) } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) .transition(.opacity.combined(with: .scale)) } } - VStack(alignment: .leading, spacing: 8) { HStack { Text("End Time") @@ -215,7 +251,6 @@ struct ReminderView: View { .cornerRadius(10) .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { - showDatePicker = false showStartTimePicker = false showEndTimePicker.toggle() @@ -243,6 +278,9 @@ struct ReminderView: View { .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .trailing) } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) .transition(.opacity.combined(with: .scale)) } } @@ -252,13 +290,5 @@ struct ReminderView: View { } } .preferredColorScheme(.dark) - .onTapGesture { - - withAnimation(.easeInOut(duration: 0.3)) { - showDatePicker = false - showStartTimePicker = false - showEndTimePicker = false - } - } } } diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index b0f204a..8d81c6e 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -162,7 +162,7 @@ struct NoteEditorView: View { } private func handleBackNavigation() { - // Check if there are unsaved changes or if it's a new note + if hasUnsavedChanges || (existingNote == nil && !isEmpty) { showTitleAlert = true } else { @@ -256,6 +256,7 @@ struct NoteEditorView: View { noteContent: dataString, createdAt: Date.now ) + print("saved with a course id of \(courseCode)") modelContext.insert(newNote) } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index 67db375..89d5479 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -99,7 +99,7 @@ struct FriendsView: View { ScrollView { VStack(spacing: 10) { ForEach(filteredFriends, id: \.username) { friend in - // Updated to use FriendsTimeTableView instead of TimeTableView + NavigationLink(destination: FriendsTimeTableView(friend: friend)) { FriendRow(friend: friend) } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift b/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift index c68620f..3ea90b7 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift @@ -28,6 +28,7 @@ struct FriendRow: View { } } Spacer() + } .padding().frame(maxWidth: .infinity) .background( diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 1409ee0..cf58049 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -486,7 +486,8 @@ struct SettingsView: View { let lecturesToCopy = currentTimeTable.lectures(forDay: day) print("Found \(lecturesToCopy.count) lectures to copy from \(day)") - + + // Create backup data let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, @@ -499,9 +500,11 @@ struct SettingsView: View { ) do { + // Delete existing timetable modelContext.delete(currentTimeTable) print("Deleted existing timetable") + // Create new Saturday lectures let newSaturdayLectures = lecturesToCopy.map { originalLecture in Lecture( name: originalLecture.name, @@ -516,6 +519,7 @@ struct SettingsView: View { print("Created \(newSaturdayLectures.count) new lectures for Saturday") + // Create new timetable let newTimeTable = TimeTable( monday: backupData.monday, tuesday: backupData.tuesday, @@ -527,27 +531,39 @@ struct SettingsView: View { saturdaySourceDay: day ) + // Insert new timetable modelContext.insert(newTimeTable) print("Inserted new timetable with Saturday lectures") + // Save changes try modelContext.save() + // Update local state IMMEDIATELY after successful save self.selectedDay = day print("Successfully recreated timetable with \(day) copied to Saturday") print("New Saturday has \(newTimeTable.saturday.count) lectures") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // Force immediate UI update + DispatchQueue.main.async { + // Send notification immediately NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil ) + + // Also send a refresh notification for the timetable view + NotificationCenter.default.post( + name: NSNotification.Name("RefreshTimetableFromSettings"), + object: nil + ) } } catch { print("Error during timetable recreation: \(error)") modelContext.rollback() + // Restore backup data on error print("Attempting to restore backup data...") do { let restoredTimeTable = TimeTable( @@ -570,6 +586,7 @@ struct SettingsView: View { } } + // MARK: - Fixed resetSaturdayClasses method in SettingsView private func resetSaturdayClasses() { guard let currentTimeTable = timeTables.first else { print("No timetable found") @@ -578,6 +595,7 @@ struct SettingsView: View { print("Starting SAFE reset of Saturday classes - DELETE & RECREATE approach") + // Create backup data let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, @@ -590,40 +608,54 @@ struct SettingsView: View { ) do { + // Delete existing timetable modelContext.delete(currentTimeTable) print("Deleted existing timetable") + // Create new timetable with empty Saturday let newTimeTable = TimeTable( monday: backupData.monday, tuesday: backupData.tuesday, wednesday: backupData.wednesday, thursday: backupData.thursday, friday: backupData.friday, - saturday: [], + saturday: [], // Empty Saturday sunday: backupData.sunday, - saturdaySourceDay: nil + saturdaySourceDay: nil // Clear source day ) + // Insert new timetable modelContext.insert(newTimeTable) print("Inserted new timetable with empty Saturday") + // Save changes try modelContext.save() + // Update local state IMMEDIATELY after successful save self.selectedDay = nil print("Successfully recreated timetable with empty Saturday") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // Force immediate UI update + DispatchQueue.main.async { + // Send notification immediately NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil ) + + // Also send a refresh notification for the timetable view + NotificationCenter.default.post( + name: NSNotification.Name("RefreshTimetableFromSettings"), + object: nil + ) } } catch { print("Error during timetable reset: \(error)") modelContext.rollback() + // Restore backup data on error print("Attempting to restore backup data...") do { let restoredTimeTable = TimeTable( @@ -646,6 +678,7 @@ struct SettingsView: View { } } + private var headerView: some View { HStack { Button(action: { diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index a41a0a7..75bc23b 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -431,7 +431,7 @@ extension TimeTableView { ) } - // Create new timetable with Saturday lectures + let newTimeTable = TimeTable( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, @@ -449,7 +449,7 @@ extension TimeTableView { context: context ) - // Update UI + self.timeTable = newTimeTable changeDay() @@ -458,7 +458,7 @@ extension TimeTableView { // MARK: - Utility Methods func resetSyncStatus() { - // Simple reset - just refresh current day + changeDay() } @@ -473,7 +473,7 @@ extension TimeTableView { authToken: String, context: ModelContext ) async { - // Redirect to forceSync for consistency + await forceSync( username: username, authToken: authToken, diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index 84046c8..eb3ad9d 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -25,18 +25,20 @@ struct VITTYApp: App { @State private var showJoinCircleAlert = false @State private var pendingCircleInvite: (code: String, circleName: String?)? - @State private var isProcessingDeepLink = false + @State private var isLaunchedFromWidget = false - @StateObject private var navigationCoordinator = NavigationCoordinator() - - @StateObject private var toastManager = ToastManager() init() { setupFirebase() NotificationManager.shared.requestAuthorization() + + + if ProcessInfo.processInfo.environment["LAUNCHED_FROM_WIDGET"] != nil { + self._isLaunchedFromWidget = State(initialValue: true) + } } var body: some Scene { @@ -47,6 +49,11 @@ struct VITTYApp: App { .environmentObject(navigationCoordinator) .task { try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)]) + + + if isLaunchedFromWidget { + await handleWidgetLaunch() + } } .onOpenURL { url in handleDeepLink(url) @@ -67,7 +74,6 @@ struct VITTYApp: App { } } - if toastManager.isShowing { CircleToastView( message: toastManager.message, @@ -85,10 +91,44 @@ struct VITTYApp: App { var sharedModelContainer: ModelContainer { let schema = Schema([TimeTable.self, Remainder.self, CreateNoteModel.self, UploadedFile.self]) + + + let appGroupContainerID = "group.com.gdscvit.vittyioswidget" let config = ModelConfiguration( - "group.com.gdscvit.vittyioswidget" + appGroupContainerID, + schema: schema, + isStoredInMemoryOnly: false, + allowsSave: true ) - return try! ModelContainer(for: schema, configurations: config) + + do { + let container = try ModelContainer(for: schema, configurations: config) + logger.info("Model container created successfully with app group: \(appGroupContainerID)") + return container + } catch { + logger.error(" Failed to create model container: \(error)") + fatalError("Failed to create model container: \(error)") + } + } + + // MARK: - Widget Launch Handling + + private func handleWidgetLaunch() async { + logger.info(" App launched from widget, performing data sync...") + + + try? await Task.sleep(nanoseconds: 500_000_000) + + + await MainActor.run { + NotificationCenter.default.post( + name: Notification.Name("RefreshTimetableFromWidget"), + object: nil + ) + } + + + isLaunchedFromWidget = false } } @@ -101,7 +141,6 @@ class ToastManager: ObservableObject { private var hideTimer: Timer? init() { - NotificationCenter.default.addObserver( self, selector: #selector(showToastNotification), @@ -126,7 +165,6 @@ class ToastManager: ObservableObject { self.isError = isError self.isShowing = true - self.hideTimer?.invalidate() self.hideTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in self.hideToast() @@ -208,7 +246,6 @@ extension VITTYApp { private func handleDeepLink(_ url: URL) { logger.info("Deep link received: \(url.absoluteString)") - guard !isProcessingDeepLink else { logger.info("Already processing a deep link, ignoring") return @@ -247,12 +284,10 @@ extension VITTYApp { logger.info("Parsed circle name: \(name)") } - navigationCoordinator.navigateToCirclesForInvite(code: code, circleName: circleName) let invite = (code: code, circleName: circleName) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.pendingCircleInvite = invite self.showJoinCircleAlert = true @@ -317,7 +352,6 @@ extension VITTYApp { let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NotificationCenter.default.post( name: Notification.Name("CircleJoinedSuccessfully"), @@ -350,7 +384,6 @@ extension VITTYApp { // MARK: - Helper Methods - private func cleanup() { pendingCircleInvite = nil isProcessingDeepLink = false @@ -377,7 +410,6 @@ extension VITTYApp { } private func showToast(message: String, isError: Bool) { - // NEW: Use ToastManager directly toastManager.showToast(message: message, isError: isError) if isError { diff --git a/VITTY/VittyWidget/Providers/ScheduleProvider.swift b/VITTY/VittyWidget/Providers/ScheduleProvider.swift index 3495ea2..a8f2064 100644 --- a/VITTY/VittyWidget/Providers/ScheduleProvider.swift +++ b/VITTY/VittyWidget/Providers/ScheduleProvider.swift @@ -14,11 +14,27 @@ struct Provider: TimelineProvider { private func getSharedContainer() -> ModelContainer? { let appGroupContainerID = "group.com.gdscvit.vittyioswidget" - let config = ModelConfiguration(appGroupContainerID) - return try? ModelContainer(for: TimeTable.self, configurations: config) + + let schema = Schema([TimeTable.self, Remainder.self, CreateNoteModel.self, UploadedFile.self]) + + let config = ModelConfiguration( + appGroupContainerID, + schema: schema, + isStoredInMemoryOnly: false, + allowsSave: true + ) + + do { + + return try ModelContainer(for: schema, configurations: config) + } catch { + print("Failed to create shared container: \(error)") + return nil + } } + // MARK: - Time Parsing and Validation private func parseTimeString(_ timeString: String) -> Date? { @@ -124,13 +140,12 @@ struct Provider: TimelineProvider { let completedCount = calculateCompletedClassesCount() var displayClasses: [Classes] = [] - - // Add current class first + if let current = currentClass { displayClasses.append(current) } - // Add upcoming classes + displayClasses.append(contentsOf: upcomingClasses) return ( @@ -189,7 +204,7 @@ struct Provider: TimelineProvider { dateFormatter.locale = Locale(identifier: "en_US_POSIX") let calendar = Calendar.current - // Find the current or next active class + var pivotIndex = 0 for (index, classItem) in sortedClasses.enumerated() { @@ -200,7 +215,7 @@ struct Provider: TimelineProvider { } } - // Create groups of 4 starting from the beginning + let groupSize = 4 let currentGroupIndex = pivotIndex / groupSize let startIndex = currentGroupIndex * groupSize @@ -225,7 +240,7 @@ struct Provider: TimelineProvider { func getSnapshot(in context: Context, completion: @escaping (ScheduleEntry) -> ()) { let content: (classes: [Classes], total: Int, completed: Int) - // Use different content preparation based on widget size + switch context.family { case .systemLarge: content = prepareLargeContent() @@ -245,7 +260,7 @@ struct Provider: TimelineProvider { let content: (classes: [Classes], total: Int, completed: Int) let currentTime = Date() - // Use different content preparation based on widget size + switch context.family { case .systemLarge: content = prepareLargeContent() @@ -260,7 +275,7 @@ struct Provider: TimelineProvider { completed: content.completed ) - // Calculate next refresh time based on widget size + let nextRefreshTime: Date switch context.family { case .systemLarge: @@ -278,20 +293,20 @@ struct Provider: TimelineProvider { private func calculateNextRefreshTime(currentTime: Date, classes: [Classes]) -> Date { let calendar = Calendar.current - // Find next significant time (class start/end) + var nextSignificantTime: Date? for classItem in classes { let (startTime, endTime) = parseClassTime(classItem.time) - // Check for next class start + if let start = startTime, start > currentTime { if nextSignificantTime == nil || start < nextSignificantTime! { nextSignificantTime = start } } - // Check for current class end + if let end = endTime, end > currentTime { if nextSignificantTime == nil || end < nextSignificantTime! { nextSignificantTime = end @@ -299,7 +314,7 @@ struct Provider: TimelineProvider { } } - // Use significant time if found, otherwise refresh in 15 minutes + if let significantTime = nextSignificantTime { return significantTime } @@ -312,23 +327,23 @@ struct Provider: TimelineProvider { let allClasses = fetchAllTodaysClasses() let sortedClasses = getSortedClasses(allClasses) - // Find when the current group of 4 will change + let currentGroupIndex = getCurrentGroupIndex(sortedClasses, currentTime: currentTime) let groupSize = 4 let currentGroupStart = currentGroupIndex * groupSize let currentGroupEnd = min(currentGroupStart + groupSize, sortedClasses.count) - // Find the next significant time that would cause a group change + var nextGroupChangeTime: Date? - // Check if we need to move to the next group when current classes in this group end + for i in currentGroupStart.. currentTime { - // Check if this would trigger a group change + if isLastClassInGroup(index: i, groupSize: groupSize, totalClasses: sortedClasses.count) { if nextGroupChangeTime == nil || end < nextGroupChangeTime! { nextGroupChangeTime = end @@ -338,19 +353,19 @@ struct Provider: TimelineProvider { } } - // If no group change time found, use regular refresh timing + if let groupChangeTime = nextGroupChangeTime { return groupChangeTime } - // Fallback to regular refresh timing + return calculateNextRefreshTime(currentTime: currentTime, classes: classes) } private func getCurrentGroupIndex(_ sortedClasses: [Classes], currentTime: Date) -> Int { let groupSize = 4 - // Find the current or next active class + var pivotIndex = 0 for (index, classItem) in sortedClasses.enumerated() { diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index c2686dc..e6c87ef 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -8,7 +8,6 @@ import SwiftUI import WidgetKit - struct LargeDueWidgetView: View { var entry: SmartDueEntry private let provider = RemindersProvider() @@ -20,7 +19,6 @@ struct LargeDueWidgetView: View { if !entry.isEmpty { let categorizedAssignments = provider.categorizeAssignmentsForLargeWidget(entry.assignments, primaryTitle: entry.widgetTitle) - VStack(alignment: .leading, spacing: 6) { Spacer().frame(height: 3) @@ -31,7 +29,6 @@ struct LargeDueWidgetView: View { } } - if !categorizedAssignments.secondary.isEmpty, let secondaryTitle = categorizedAssignments.secondaryTitle { Spacer().frame(height: 5) VStack(alignment: .leading, spacing: 6) { @@ -93,7 +90,6 @@ struct ScheduleLargeWidgetView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if entry.completed == entry.total { - VStack { Spacer() CircleProgressView( @@ -124,7 +120,6 @@ struct ScheduleLargeWidgetView: View { } .frame(maxWidth: .infinity) } else { - VStack { Spacer() CircleProgressView( @@ -142,16 +137,16 @@ struct ScheduleLargeWidgetView: View { Image("fourclassesline") VStack(alignment: .leading, spacing: 20) { - let displayClasses = getDisplayClasses() - - ForEach(displayClasses, id: \.title) { classItem in + + ForEach(entry.classes, id: \.title) { classItem in ScheduleItemView( title: classItem.title, time: "\(classItem.time) | \(classItem.slot ?? "")" ) } - let remainingCount = getUpcomingClasses().count - displayClasses.count + + let remainingCount = getRemainingClassesCount() if remainingCount > 0 { Text("+\(remainingCount) More") .foregroundColor(.white.opacity(0.6)) @@ -168,66 +163,10 @@ struct ScheduleLargeWidgetView: View { .padding(.vertical, 6).ignoresSafeArea() } - private func getUpcomingClasses() -> [Classes] { - let currentTime = Date() - let calendar = Calendar.current - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "h:mm a" - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - - - 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 - } - - - let now = Date() - let upcomingClasses = sortedClasses.filter { classItem in - let timeComponents = classItem.time.components(separatedBy: " - ") - guard timeComponents.count == 2 else { return true } - - let endTimeStr = timeComponents[1].trimmingCharacters(in: .whitespaces) - guard let endTime = dateFormatter.date(from: endTimeStr) else { return true } - - - let todayEnd = calendar.date( - bySettingHour: calendar.component(.hour, from: endTime), - minute: calendar.component(.minute, from: endTime), - second: 0, - of: now - ) - - - if let todayEnd = todayEnd { - return now <= todayEnd - } - - return true - } - - return upcomingClasses - } - private func getDisplayClasses() -> [Classes] { - let upcomingClasses = getUpcomingClasses() - - - let maxDisplay = min(4, upcomingClasses.count) - return Array(upcomingClasses.prefix(maxDisplay)) + private func getRemainingClassesCount() -> Int { + let totalUpcoming = entry.total - entry.completed + let currentBatchSize = entry.classes.count + return max(0, totalUpcoming - currentBatchSize) } } From 8970b8f4ebd4d6651c621d73e831be3034dbb519 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sun, 27 Jul 2025 09:15:51 +0530 Subject: [PATCH 7/8] feat: notes title bug fix --- VITTY/VITTY/Academics/View/Notes.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index 8d81c6e..f5c42f2 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -162,9 +162,14 @@ struct NoteEditorView: View { } private func handleBackNavigation() { - + if hasUnsavedChanges || (existingNote == nil && !isEmpty) { - showTitleAlert = true + + if existingNote != nil && !noteTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + saveNoteWithTitle() + } else { + showTitleAlert = true + } } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if presentationMode.wrappedValue.isPresented { @@ -230,8 +235,16 @@ struct NoteEditorView: View { } func saveContent() { - showTitleAlert = true + + if existingNote != nil && !noteTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + saveNoteWithTitle() + } else { + + showTitleAlert = true + } } + + private func saveNoteWithTitle() { guard !noteTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { From 0f7bf3f5dc185b2a81ad74ba3ca4d6caf545886c Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sun, 27 Jul 2025 23:25:55 +0530 Subject: [PATCH 8/8] feat: ghost mode implementation --- .../Colors/Background.colorset/Contents.json | 6 +- .../Colors/Secondary.colorset/Contents.json | 6 +- .../Connect/View/Freinds/View/Freinds.swift | 263 ++++++--- .../Connect/View/Freinds/View/FriendRow.swift | 498 +++++++++++++++++- .../ViewModel/CommunityPageViewModel.swift | 224 +++++++- .../Service/TimeTableAPIService.swift | 1 + .../Views/FriendsTimetableView.swift | 389 ++++++++------ VITTY/VITTY/UserProfileSideBar/SideBar.swift | 90 +--- 8 files changed, 1101 insertions(+), 376 deletions(-) diff --git a/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json b/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json index 7ca1468..7501a81 100644 --- a/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json +++ b/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.149", - "green" : "0.090", - "red" : "0.031" + "blue" : "0x25", + "green" : "0x16", + "red" : "0x07" } }, "idiom" : "universal" diff --git a/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json b/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json index 9785fb8..f917020 100644 --- a/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json +++ b/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.192", - "green" : "0.118", - "red" : "0.051" + "blue" : "0x30", + "green" : "0x1E", + "red" : "0x0D" } }, "idiom" : "universal" diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index 89d5479..3689545 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -9,115 +9,206 @@ import SwiftUI struct FriendsView: View { @State private var searchText = "" @State private var selectedFilterOption = 0 + @State private var showingUnfriendAlert = false + @State private var showingActionAlert = false + @State private var alertMessage = "" + @State private var selectedFriend: Friend? + @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(AuthViewModel.self) private var authViewModel + + private let filterOptions = ["All", "Available", "Ghosted"] var body: some View { - VStack(spacing: 12) { - Spacer().frame(height: 8) - - SearchBar(searchText: $searchText) - Spacer().frame(height: 8) - - - HStack { - FilterPill(title: "Available", isSelected: selectedFilterOption == 0) - .onTapGesture { - selectedFilterOption = 0 - } - FilterPill(title: "View All", isSelected: selectedFilterOption == 1) - .onTapGesture { - selectedFilterOption = 1 - } - Spacer() - } - .padding(.horizontal) - Spacer().frame(height: 7) - - - if communityPageViewModel.errorFreinds { - Spacer() - VStack(spacing: 5) { - Text("No Friends?") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white) - Text("Add your friends and see their timetable") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundColor(Color.white) - } - Spacer() - } else if communityPageViewModel.loadingFreinds { - Spacer() - ProgressView() - Spacer() - } else { + ZStack { + VStack(spacing: 12) { + Spacer().frame(height: 8) - let filteredFriends = communityPageViewModel.friends.filter { friend in - - let matchesSearch: Bool - if searchText.isEmpty { - matchesSearch = true - } else { - 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 + SearchBar(searchText: $searchText) + Spacer().frame(height: 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0.. String { + if !searchText.isEmpty { + switch selectedFilterOption { + case 0: return "No friends match your search" + case 1: return "No available friends match your search" + case 2: return "No ghosted friends match your search" + default: return "No friends match your search" + } + } else { + switch selectedFilterOption { + case 0: return "You don't have any friends yet" + case 1: return "No friends are currently available" + case 2: return "You haven't ghosted any friends" + default: return "You don't have any friends yet" + } + } + } + + private func cleanName(_ fullName: String) -> String { + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + let range = NSRange(location: 0, length: fullName.utf16.count) + let cleanedName = regex?.stringByReplacingMatches(in: fullName, options: [], range: range, withTemplate: "").trimmingCharacters(in: .whitespaces) ?? fullName + + return cleanedName + } + + private func getFriendRow(for friend: Friend) -> FriendRow? { + return nil } } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift b/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift index 3ea90b7..28ddae7 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift @@ -1,51 +1,497 @@ import SwiftUI -struct FriendRow: View { - let friend: Friend - +// MARK: - Unfriend Alert +struct UnfriendAlert: View { + let friendName: String + let onCancel: () -> Void + let onUnfriend: () -> Void + var body: some View { - HStack { - UserImage(url: friend.picture, height: 48, width: 48) - Spacer().frame(width: 20) - VStack(alignment: .leading) { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Unfriend \(friendName)?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) - Text(cleanName(friend.name)) - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white) + Text("This action cannot be undone. You'll need to send a new friend request to reconnect.") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) - if friend.currentStatus.status == "free" { - HStack { - Image("available").resizable().frame(width: 20, height: 20) - Text("Available").foregroundStyle(Color("Accent")) + 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) } - } else { - HStack { - Image("inclass") - Text(friend.currentStatus.venue ?? "") - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) + + Button(action: onUnfriend) { + Text("Unfriend") + .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)) + } +} + +// MARK: - Action Result Alert +struct ActionResultAlert: View { + let message: String + let onDismiss: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Action Result") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text(message) + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + Button(action: { + onDismiss() + }) { + Text("OK") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color("Accent")) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .frame(height: 120) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) Spacer() - } - .padding().frame(maxWidth: .infinity) .background( - RoundedRectangle(cornerRadius: 15) - .fill(Color("Secondary")) + Color.black.opacity(0.5) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + + } + ) + } +} + +// MARK: - Menu Button Item +struct MenuButtonItem: View { + let icon: String + let title: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundColor(color) + .frame(width: 14, height: 14) + Text(title) + .font(.custom("Poppins-Medium", size: 13)) + .foregroundColor(color) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.clear) + } + } +} + +// MARK: - Friend Menu +struct FriendMenu: View { + let friend: Friend + let isGhosted: Bool + let onTimetable: () -> Void + let onUnfriend: () -> Void + let onToggleGhost: () -> Void + + var body: some View { + VStack(spacing: 0) { + MenuButtonItem( + icon: "person.badge.minus", + title: "Unfriend", + color: .red, + action: onUnfriend + ) + + menuDivider + + MenuButtonItem( + icon: isGhosted ? "eye" : "eye.slash", + title: isGhosted ? "Show" : "Hide", + color: .orange, + action: onToggleGhost + ) + } + .frame(width: 140) + .background(menuBackground) + .offset(x: -75, y: 0) + .zIndex(100) + .transition(menuTransition) + } + + private var menuDivider: some View { + Divider() + .background(Color.gray.opacity(0.3)) + } + + private var menuBackground: some View { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "#1A2B42")) + .stroke(Color(hex: "#0D1E30"), lineWidth: 1) + .shadow(color: .black.opacity(0.4), radius: 6, x: -2, y: 2) + } + + private var menuTransition: AnyTransition { + .asymmetric( + insertion: .scale(scale: 0.8).combined(with: .opacity), + removal: .scale(scale: 0.8).combined(with: .opacity) ) } +} +// MARK: - Friend Status View +struct FriendStatusView: View { + let friend: Friend + + var body: some View { + VStack(alignment: .leading) { + Text(cleanName(friend.name)) + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(Color.white) + + statusIndicator + } + } + + @ViewBuilder + private var statusIndicator: some View { + 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")) + } + } + } + + private func cleanName(_ fullName: String) -> String { + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + let range = NSRange(location: 0, length: fullName.utf16.count) + let cleanedName = regex?.stringByReplacingMatches( + in: fullName, + options: [], + range: range, + withTemplate: "" + ).trimmingCharacters(in: .whitespaces) ?? fullName + + return cleanedName + } +} + + +struct FriendRow: View { + let friend: Friend + @State private var showingMenu = false + @State private var isLoading = false + @State private var navigateToTimetable = false + + @Binding var showingUnfriendAlert: Bool + @Binding var showingActionAlert: Bool + @Binding var alertMessage: String + @Binding var selectedFriend: Friend? + + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + + var body: some View { + ZStack { + hiddenNavigationLink + + mainContent + .overlay(loadingOverlay, alignment: .center) + .onReceive(NotificationCenter.default.publisher(for: .dismissMenus)) { _ in + showingMenu = false + } + .onTapGesture { + handleTapGesture() + } + } + .animation(.easeInOut(duration: 0.2), value: showingMenu) + } + private var hiddenNavigationLink: some View { + NavigationLink( + destination: FriendsTimeTableView(friend: friend), + isActive: $navigateToTimetable + ) { + EmptyView() + } + .hidden() + } + + private var mainContent: some View { + HStack { + UserImage(url: friend.picture, height: 48, width: 48) + + Spacer() + .frame(width: 20) + + FriendStatusView(friend: friend) + + Spacer() + + actionButtons + } + .padding() + .frame(maxWidth: .infinity) + .background(rowBackground) + } + + private var actionButtons: some View { + HStack(spacing: 12) { + if communityPageViewModel.isGhosted(friend.username) { + ghostIndicator + } + + menuButton + } + } + + private var ghostIndicator: some View { + Image(systemName: "eye.slash.fill") + .foregroundColor(.orange) + .font(.system(size: 16)) + } + + private var menuButton: some View { + Button(action: toggleMenu) { + Image(systemName: "ellipsis") + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .disabled(isLoading) + .zIndex(2) + .overlay(menuOverlay, alignment: .center) + } + + @ViewBuilder + private var menuOverlay: some View { + if showingMenu { + FriendMenu( + friend: friend, + isGhosted: communityPageViewModel.isGhosted(friend.username), + onTimetable: handleTimetableAction, + onUnfriend: handleUnfriendAction, + onToggleGhost: handleToggleGhostAction + ) + } + } + + private var rowBackground: some View { + RoundedRectangle(cornerRadius: 15) + .fill(Color(hex: "#0D1E30")) + .opacity(communityPageViewModel.isGhosted(friend.username) ? 0.6 : 1.0) + } + + @ViewBuilder + private var loadingOverlay: some View { + if isLoading { + RoundedRectangle(cornerRadius: 15) + .fill(Color.black.opacity(0.3)) + .overlay( + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } + + + private func toggleMenu() { + withAnimation(.easeInOut(duration: 0.2)) { + showingMenu.toggle() + } + } + + private func handleTapGesture() { + if showingMenu { + withAnimation(.easeInOut(duration: 0.2)) { + showingMenu = false + } + } else { + navigateToTimetable = true + } + } + + private func handleTimetableAction() { + showingMenu = false + navigateToTimetable = true + } + + private func handleUnfriendAction() { + selectedFriend = friend + showingUnfriendAlert = true + showingMenu = false + } + + private func handleToggleGhostAction() { + toggleGhostMode() + showingMenu = false + } + + // MARK: - Helper Functions func cleanName(_ fullName: String) -> String { - let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" // + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" let regex = try? NSRegularExpression(pattern: pattern, options: []) let range = NSRange(location: 0, length: fullName.utf16.count) - let cleanedName = regex?.stringByReplacingMatches(in: fullName, options: [], range: range, withTemplate: "").trimmingCharacters(in: .whitespaces) ?? fullName + let cleanedName = regex?.stringByReplacingMatches( + in: fullName, + options: [], + range: range, + withTemplate: "" + ).trimmingCharacters(in: .whitespaces) ?? fullName return cleanedName } + + // MARK: - Action Functions + func unfriendAction() { + guard let token = authViewModel.loggedInBackendUser?.token else { + alertMessage = "Authentication error. Please try again." + showingActionAlert = true + return + } + + isLoading = true + + communityPageViewModel.unfriendUser(username: friend.username, token: token) { success in + DispatchQueue.main.async { + isLoading = false + + if success { + alertMessage = "Successfully unfriended \(cleanName(friend.name))" + } else { + alertMessage = "Failed to unfriend \(cleanName(friend.name)). Please try again." + } + showingActionAlert = true + } + } + } + + private func toggleGhostMode() { + guard let token = authViewModel.loggedInBackendUser?.token else { + alertMessage = "Authentication error. Please try again." + showingActionAlert = true + return + } + + isLoading = true + let isCurrentlyGhosted = communityPageViewModel.isGhosted(friend.username) + + if isCurrentlyGhosted { + communityPageViewModel.makeAlive(username: friend.username, token: token) { success in + DispatchQueue.main.async { + handleGhostResult(success: success, action: "make alive") + } + } + } else { + communityPageViewModel.ghostFriend(username: friend.username, token: token) { success in + DispatchQueue.main.async { + handleGhostResult(success: success, action: "ghost") + } + } + } + } + + private func handleGhostResult(success: Bool, action: String) { + isLoading = false + + if success { + let message = action == "ghost" + ? "\(cleanName(friend.name)) has been ghosted and won't appear in your active friends" + : "\(cleanName(friend.name)) is now visible in your friends list" + alertMessage = message + } else { + alertMessage = "Failed to \(action) \(cleanName(friend.name)). Please try again." + } + + showingActionAlert = true + } } +// MARK: - Color Extension +extension Color { + init(hex: String) { + 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: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// MARK: - Notification Extension +extension Notification.Name { + static let dismissMenus = Notification.Name("dismissMenus") +} diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 83498d9..b764003 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -35,14 +35,20 @@ class CommunityPageViewModel { var memberTimetable: TimeTable? var loadingMemberTimetable = false var errorMemberTimetable = false + + //MARK: ghost mode + var ghostedFriends = Set() + var activeFriends = Set() + var loadingGhostAction = false private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: CommunityPageViewModel.self) ) + + var hasInitialActiveFriendsFetch = false func fetchFriendsData(from url: String, token: String, loading: Bool = false) { - if loading || friends.isEmpty { self.loadingFreinds = true } @@ -62,6 +68,9 @@ class CommunityPageViewModel { self.friends = data.data self.errorFreinds = false + + self.syncGhostStatusWithFriends() + case .failure(let error): self.logger.error("Error fetching friends: \(error)") if self.friends.isEmpty { @@ -71,6 +80,7 @@ class CommunityPageViewModel { } } } + //MARK: Circle DATA @@ -475,7 +485,7 @@ class CommunityPageViewModel { if data.detail.lowercased().contains("successfully") { self.logger.info("Successfully created circle: \(name)") - // Now fetch the updated circles data and wait for completion + self.fetchCircleDataWithCompletion( from: "\(APIConstants.base_urlv3)circles", token: token, @@ -530,7 +540,7 @@ class CommunityPageViewModel { } } - // MARK: - Updated Circle Invitations with New Endpoint + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { @@ -701,5 +711,213 @@ class CommunityPageViewModel { } } } + + // MARK: unfreind a user + func unfriendUser(username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)friends/\(username)/" + + AF.request(url, method: .delete, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: APIResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + self.logger.info("Successfully unfriended: \(username) - \(data.data)") + + + self.friends.removeAll { $0.username == username } + self.removeFromGhosted(username) + self.activeFriends.remove(username) + self.saveGhostStateToUserDefaults() + + completion(true) + + case .failure(let error): + self.logger.error("Error unfriending \(username): \(error)") + completion(false) + } + } + } + } + + // MARK: ghost mode funcs + + func fetchActiveFriends(token: String, forceRefresh: Bool = false, completion: @escaping (Bool) -> Void = { _ in }) { + + guard forceRefresh || !hasInitialActiveFriendsFetch else { + completion(true) + return + } + + let url = "\(APIConstants.base_url)friends/active" + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: ActiveFriendsResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + self.activeFriends = Set(data.data) + self.hasInitialActiveFriendsFetch = true + self.logger.info("Successfully fetched active friends: \(data.data)") + + + if !self.friends.isEmpty { + let allFriendUsernames = Set(self.friends.map { $0.username }) + let activeSet = Set(data.data) + let friendsToGhost = allFriendUsernames.subtracting(activeSet) + + + self.ghostedFriends.formUnion(friendsToGhost) + self.saveGhostStateToUserDefaults() + + self.logger.info("Active friends: \(data.data)") + self.logger.info("Ghosted friends: \(Array(self.ghostedFriends))") + } + + completion(true) + + case .failure(let error): + self.logger.error("Error fetching active friends: \(error)") + + self.loadGhostStateFromUserDefaults() + completion(false) + } + } + } + } + + private func syncGhostStatusWithFriends() { + guard !friends.isEmpty && !activeFriends.isEmpty else { return } + + let allFriendUsernames = Set(friends.map { $0.username }) + let friendsToGhost = allFriendUsernames.subtracting(activeFriends) + + + ghostedFriends.formUnion(friendsToGhost) + saveGhostStateToUserDefaults() } + + func ghostFriend(username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)friends/ghost/\(username)" + self.loadingGhostAction = true + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: APIResponse.self) { response in + DispatchQueue.main.async { + self.loadingGhostAction = false + + switch response.result { + case .success(let data): + self.logger.info("Successfully ghosted: \(username) - \(data.data)") + + + self.ghostedFriends.insert(username) + self.activeFriends.remove(username) + self.saveGhostStateToUserDefaults() + + completion(true) + + case .failure(let error): + self.logger.error("Error ghosting \(username): \(error)") + completion(false) + } + } + } + } + func makeAlive(username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)friends/alive/\(username)" + self.loadingGhostAction = true + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: APIResponse.self) { response in + DispatchQueue.main.async { + self.loadingGhostAction = false + + switch response.result { + case .success(let data): + self.logger.info("Successfully made alive: \(username) - \(data.data)") + + + self.ghostedFriends.remove(username) + self.activeFriends.insert(username) + self.saveGhostStateToUserDefaults() + + completion(true) + + case .failure(let error): + self.logger.error("Error making alive \(username): \(error)") + completion(false) + } + } + } + } + + func isGhosted(_ username: String) -> Bool { + return ghostedFriends.contains(username) + } + + func isActive(_ username: String) -> Bool { + return activeFriends.contains(username) && !ghostedFriends.contains(username) + } + + private func removeFromGhosted(_ username: String) { + ghostedFriends.remove(username) + } + + + private func saveGhostStateToUserDefaults() { + UserDefaults.standard.set(Array(ghostedFriends), forKey: "ghostedFriends") + } + + private func loadGhostStateFromUserDefaults() { + if let savedGhosted = UserDefaults.standard.array(forKey: "ghostedFriends") as? [String] { + ghostedFriends = Set(savedGhosted) + } + } + func refreshAllDataWithActiveCheck(token: String, username: String) { + + fetchActiveFriends(token: token) { [weak self] success in + if success { + + self?.fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(username)/", + token: token, + loading: false + ) + + self?.fetchCircleData( + from: "\(APIConstants.base_urlv3)circles", + token: token, + loading: false + ) + + self?.fetchCircleRequests(token: token, loading: false) + } + } + } + func initializeGhostState() { + loadGhostStateFromUserDefaults() + } + + struct ActiveFriendsResponse: Codable { + let data: [String] + } + + struct APIResponse: Codable { + let data: String + } + + + } + +extension CommunityPageViewModel { + + + func dismissAllMenus() { + NotificationCenter.default.post(name: .dismissMenus, object: nil) + } +} diff --git a/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift b/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift index 782f472..1f90284 100644 --- a/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift +++ b/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift @@ -26,6 +26,7 @@ class TimeTableAPIService { return timeTableRaw.data } + func getCircleMemberTimeTable( circleId: String, memberUsername: String, diff --git a/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift index feff9b5..5f710fe 100644 --- a/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift +++ b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift @@ -1,4 +1,3 @@ -// // FriendsTimetableView.swift // VITTY // @@ -28,178 +27,178 @@ struct FriendsTimeTableView: View { ) var body: some View { - NavigationStack { - ZStack { - BackgroundView() - VStack { + ZStack { + BackgroundView() + VStack { + + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + .font(.title2) + } + + Spacer() + + Text("\(friend.name)'s Timetable") + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Spacer() + + + } + .padding(.horizontal) + .padding(.bottom, 8) + + switch viewModel.stage { + case .loading: + VStack { + Spacer() + ProgressView() + .scaleEffect(1.2) + Text("Loading \(friend.name ?? friend.username)'s timetable...") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + Spacer() + } - HStack { - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) - .font(.title2) + case .error: + VStack { + Spacer() + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundColor(.orange) + .padding(.bottom, 16) + + Text("Can't show timetable right now") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("Unable to display \(friend.name ?? friend.username)'s timetable at the moment") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.bottom, 20) + + Button(action: { + + }) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Try Again") + } + .foregroundColor(.black) + .padding() + .background(Color("Accent")) + .cornerRadius(10) } + .disabled(isRefreshing) Spacer() + } + + case .empty: + VStack { + Spacer() + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + .padding(.bottom, 16) - Text("\(friend.name ?? friend.username)'s Timetable") - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(.white) + Text("No timetable available") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) - Spacer() + Text("\(friend.name ?? friend.username) hasn't shared their timetable yet") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) - + Spacer() } - .padding(.horizontal) - .padding(.bottom, 8) - switch viewModel.stage { - case .loading: - VStack { - Spacer() - ProgressView() - .scaleEffect(1.2) - Text("Loading \(friend.name ?? friend.username)'s timetable...") - .font(.caption) - .foregroundColor(.secondary) - .padding(.top, 8) - Spacer() - } - - case .error: - VStack { - Spacer() - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 50)) - .foregroundColor(.orange) - .padding(.bottom, 16) - - Text("Couldn't load timetable") - .font(Font.custom("Poppins-Bold", size: 24)) - .padding(.bottom, 8) - - Text("Unable to fetch \(friend.name ?? friend.username)'s timetable") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.bottom, 20) - - Button(action: { - showingRefreshAlert = true - }) { + case .data: + VStack(spacing: 0) { + // Day selector + ScrollViewReader { proxy in + ScrollView(.horizontal) { HStack { - Image(systemName: "arrow.clockwise") - Text("Try Again") + ForEach(daysOfWeek, id: \.self) { day in + Text(day) + .foregroundStyle(daysOfWeek[viewModel.dayNo] == day + ? Color("Background") : Color("Accent")) + .frame(width: 60, height: 54) + .background( + daysOfWeek[viewModel.dayNo] == day + ? Color("Accent") : Color.clear + ) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.dayNo = daysOfWeek.firstIndex(of: day)! + viewModel.changeDay() + proxy.scrollTo(day, anchor: .center) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + .id(day) + } + } + .padding(.horizontal, 8) + } + .scrollIndicators(.hidden) + .onAppear { + let currentDay = daysOfWeek[viewModel.dayNo] + proxy.scrollTo(currentDay, anchor: .center) + } + .onChange(of: viewModel.dayNo) { oldValue, newValue in + let selectedDay = daysOfWeek[newValue] + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(selectedDay, anchor: .center) } - .foregroundColor(.white) - .padding() - .background(Color("Accent")) - .cornerRadius(10) } - .disabled(isRefreshing) - - Spacer() } + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) - case .empty: - VStack { - Spacer() - Image(systemName: "calendar.badge.exclamationmark") - .font(.system(size: 50)) - .foregroundColor(.secondary) - .padding(.bottom, 16) - - Text("No timetable available") - .font(Font.custom("Poppins-Bold", size: 24)) - .padding(.bottom, 8) - - Text("\(friend.name ?? friend.username) hasn't shared their timetable yet") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Spacer() - } - case .data: - VStack(spacing: 0) { - // Day selector - ScrollViewReader { proxy in - ScrollView(.horizontal) { - HStack { - ForEach(daysOfWeek, id: \.self) { day in - Text(day) - .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) - .frame(width: 60, height: 54) - .background( - daysOfWeek[viewModel.dayNo] == day - ? Color("Accent") : Color.clear - ) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.dayNo = daysOfWeek.firstIndex(of: day)! - viewModel.changeDay() - proxy.scrollTo(day, anchor: .center) - } - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - .id(day) - } - } - .padding(.horizontal, 8) - } - .scrollIndicators(.hidden) - .onAppear { - let currentDay = daysOfWeek[viewModel.dayNo] - proxy.scrollTo(currentDay, anchor: .center) - } - .onChange(of: viewModel.dayNo) { oldValue, newValue in - let selectedDay = daysOfWeek[newValue] - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(selectedDay, anchor: .center) - } - } + if viewModel.lectures.isEmpty { + Spacer() + VStack(spacing: 16) { + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + + Text("No classes today!") + .font(Font.custom("Poppins-Bold", size: 24)) + + Text("Your friend has no classes on \(daysOfWeek[viewModel.dayNo])") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } - .background(Color("Secondary")) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - - // Lectures list - if viewModel.lectures.isEmpty { - Spacer() - VStack(spacing: 16) { - Image(systemName: "calendar.badge.exclamationmark") - .font(.system(size: 50)) - .foregroundColor(.secondary) - - Text("No classes today!") - .font(Font.custom("Poppins-Bold", size: 24)) - - Text("Your friend has no classes on \(daysOfWeek[viewModel.dayNo])") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - Spacer() - } else { - ScrollView { - VStack(spacing: 12) { - ForEach(viewModel.lectures.sorted()) { lecture in - LectureItemView( - lecture: lecture, - selectedDayIndex: viewModel.dayNo, - allLectures: viewModel.lectures - ) { - selectedLecture = lecture - } + Spacer() + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(viewModel.lectures.sorted()) { lecture in + LectureItemView( + lecture: lecture, + selectedDayIndex: viewModel.dayNo, + allLectures: viewModel.lectures + ) { + selectedLecture = lecture } } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 100) } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) } } } @@ -209,17 +208,8 @@ struct FriendsTimeTableView: View { .sheet(item: $selectedLecture) { lecture in LectureDetailView(lecture: lecture) } - .alert("Refresh Timetable", isPresented: $showingRefreshAlert) { - Button("Cancel", role: .cancel) { } - Button("Refresh", role: .destructive) { - Task { - await refreshTimetable() - } - } - } message: { - Text("This will fetch fresh data from the server. Continue?") - } - .navigationBarBackButtonHidden(true) + + .navigationBarHidden(true) .onAppear { logger.debug("FriendsTimeTableView appeared for friend: \(friend.username)") loadFriendsTimetable() @@ -338,11 +328,11 @@ extension FriendsTimeTableView { authToken: String ) async { do { - logger.info("Fetching friend's timetable from API") + logger.info("Fetching friend's timetable from API using /users/\(friendUsername) endpoint") - - let friendTimeTable = try await TimeTableAPIService.shared.getTimeTable( - with: friendUsername, + + let friendTimeTable = try await TimeTableAPIService.shared.getFriendTimeTable( + username: friendUsername, authToken: authToken ) @@ -354,7 +344,6 @@ extension FriendsTimeTableView { return } - self.timeTable = friendTimeTable changeDay() stage = .data @@ -363,6 +352,8 @@ extension FriendsTimeTableView { } catch { logger.error("Failed to fetch friend's timetable: \(error.localizedDescription)") + + stage = .error } } @@ -378,3 +369,61 @@ extension FriendsTimeTableView { } } } + + +extension FriendsTimeTableView { + enum Stage { + case loading + case data + case empty + case error + } +} + + +enum APIError: Error { + case serverError(code: String, message: String) + case networkError + case decodingError + case unauthorized + +} + + +extension TimeTableAPIService { + func getFriendTimeTable(username: String, authToken: String) async throws -> TimeTable { + guard let url = URL(string: "\(APIConstants.base_urlv3)users/\(username)") else { + throw APIError.networkError + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 500 { + + if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data), + errorResponse.code == "1811" { + throw APIError.serverError(code: errorResponse.code, message: errorResponse.error) + } + } + + guard httpResponse.statusCode == 200 else { + throw APIError.serverError(code: "UNKNOWN", message: "HTTP \(httpResponse.statusCode)") + } + } + + let decoder = JSONDecoder() + return try decoder.decode(TimeTable.self, from: data) + } +} + + +struct ErrorResponse: Codable { + let code: String + let error: String +} diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 057dc4f..23197d5 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -8,8 +8,7 @@ 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 @State private var isLoggingOut: Bool = false @@ -65,31 +64,7 @@ struct UserProfileSidebar: View { 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)) - - 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() @@ -128,66 +103,13 @@ struct UserProfileSidebar: View { .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_urlv3)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() - } + + private func performLogout() async { isLoggingOut = true @@ -220,8 +142,6 @@ struct UserProfileSidebar: View { print("Failed to delete local data: \(error)") } }.value - } catch { - print("Failed to clear local data: \(error)") } } }