diff --git a/VITTY/ServerStatus.swift b/VITTY/ServerStatus.swift new file mode 100644 index 0000000..96b8f06 --- /dev/null +++ b/VITTY/ServerStatus.swift @@ -0,0 +1,175 @@ +// +// ServerStatus.swift +// VITTY +// +// Created by Rujin Devkota on 8/4/25. +// + +import Foundation +import Alamofire +import SwiftUI +import OSLog + +@Observable +class ServerStatusManager { + var isServerDown = false + var isCheckingServer = false + var showMaintenanceAlert = false + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: ServerStatusManager.self) + ) + + + func checkServerStatus(completion: @escaping (Bool) -> Void) { + isCheckingServer = true + + let url = APIConstants.base_url + + AF.request(url, method: .get) + .validate() + .responseString { response in + DispatchQueue.main.async { + self.isCheckingServer = false + + switch response.result { + case .success(let responseString): + + let isServerUp = responseString.contains("Welcome to VITTY API!🎉") + self.isServerDown = !isServerUp + + if !isServerUp { + self.showMaintenanceAlert = true + self.logger.warning("Server is under maintenance") + } + + completion(isServerUp) + + case .failure(let error): + self.logger.error("Server check failed: \(error)") + self.isServerDown = true + self.showMaintenanceAlert = true + completion(false) + } + } + } + } + + + // function for testing +// func checkServerStatus(completion: @escaping (Bool) -> Void) { +// isCheckingServer = true +// +// +// DispatchQueue.main.async { +// self.isCheckingServer = false +// self.isServerDown = false +// self.showMaintenanceAlert = false +// +// +// completion(true) +// } +// } + + func hideMaintenanceAlert() { + showMaintenanceAlert = false + } + + func retryServerCheck(completion: @escaping (Bool) -> Void) { + checkServerStatus(completion: completion) + } +} + +// MARK: - Maintenance Alert View +struct MaintenanceAlertView: View { + @Binding var isPresented: Bool + let onRetry: () -> Void + let onContinue: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.5) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 20) { + + HStack { + Spacer() + Button(action: { + isPresented = false + onContinue() + }) { + Image(systemName: "xmark") + .foregroundColor(.gray) + .font(.system(size: 18)) + } + } + + + RoundedRectangle(cornerRadius: 16) + .fill(Color.gray.opacity(0.3)) + .frame(width: 80, height: 80) + .overlay( + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(.white) + .font(.system(size: 32)) + ) + + + Text("Server Maintenance") + .font(.custom("Poppins-SemiBold", size: 24)) + .foregroundColor(.white) + + + Text("The server is currently under maintenance. Some features may be temporarily unavailable.") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + // Buttons + HStack(spacing: 15) { + Button(action: { + onRetry() + }) { + Text("Retry") + .font(.custom("Poppins-Medium", size: 16)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.white) + .cornerRadius(8) + } + + Button(action: { + isPresented = false + onContinue() + }) { + Text("Continue") + .font(.custom("Poppins-Medium", size: 16)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + } + } + .padding(.horizontal, 20) + + + Text("Thank you for your patience") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.gray) + .padding(.top, 10) + } + .padding(30) + .background(Color("Background")) + .cornerRadius(20) + .padding(.horizontal, 30) + } + } +} diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 4a7fb3c..f522f5b 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 4B183EE82D7C78B600C9D801 /* Courses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE72D7C78B300C9D801 /* Courses.swift */; }; 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; + 4B1A500F2E3E61530060314D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B1A500E2E3E61530060314D /* GoogleService-Info.plist */; }; + 4B1A50112E409F8C0060314D /* ServerStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1A50102E409F860060314D /* ServerStatus.swift */; }; 4B1BDBCC2E1396B1008C2DE9 /* ToolTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */; }; 4B2D648F2E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; @@ -197,6 +199,8 @@ 4B183EE72D7C78B300C9D801 /* Courses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Courses.swift; sourceTree = ""; }; 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = ""; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = ""; }; + 4B1A500E2E3E61530060314D /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 4B1A50102E409F860060314D /* ServerStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerStatus.swift; sourceTree = ""; }; 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolTip.swift; sourceTree = ""; }; 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = ""; }; @@ -414,10 +418,12 @@ isa = PBXGroup; children = ( 4BC853C52DF6F71B0092B2E2 /* VittyWidgetExtension.entitlements */, + 4B1A500E2E3E61530060314D /* GoogleService-Info.plist */, 4B8FB2C72E39D29F00E50AE2 /* GoogleService-Info.plist */, 5251A7FF2B46E3C000D44CFE /* .swift-format */, 314A408E27383BEC0058082F /* VITTYApp.swift */, 314A409027383BEC0058082F /* ContentView.swift */, + 4B1A50102E409F860060314D /* ServerStatus.swift */, 314A409227383BEE0058082F /* Assets.xcassets */, AFEFCB6C27C90233007B2029 /* VITTY.entitlements */, AFEFCB6B27C90042007B2029 /* VITTYRelease.entitlements */, @@ -1122,6 +1128,7 @@ 31128CFA2772F57E0084C9EA /* Poppins-SemiBoldItalic.ttf in Resources */, 31128CFC2772F57E0084C9EA /* Poppins-Regular.ttf in Resources */, 4B8FB2C82E39D29F00E50AE2 /* GoogleService-Info.plist in Resources */, + 4B1A500F2E3E61530060314D /* GoogleService-Info.plist in Resources */, 314A409627383BEE0058082F /* Preview Assets.xcassets in Resources */, 314A409327383BEE0058082F /* Assets.xcassets in Resources */, ); @@ -1213,6 +1220,7 @@ 4B7DA5E72D71AC54007354A3 /* CirclesRow.swift in Sources */, 4B7DA5E52D70B2CA007354A3 /* Circles.swift in Sources */, 525F759D2B809F8400E3B418 /* LectureDetailView.swift in Sources */, + 4B1A50112E409F8C0060314D /* ServerStatus.swift in Sources */, 520BA6432B47FFF900124850 /* SuggestedFriendsViewModel.swift in Sources */, 314A408F27383BEC0058082F /* VITTYApp.swift in Sources */, 52978F8E2C3A7924008CF46C /* BottomBarView.swift in Sources */, diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index e8418ff..142522e 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -29,6 +29,8 @@ struct ConnectPage: View { @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(FriendRequestViewModel.self) private var friendRequestViewModel @Environment(RequestsViewModel.self) private var requestsViewModel + @State private var serverStatusManager = ServerStatusManager() + @State private var isShowingRequestView = false @State var isCircleView = false @State private var activeSheet: SheetType? @@ -41,6 +43,7 @@ struct ConnectPage: View { @State private var isAddFriendsViewPresented = false @State private var selectedTab = 0 @State private var hasLoadedInitialData = false + @State private var hasCheckedServer = false var body: some View { ZStack { @@ -71,7 +74,9 @@ struct ConnectPage: View { if isCircleView == false { Button(action: { - isShowingRequestView.toggle() + checkServerAndExecute { + isShowingRequestView.toggle() + } }) { ZStack { @@ -104,7 +109,9 @@ struct ConnectPage: View { .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } else { Button(action: { - showCircleMenu = true + checkServerAndExecute { + showCircleMenu = true + } }) { Image(systemName: "ellipsis") .foregroundColor(.white) @@ -112,19 +119,43 @@ struct ConnectPage: View { } .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } + + + if serverStatusManager.showMaintenanceAlert { + MaintenanceAlertView( + isPresented: $serverStatusManager.showMaintenanceAlert, + onRetry: { + serverStatusManager.retryServerCheck { isServerUp in + if isServerUp { + loadInitialData() + } + } + }, + onContinue: { + + serverStatusManager.hideMaintenanceAlert() + } + ) + } } .overlay( Group { if showCircleMenu { ConnectCircleMenuView( onCreateGroup: { - activeSheet = .createGroup + checkServerAndExecute { + activeSheet = .createGroup + } }, onJoinGroup: { - activeSheet = .joinGroup + checkServerAndExecute { + activeSheet = .joinGroup + } }, onGroupRequests: { - activeSheet = .groupRequests + checkServerAndExecute { + activeSheet = .groupRequests + } }, onCancel: { showCircleMenu = false @@ -144,55 +175,86 @@ struct ConnectPage: View { case .groupRequests: CircleRequestsView() } - }.onChange(of: navigationCoordinator.shouldNavigateToCircles) { _, shouldNavigate in + } + .onChange(of: navigationCoordinator.shouldNavigateToCircles) { _, shouldNavigate in if shouldNavigate { selectedTab = 0 } + checkServerAndExecute { + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_urlv3)circles", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: true + ) + } + } + .onAppear { + if !hasCheckedServer { + checkServerAndLoadData() + } + } + } + + // MARK: - Helper Methods + + private func checkServerAndLoadData() { + hasCheckedServer = true + serverStatusManager.checkServerStatus { isServerUp in + if isServerUp || !serverStatusManager.showMaintenanceAlert { + loadInitialData() + } + } + } + + private func checkServerAndExecute(_ action: @escaping () -> Void) { + if serverStatusManager.isServerDown { + serverStatusManager.showMaintenanceAlert = true + } else { + serverStatusManager.checkServerStatus { isServerUp in + if isServerUp { + action() + } + } + } + } + + private func loadInitialData() { + if navigationCoordinator.shouldNavigateToCircles { + selectedTab = 0 + } + + let shouldShowLoading = !hasLoadedInitialData + + requestsViewModel.fetchFriendRequests( + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + + if communityPageViewModel.friends.isEmpty || !hasLoadedInitialData { + communityPageViewModel.fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: shouldShowLoading + ) + } + + if communityPageViewModel.circles.isEmpty || !hasLoadedInitialData { communityPageViewModel.fetchCircleData( from: "\(APIConstants.base_urlv3)circles", token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true + loading: shouldShowLoading ) } - .onAppear { - if navigationCoordinator.shouldNavigateToCircles { - selectedTab = 0 - } - let shouldShowLoading = !hasLoadedInitialData - - requestsViewModel.fetchFriendRequests( - token: authViewModel.loggedInBackendUser?.token ?? "", + if communityPageViewModel.circleRequests.isEmpty || !hasLoadedInitialData { + friendRequestViewModel.fetchFriendRequests( + from: URL(string: "\(APIConstants.base_urlv3)requests/")!, + authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: shouldShowLoading ) - - - if communityPageViewModel.friends.isEmpty || !hasLoadedInitialData { - communityPageViewModel.fetchFriendsData( - from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: shouldShowLoading - ) - } - - if communityPageViewModel.circles.isEmpty || !hasLoadedInitialData { - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_urlv3)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: shouldShowLoading - ) - } - - if communityPageViewModel.circleRequests.isEmpty || !hasLoadedInitialData { - friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_urlv3)requests/")!, - authToken: authViewModel.loggedInBackendUser?.token ?? "", - loading: shouldShowLoading - ) - } - - hasLoadedInitialData = true } + + hasLoadedInitialData = true } } struct ConnectCircleMenuView: View { diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index b764003..ee8033c 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -757,21 +757,22 @@ class CommunityPageViewModel { DispatchQueue.main.async { switch response.result { case .success(let data): - self.activeFriends = Set(data.data) + + let activeUsernames = data.data.map { $0.friend_username } + self.activeFriends = Set(activeUsernames) self.hasInitialActiveFriendsFetch = true - self.logger.info("Successfully fetched active friends: \(data.data)") + self.logger.info("Successfully fetched active friends: \(activeUsernames)") - + if !self.friends.isEmpty { let allFriendUsernames = Set(self.friends.map { $0.username }) - let activeSet = Set(data.data) + let activeSet = Set(activeUsernames) let friendsToGhost = allFriendUsernames.subtracting(activeSet) - self.ghostedFriends.formUnion(friendsToGhost) self.saveGhostStateToUserDefaults() - self.logger.info("Active friends: \(data.data)") + self.logger.info("Active friends: \(activeUsernames)") self.logger.info("Ghosted friends: \(Array(self.ghostedFriends))") } @@ -779,7 +780,6 @@ class CommunityPageViewModel { case .failure(let error): self.logger.error("Error fetching active friends: \(error)") - self.loadGhostStateFromUserDefaults() completion(false) } @@ -904,9 +904,15 @@ class CommunityPageViewModel { } struct ActiveFriendsResponse: Codable { - let data: [String] + let data: [ActiveFriend] + } + + struct ActiveFriend: Codable { + let friend_username: String + let hide: Bool } + struct APIResponse: Codable { let data: String } diff --git a/VITTY/VITTY/Instruction/Views/InstructionView.swift b/VITTY/VITTY/Instruction/Views/InstructionView.swift index e73c7fd..4067901 100644 --- a/VITTY/VITTY/Instruction/Views/InstructionView.swift +++ b/VITTY/VITTY/Instruction/Views/InstructionView.swift @@ -8,103 +8,194 @@ import SwiftUI struct InstructionView: View { - @Environment(AuthViewModel.self) private var authViewModel - var body: some View { - NavigationStack { - ZStack { - BackgroundView() - VStack(alignment: .leading) { - VStack(alignment: .leading) { - Text("Account Details") - .font(Font.custom("Poppins-SemiBold", size: 20)) - .foregroundColor(Color.white) - .padding(.vertical, 5) - Text( - "Name: \(authViewModel.loggedInFirebaseUser?.displayName ?? "-")" - ) - Text( - "Signed in with: \(authViewModel.loggedInFirebaseUser?.providerID ?? "-")" - ) - Text( - "Email: \(authViewModel.loggedInFirebaseUser?.email ?? "-")" - ) - } - .padding() - .frame(maxWidth: .infinity, alignment: .topLeading) - .font(Font.custom("Poppins-Regular", size: 16)) - .background{ - RoundedRectangle(cornerRadius: 12, style: .circular) - .fill(Color("Secondary")) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color("Accent"), lineWidth: 1.2) - ) - } - VStack(alignment: .leading) { - Text("Setup Instructions") - .font(Font.custom("Poppins-SemiBold", size: 20)) - .padding(.vertical, 5) - VStack(alignment: .leading) { - Text("1. Upload the timetable on") - Link( - destination: URL(string: "https://dscv.it/vittyconnect")!, - label: { - Text(StringConstants.websiteURL) - .underline() - } - ) - Text("2. Log in with the same Apple/Google Account as shown above") - Text("3. Upload a screenshot of your timetable") - Text("4. Review it") - Text("5. When done, click on Upload") - Text("BRAVO! That's it. You did it!") - .padding(.vertical) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .topLeading) - .font(Font.custom("Poppins-Regular", size: 16)) - .background{ - RoundedRectangle(cornerRadius: 12, style: .circular) - .fill(Color("Secondary")) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color("Accent"), lineWidth: 1.2) - ) - } - Spacer() - NavigationLink(destination: { - if authViewModel.loggedInBackendUser == nil { - UsernameView() - } - else { - HomeView() - } - }) { - Spacer() - Text("Done") - .fontWeight(.bold) - .foregroundColor(Color.white) - .padding(.vertical, 16) - - Spacer() - } - .background(Color("Secondary")) - .cornerRadius(18) - } - .padding() - } - .toolbar { - Button(action: { - authViewModel.signOut() - }) { - Image(systemName: "arrow.right.square") - } - .foregroundStyle(.white) - } - .navigationTitle("Sync Timetable") - } - } + @Environment(AuthViewModel.self) private var authViewModel + @State private var serverStatusManager = ServerStatusManager() + + var body: some View { + NavigationStack { + ZStack { + BackgroundView() + + if serverStatusManager.isServerDown { + + VStack(spacing: 30) { + Spacer() + + + RoundedRectangle(cornerRadius: 20) + .fill(Color.gray.opacity(0.3)) + .frame(width: 100, height: 100) + .overlay( + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(.white) + .font(.system(size: 40)) + ) + + + Text("Server Under Maintenance") + .font(.custom("Poppins-SemiBold", size: 28)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + + + + Text("We're currently performing server maintenance to improve your experience.") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Text("Please check back in a few minutes. We'll be back online soon!") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + + Button(action: { + serverStatusManager.retryServerCheck { isUp in + + } + }) { + HStack { + if serverStatusManager.isCheckingServer { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + .scaleEffect(0.8) + } else { + Text("Try Again") + .font(.custom("Poppins-Medium", size: 16)) + } + } + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .cornerRadius(12) + } + .disabled(serverStatusManager.isCheckingServer) + .padding(.horizontal, 40) + + + Button(action: { + exit(0) + }) { + Text("Exit App") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.gray) + } + .padding(.top, 10) + + Spacer() + + + Text("Thank you for your patience") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.gray) + .padding(.bottom, 30) + } + } else { + + VStack(alignment: .leading) { + VStack(alignment: .leading) { + Text("Account Details") + .font(Font.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(Color.white) + .padding(.vertical, 5) + Text( + "Name: \(authViewModel.loggedInFirebaseUser?.displayName ?? "-")" + ) + Text( + "Signed in with: \(authViewModel.loggedInFirebaseUser?.providerID ?? "-")" + ) + Text( + "Email: \(authViewModel.loggedInFirebaseUser?.email ?? "-")" + ) + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + .font(Font.custom("Poppins-Regular", size: 16)) + .background{ + RoundedRectangle(cornerRadius: 12, style: .circular) + .fill(Color("Secondary")) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 1.2) + ) + } + + VStack(alignment: .leading) { + Text("Setup Instructions") + .font(Font.custom("Poppins-SemiBold", size: 20)) + .padding(.vertical, 5) + VStack(alignment: .leading) { + Text("1. Upload the timetable on") + Link( + destination: URL(string: "https://dscv.it/vittyconnect")!, + label: { + Text(StringConstants.websiteURL) + .underline() + } + ) + Text("2. Log in with the same Apple/Google Account as shown above") + Text("3. Upload a screenshot of your timetable") + Text("4. Review it") + Text("5. When done, click on Upload") + Text("BRAVO! That's it. You did it!") + .padding(.vertical) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + .font(Font.custom("Poppins-Regular", size: 16)) + .background{ + RoundedRectangle(cornerRadius: 12, style: .circular) + .fill(Color("Secondary")) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color("Accent"), lineWidth: 1.2) + ) + } + + Spacer() + + NavigationLink(destination: { + if authViewModel.loggedInBackendUser == nil { + UsernameView() + } + else { + HomeView() + } + }) { + Spacer() + Text("Done") + .fontWeight(.bold) + .foregroundColor(Color.white) + .padding(.vertical, 16) + Spacer() + } + .background(Color("Secondary")) + .cornerRadius(18) + } + .padding() + } + } + .toolbar { + Button(action: { + authViewModel.signOut() + }) { + Image(systemName: "arrow.right.square") + } + .foregroundStyle(.white) + } + .navigationTitle(serverStatusManager.isServerDown ? "" : "Sync Timetable") + .onAppear { + + serverStatusManager.checkServerStatus { isUp in + + } + } + } + } } - - diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 5a8ed9d..c7c0141 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -12,7 +12,7 @@ class Constants { // "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" - "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + "https://53392be09f64.ngrok-free.app/api/v2/" // "https://f4df-2409-40e3-30a4-8539-6d49-631b-ddd8-60a3.ngrok-free.app/api/v2/" diff --git a/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift index 5f710fe..66eb5b8 100644 --- a/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift +++ b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift @@ -45,8 +45,6 @@ struct FriendsTimeTableView: View { .foregroundColor(.white) Spacer() - - } .padding(.horizontal) .padding(.bottom, 8) @@ -64,42 +62,6 @@ struct FriendsTimeTableView: View { Spacer() } - 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() @@ -123,7 +85,7 @@ struct FriendsTimeTableView: View { case .data: VStack(spacing: 0) { - // Day selector + ScrollViewReader { proxy in ScrollView(.horizontal) { HStack { @@ -200,6 +162,7 @@ struct FriendsTimeTableView: View { .padding(.top, 12) .padding(.bottom, 100) } + .scrollIndicators(.hidden) } } } @@ -219,7 +182,6 @@ struct FriendsTimeTableView: View { 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 @@ -297,7 +259,7 @@ extension FriendsTimeTableView { guard !friendUsername.isEmpty && !authToken.isEmpty else { logger.error("Missing friend username or auth token") - stage = .error + stage = .empty return } @@ -330,11 +292,13 @@ extension FriendsTimeTableView { do { logger.info("Fetching friend's timetable from API using /users/\(friendUsername) endpoint") - - let friendTimeTable = try await TimeTableAPIService.shared.getFriendTimeTable( + let friendResponse = try await TimeTableAPIService.shared.getFriendResponse( username: friendUsername, authToken: authToken ) + + // Extract the timetable from the response + let friendTimeTable = friendResponse.timetable.data if isTimeTableEmpty(friendTimeTable) { logger.info("Friend's timetable is empty") @@ -354,7 +318,7 @@ extension FriendsTimeTableView { logger.error("Failed to fetch friend's timetable: \(error.localizedDescription)") - stage = .error + stage = .empty } } @@ -370,28 +334,68 @@ 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 - } +// MARK: - Friend Response Models +struct FriendResponse: Codable { + let campus: String + let email: String + let friendStatus: String + let friendsCount: Int + let mutualFriendsCount: Int + let name: String + let picture: String + let timetable: FriendTimetableWrapper + let username: String + + enum CodingKeys: String, CodingKey { + case campus, email, name, picture, timetable, username + case friendStatus = "friend_status" + case friendsCount = "friends_count" + case mutualFriendsCount = "mutual_friends_count" + } +} + +struct FriendTimetableWrapper: Codable { + let data: TimeTable +} + + +struct TimeTableFromAPI: Codable { + let monday: [Lecture] + let tuesday: [Lecture] + let wednesday: [Lecture] + let thursday: [Lecture] + let friday: [Lecture] + let saturday: [Lecture] + let sunday: [Lecture] + + enum CodingKeys: String, CodingKey { + case monday = "Monday" + case tuesday = "Tuesday" + case wednesday = "Wednesday" + case thursday = "Thursday" + case friday = "Friday" + case saturday = "Saturday" + case sunday = "Sunday" + } +} extension TimeTableAPIService { - func getFriendTimeTable(username: String, authToken: String) async throws -> TimeTable { + func getFriendResponse(username: String, authToken: String) async throws -> FriendResponse { guard let url = URL(string: "\(APIConstants.base_urlv3)users/\(username)") else { throw APIError.networkError } @@ -404,12 +408,9 @@ extension TimeTableAPIService { 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 != 500 else { + throw APIError.serverError(code: "500", message: "Server error") } guard httpResponse.statusCode == 200 else { @@ -418,11 +419,41 @@ extension TimeTableAPIService { } let decoder = JSONDecoder() - return try decoder.decode(TimeTable.self, from: data) + + + var friendResponse = try decoder.decode(FriendResponse.self, from: data) + + + let apiTimeTable = try JSONDecoder().decode(TimeTableFromAPI.self, from: + try JSONEncoder().encode(friendResponse.timetable.data)) + + let convertedTimeTable = TimeTable( + monday: apiTimeTable.monday, + tuesday: apiTimeTable.tuesday, + wednesday: apiTimeTable.wednesday, + thursday: apiTimeTable.thursday, + friday: apiTimeTable.friday, + saturday: apiTimeTable.saturday, + sunday: apiTimeTable.sunday + ) + + + friendResponse = FriendResponse( + campus: friendResponse.campus, + email: friendResponse.email, + friendStatus: friendResponse.friendStatus, + friendsCount: friendResponse.friendsCount, + mutualFriendsCount: friendResponse.mutualFriendsCount, + name: friendResponse.name, + picture: friendResponse.picture, + timetable: FriendTimetableWrapper(data: convertedTimeTable), + username: friendResponse.username + ) + + return friendResponse } } - struct ErrorResponse: Codable { let code: String let error: String diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index cf063ff..e8a2f50 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -13,13 +13,16 @@ 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 base_url = "https://00951cf40596.ngrok-free.app/api/v2/" - static let base_url = "http://68.233.117.217:3000/api/v2/" + static let base_urlv3 = "https://00951cf40596.ngrok-free.app/api/v3/" - static let base_urlv3 = "http://68.233.117.217:3000/api/v3/" +// static let base_url = "http://68.233.117.217:3000/api/v2/" +// +// static let base_urlv3 = "http://68.233.117.217:3000/api/v3/" +// +// static let base_url = "https://50e460598f74.ngrok-fre.app/api/v2/" +// static let base_urlv3 = "https://50e460598f74.ngrok-fre.app/api/v3/" static let createCircle = "circles/create/"