From 239fd7dc46b759fc83e2ee7f65ca9acc3f936eeb Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sat, 19 Jul 2025 00:42:43 +0530 Subject: [PATCH] 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: