diff --git a/VITTY/ContentView.swift b/VITTY/ContentView.swift index 0da0ef7..498ed34 100644 --- a/VITTY/ContentView.swift +++ b/VITTY/ContentView.swift @@ -20,15 +20,15 @@ struct ContentView: View { var body: some View { Group { - // Check if backend user exists first + if authViewModel.loggedInBackendUser != nil { HomeView() } - // If no backend user but Firebase user exists, show instruction + else if authViewModel.loggedInFirebaseUser != nil { InstructionView() } - // If neither exists, show login + else { LoginView() } diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 7d5cf42..a1ef1e5 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */; }; 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; 4B40E1DA2E27E133004F8447 /* SettingsTololtip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40E1D92E27E12B004F8447 /* SettingsTololtip.swift */; }; + 4B40E1DC2E28D0C0004F8447 /* EmptyTimeTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40E1DB2E28D0B9004F8447 /* EmptyTimeTable.swift */; }; 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; @@ -205,6 +206,7 @@ 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingHotelView.swift; sourceTree = ""; }; 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 4B40E1D92E27E12B004F8447 /* SettingsTololtip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTololtip.swift; sourceTree = ""; }; + 4B40E1DB2E28D0B9004F8447 /* EmptyTimeTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTimeTable.swift; sourceTree = ""; }; 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCode.swift; sourceTree = ""; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = ""; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = ""; }; @@ -863,6 +865,7 @@ 527E3E042B7662760086F23D /* Views */ = { isa = PBXGroup; children = ( + 4B40E1DB2E28D0B9004F8447 /* EmptyTimeTable.swift */, 527E3E072B7662920086F23D /* TimeTableView.swift */, 525F759C2B809F8400E3B418 /* LectureDetailView.swift */, 4B7DA5DB2D708BCD007354A3 /* LectureItemView.swift */, @@ -1160,6 +1163,7 @@ 4B7DA5DC2D708BD3007354A3 /* LectureItemView.swift in Sources */, 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */, 4BC853C32DF693780092B2E2 /* SaveTimeTableView.swift in Sources */, + 4B40E1DC2E28D0C0004F8447 /* EmptyTimeTable.swift in Sources */, 52D5AB892B6FE3B200B2E66D /* AppUser.swift in Sources */, 31128D0C277300470084C9EA /* StringConstants.swift in Sources */, 4B341C102E1803070073906B /* FreindRequestViewModel.swift in Sources */, diff --git a/VITTY/VITTY/Auth/Models/AppUser.swift b/VITTY/VITTY/Auth/Models/AppUser.swift index 2d62991..5309397 100644 --- a/VITTY/VITTY/Auth/Models/AppUser.swift +++ b/VITTY/VITTY/Auth/Models/AppUser.swift @@ -8,17 +8,23 @@ import Foundation class AppUser: Codable { - let name: String - let picture: String - let role: String - let token: String - let username: String - - init(name: String, picture: String, role: String, token: String, username: String) { - self.name = name - self.picture = picture - self.role = role - self.token = token - self.username = username - } + let name: String + let picture: String + let role: String + let token: String + let username: String + let campus: String? + + init(name: String, picture: String, role: String, token: String, username: String, campus: String?) { + self.name = name + self.picture = picture + self.role = role + self.token = token + self.username = username + self.campus = campus + } + + var hasCampus: Bool { + return campus != nil && !campus!.isEmpty + } } diff --git a/VITTY/VITTY/Auth/Models/AuthRequestBody.swift b/VITTY/VITTY/Auth/Models/AuthRequestBody.swift index 102d35f..1b11e51 100644 --- a/VITTY/VITTY/Auth/Models/AuthRequestBody.swift +++ b/VITTY/VITTY/Auth/Models/AuthRequestBody.swift @@ -8,7 +8,8 @@ import Foundation struct AuthRequestBody: Codable { - let uuid: String - let reg_no: String - let username: String + let uuid: String + let reg_no: String + let username: String + let campus: String } diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 2fdcd71..c6b30c0 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -13,55 +13,55 @@ import CryptoKit import FirebaseAuth import Alamofire - - enum LoginOptions { case googleSignIn case appleSignIn } + struct FirebaseAuthRequest: Codable { let uuid: String } + struct FirebaseAuthResponse: Codable { let name: String let picture: String let role: String let token: String let username: String + let campus: String? } + struct AuthError: Codable { let detail: String } enum AuthenticationError: Error, LocalizedError { - case userNotFound(String) - case firebaseAuthFailed - case backendAuthFailed - - var errorDescription: String? { - switch self { - case .userNotFound(let detail): - return detail - case .firebaseAuthFailed: - return "Firebase authentication failed" - case .backendAuthFailed: - return "Backend authentication failed" - } - } - } + case userNotFound(String) + case firebaseAuthFailed + case backendAuthFailed + + var errorDescription: String? { + switch self { + case .userNotFound(let detail): + return detail + case .firebaseAuthFailed: + return "Firebase authentication failed" + case .backendAuthFailed: + return "Backend authentication failed" + } + } +} @Observable class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { var loggedInFirebaseUser: User? var loggedInBackendUser: AppUser? - - var isLoading: Bool = false var isLoadingApple: Bool = false let firebaseAuth = Auth.auth() fileprivate var currentNonce: String? - var isLoadingGoogle: Bool = false + var isLoadingGoogle: Bool = false private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: AuthViewModel.self) @@ -84,111 +84,108 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { if UserDefaults.standard.string(forKey: UserDefaultKeys.tokenKey) != nil { logger.info("Local User Exists") self.loggedInBackendUser = AppUser( - name: UserDefaults.standard.string(forKey: UserDefaultKeys.usernameKey)!, + name: UserDefaults.standard.string(forKey: UserDefaultKeys.nameKey)!, picture: UserDefaults.standard.string(forKey: UserDefaultKeys.pictureKey)!, - role: UserDefaults.standard.string(forKey: UserDefaultKeys.roleKey)!, + role: UserDefaults.standard.string(forKey: UserDefaultKeys.roleKey)!, token: UserDefaults.standard.string(forKey: UserDefaultKeys.tokenKey)!, - username: UserDefaults.standard.string(forKey: UserDefaultKeys.usernameKey)!) + username: UserDefaults.standard.string(forKey: UserDefaultKeys.usernameKey)!, + campus: UserDefaults.standard.string(forKey: UserDefaultKeys.campusKey) + ) } logger.info("Auth Initialisation Complete") } - private func authenticateWithFirebase(uuid: String,url:String) async throws -> FirebaseAuthResponse { - guard let url = URL(string: "\(url)auth/firebase") else { - throw URLError(.badURL) - } + private func authenticateWithFirebase(uuid: String, url: String) async throws -> FirebaseAuthResponse { + guard let url = URL(string: "\(url)auth/firebase") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = FirebaseAuthRequest(uuid: uuid) + request.httpBody = try JSONEncoder().encode(requestBody) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if httpResponse.statusCode == 200 { + return try JSONDecoder().decode(FirebaseAuthResponse.self, from: data) + } else if httpResponse.statusCode == 404 { + let authError = try JSONDecoder().decode(AuthError.self, from: data) + throw AuthenticationError.userNotFound(authError.detail) + } else { + throw URLError(.badServerResponse) + } + } + + private func checkBackendUserExists(uuid: String, url: String) async { + do { + let backendUser = try await authenticateWithFirebase(uuid: uuid, url: url) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + DispatchQueue.main.async { + self.loggedInBackendUser = AppUser( + name: backendUser.name, + picture: backendUser.picture, + role: backendUser.role, + token: backendUser.token, + username: backendUser.username, + campus: backendUser.campus + ) + print("this is the log need to check \(backendUser)") + + UserDefaults.standard.set(backendUser.token, forKey: UserDefaultKeys.tokenKey) + UserDefaults.standard.set(backendUser.username, forKey: UserDefaultKeys.usernameKey) + UserDefaults.standard.set(backendUser.name, forKey: UserDefaultKeys.nameKey) + UserDefaults.standard.set(backendUser.picture, forKey: UserDefaultKeys.pictureKey) + UserDefaults.standard.set(backendUser.role, forKey: UserDefaultKeys.roleKey) + + if let campus = backendUser.campus { + UserDefaults.standard.set(campus, forKey: UserDefaultKeys.campusKey) + } else { + UserDefaults.standard.removeObject(forKey: UserDefaultKeys.campusKey) + } + } - let requestBody = FirebaseAuthRequest(uuid: uuid) - request.httpBody = try JSONEncoder().encode(requestBody) + logger.info("User exists in backend: \(backendUser.username)") - let (data, response) = try await URLSession.shared.data(for: request) + } catch AuthenticationError.userNotFound(let detail) { + logger.info("User not found in backend: \(detail)") - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) + DispatchQueue.main.async { + self.loggedInBackendUser = nil } - - if httpResponse.statusCode == 200 { - - return try JSONDecoder().decode(FirebaseAuthResponse.self, from: data) - } else if httpResponse.statusCode == 404 { - - let authError = try JSONDecoder().decode(AuthError.self, from: data) - throw AuthenticationError.userNotFound(authError.detail) - } else { - throw URLError(.badServerResponse) + } catch { + logger.error("Error checking backend user: \(error)") + DispatchQueue.main.async { + self.loggedInBackendUser = nil } } - private func checkBackendUserExists(uuid: String,url:String) async { - do { - let backendUser = try await authenticateWithFirebase(uuid: uuid,url: url) - - - DispatchQueue.main.async { - self.loggedInBackendUser = AppUser( - name: backendUser.name, - picture: backendUser.picture, - role: backendUser.role, - token: backendUser.token, - username: backendUser.username - ) - print("this is the log need to check \(backendUser)") - - UserDefaults.standard.set(backendUser.token, forKey: UserDefaultKeys.tokenKey) - UserDefaults.standard.set(backendUser.username, forKey: UserDefaultKeys.usernameKey) - UserDefaults.standard.set(backendUser.name, forKey: UserDefaultKeys.nameKey) - UserDefaults.standard.set(backendUser.picture, forKey: UserDefaultKeys.pictureKey) - UserDefaults.standard.set(backendUser.role, forKey: UserDefaultKeys.roleKey) - } - - logger.info("User exists in backend: \(backendUser.username)") - - } catch AuthenticationError.userNotFound(let detail) { - logger.info("User not found in backend: \(detail)") - - DispatchQueue.main.async { - self.loggedInBackendUser = nil - } - } catch { - logger.error("Error checking backend user: \(error)") - DispatchQueue.main.async { - self.loggedInBackendUser = nil - } - } - } - + } - func signInServer(username: String, regNo: String) async { - logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") - do { - - self.loggedInBackendUser = try await AuthAPIService.shared - .signInUser( - with: AuthRequestBody( - uuid: loggedInFirebaseUser?.uid ?? "", - reg_no: regNo, - username: username - ) - ) - - - - } - catch { - logger.error("Signing into server error: \(error)") - } - print("this is kinda empty : \(self.loggedInBackendUser?.name ?? "")") + func signInServer(username: String, regNo: String, campus: String) async throws { + logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") + + self.loggedInBackendUser = try await AuthAPIService.shared + .signInUser( + with: AuthRequestBody( + uuid: loggedInFirebaseUser?.uid ?? "", + reg_no: regNo, + username: username, + campus: campus + ) + ) + + print("this is kinda empty : \(self.loggedInBackendUser?.name ?? "")") logger.info("Signed into server \(self.loggedInBackendUser?.name ?? "empty")") } - - - func login(with loginOptions: LoginOptions) async { logger.info("Loging In...") @@ -216,8 +213,12 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { 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: "") + uuid: self.loggedInFirebaseUser!.uid, + reg_no: "", + username: "", + campus: "" ) + ) UserDefaults.standard.set( loggedInBackendUser!.token, @@ -240,6 +241,12 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { forKey: UserDefaultKeys.roleKey ) + if let campus = loggedInBackendUser!.campus { + UserDefaults.standard.set(campus, forKey: UserDefaultKeys.campusKey) + } else { + UserDefaults.standard.removeObject(forKey: UserDefaultKeys.campusKey) + } + logger.debug("\(UserDefaults.standard.string(forKey: UserDefaultKeys.usernameKey)!)") } else { self.loggedInBackendUser = nil @@ -248,7 +255,6 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.error("Error in logging in: \(error)") return } - } private func signInWithGoogle() async throws { @@ -269,11 +275,8 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.info("Signed in with Google") if let firebaseUser = self.loggedInFirebaseUser { - await checkBackendUserExists(uuid: firebaseUser.uid,url: APIConstants.base_url) - } - - - + await checkBackendUserExists(uuid: firebaseUser.uid, url: APIConstants.base_url) + } } private func signInWithApple() { @@ -291,7 +294,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { authController.performRequests() } - internal func authorizationController ( + internal func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: Error ) { @@ -333,16 +336,13 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { do { try firebaseAuth.signOut() - UserDefaults.resetDefaults() - DispatchQueue.main.async { self.loggedInBackendUser = nil self.loggedInFirebaseUser = nil } - print(self.loggedInBackendUser ?? "the backend user is set to nil ") logger.info("User signed out successfully") @@ -351,14 +351,12 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { logger.error("Error Signing Out: \(error)") } } - - + private func firebaseUserAuthUpdate(with auth: Auth, user: User?) { logger.info("Firebase User Auth State Updated") DispatchQueue.main.async { self.loggedInFirebaseUser = user - if user == nil { self.loggedInBackendUser = nil } @@ -374,7 +372,6 @@ extension UserDefaults { } } - private class AppleSignInUtilties { static func randomNonceString(length: Int = 32) -> String { precondition(length > 0) diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 5a19d32..e041b0f 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -1,14 +1,248 @@ import Foundation +import Alamofire +import OSLog + +extension AppUser: Equatable { + static func == (lhs: AppUser, rhs: AppUser) -> Bool { + return lhs.name == rhs.name && + lhs.username == rhs.username && + lhs.token == rhs.token && + lhs.campus == rhs.campus + } +} + +class CampusUpdateService { + static let shared = CampusUpdateService() + + let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, category: String(describing: CampusUpdateService.self) + ) + + private init() {} + + func updateCampus(campus: String, token: String) async throws { + guard let url = URL(string: "\(APIConstants.base_url)users/campus") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = ["campus": campus] + request.httpBody = try JSONEncoder().encode(requestBody) + + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + print("http status code : \(httpResponse.statusCode)") + print("information : \(httpResponse.description)") + + guard httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + } +} + +// MARK: - Campus Selection Dialog import SwiftUI +struct CampusSelectionDialog: View { + @Binding var isPresented: Bool + @Environment(AuthViewModel.self) private var authViewModel + @State private var selectedCampus: String = "" + @State private var isUpdating: Bool = false + @State private var showError: Bool = false + @State private var errorMessage: String = "" + + + private let campusOptions = [ + ("VIT Chennai", "chennai"), + ("VIT Vellore", "vellore"), + ("VIT Bhopal", "bhopal") + ] + + var body: some View { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + + VStack(spacing: 20) { + + VStack(spacing: 8) { + Text("Select Your Campus") + .font(.custom("Poppins-Bold", size: 20)) + .foregroundColor(.primary) + + Text("Please select your campus to continue") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.secondary) + } + + + VStack(spacing: 12) { + ForEach(campusOptions, id: \.0) { campus in + Button(action: { + selectedCampus = campus.1 + }) { + HStack { + Text(campus.0) + .font(.custom("Poppins-Medium", size: 16)) + .foregroundColor(.primary) + + Spacer() + + if selectedCampus == campus.1 { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selectedCampus == campus.1 ? + Color.blue.opacity(0.1) : Color.gray.opacity(0.1)) + ) + } + } + } + + + if showError { + Text(errorMessage) + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.red) + .padding(.horizontal) + } + + + HStack(spacing: 16) { + Button("Update") { + Task { + await updateCampus() + } + } + .disabled(selectedCampus.isEmpty || isUpdating) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selectedCampus.isEmpty || isUpdating ? + Color.gray.opacity(0.3) : Color.blue) + ) + .foregroundColor(.white) + .font(.custom("Poppins-Medium", size: 16)) + } + + if isUpdating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(UIColor.systemBackground)) + ) + .padding(.horizontal, 40) + } + } + + private func updateCampus() async { + guard !selectedCampus.isEmpty, + let token = authViewModel.loggedInBackendUser?.token else { + return + } + + isUpdating = true + showError = false + + do { + try await CampusUpdateService.shared.updateCampus( + campus: selectedCampus, + token: token + ) + + + DispatchQueue.main.async { + authViewModel.updateUserCampus(selectedCampus) + isPresented = false + } + + } catch { + DispatchQueue.main.async { + showError = true + errorMessage = "Failed to update campus. Please try again." + } + } + + isUpdating = false + } +} + + +extension AuthViewModel { + + var shouldShowCampusDialog: Bool { + guard let backendUser = loggedInBackendUser else { return false } + return backendUser.campus == nil || backendUser.campus?.isEmpty == true + } + + + func updateUserCampus(_ newCampus: String) { + guard let currentUser = loggedInBackendUser else { + + return + } + + + loggedInBackendUser = AppUser( + name: currentUser.name, + picture: currentUser.picture, + role: currentUser.role, + token: currentUser.token, + username: currentUser.username, + campus: newCampus + ) + + + UserDefaults.standard.set(newCampus, forKey: UserDefaultKeys.campusKey) + + } + + func clearCampusInfo() { + guard let currentUser = loggedInBackendUser else { return } + + loggedInBackendUser = AppUser( + name: currentUser.name, + picture: currentUser.picture, + role: currentUser.role, + token: currentUser.token, + username: currentUser.username, + campus: nil + ) + + UserDefaults.standard.removeObject(forKey: UserDefaultKeys.campusKey) + + + } +} + + + struct HomeView: View { @Environment(AuthViewModel.self) private var authViewModel @State private var selectedPage = 1 @State private var showProfileSidebar: Bool = false @State private var isCreatingGroup = false + @State private var showCampusDialog = false @StateObject private var tipManager = CustomTipManager() - @EnvironmentObject private var navigationCoordinator: NavigationCoordinator var body: some View { @@ -17,36 +251,35 @@ struct HomeView: View { BackgroundView() VStack(spacing: 0) { - topBar - - mainContent - Spacer() - BottomBarView(presentTab: $selectedPage) .padding(.bottom, 24) } - profileSidebar + CustomTipOverlay(tipManager: tipManager, selectedTab: $selectedPage) - CustomTipOverlay(tipManager: tipManager, selectedTab: $selectedPage) + if showCampusDialog { + CampusSelectionDialog(isPresented: $showCampusDialog) + } } .ignoresSafeArea(edges: .bottom) .onAppear { setupOnboarding() + checkCampusStatus() } .onChange(of: selectedPage) { _, newValue in handleTabChange(newValue) } - // NEW: Listen for deep link navigation + .onChange(of: authViewModel.loggedInBackendUser?.username) { _, _ in + checkCampusStatus() + } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("NavigateToCircles"))) { _ in - selectedPage = 2 // Navigate to Connects tab + selectedPage = 2 } - // NEW: Handle navigation coordinator changes .onChange(of: navigationCoordinator.shouldNavigateToCircles) { _, shouldNavigate in if shouldNavigate { selectedPage = 2 @@ -55,7 +288,17 @@ struct HomeView: View { } } - // MARK: - Top Bar + + private func checkCampusStatus() { + + if authViewModel.shouldShowCampusDialog { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + showCampusDialog = true + } + } + } + + private var topBar: some View { HStack { Text(pageTitle) @@ -81,7 +324,7 @@ struct HomeView: View { } } - // MARK: - Profile Button + private var profileButton: some View { ZStack { if !showProfileSidebar { @@ -101,7 +344,7 @@ struct HomeView: View { } } - // MARK: - Main Content + private var mainContent: some View { ZStack { switch selectedPage { @@ -118,7 +361,7 @@ struct HomeView: View { .padding(.top, 4) } - // MARK: - Profile Sidebar + @ViewBuilder private var profileSidebar: some View { if showProfileSidebar { @@ -140,9 +383,8 @@ struct HomeView: View { } } - // MARK: - Setup Functions + private func setupOnboarding() { - // Start onboarding if not completed if !tipManager.hasCompletedOnboarding { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { tipManager.startOnboarding() @@ -154,3 +396,17 @@ struct HomeView: View { print("Switched to tab: \(newTab)") } } + + +extension AppUser { + func updatingCampus(_ newCampus: String) -> AppUser { + return AppUser( + name: self.name, + picture: self.picture, + role: self.role, + token: self.token, + username: self.username, + campus: newCampus + ) + } +} diff --git a/VITTY/VITTY/Instruction/Views/InstructionView.swift b/VITTY/VITTY/Instruction/Views/InstructionView.swift index 93f38e3..e73c7fd 100644 --- a/VITTY/VITTY/Instruction/Views/InstructionView.swift +++ b/VITTY/VITTY/Instruction/Views/InstructionView.swift @@ -107,8 +107,4 @@ struct InstructionView: View { } } -#Preview { - InstructionView() - .environment(AuthViewModel()) - .preferredColorScheme(.dark) -} + diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 43674fb..1e122d3 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -167,7 +167,7 @@ struct SettingsView: View { .scrollContentBackground(.hidden) } - // Existing alerts + if showResetAlert { ResetSaturdayAlert( onCancel: { showResetAlert = false }, @@ -203,9 +203,9 @@ struct SettingsView: View { .zIndex(1) } - // Add the tooltip overlay - this will show on top of everything + SettingsTipOverlay(tipManager: settingsTipManager) - .zIndex(2) // Higher z-index to appear above other overlays + .zIndex(2) } .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) @@ -213,7 +213,7 @@ struct SettingsView: View { viewModel.timetable = timeTables.first viewModel.checkNotificationAuthorization() loadSelectedDay() - setupSettingsOnboarding() // Setup tooltip onboarding + setupSettingsOnboarding() } .alert("Notifications Disabled", isPresented: $viewModel.showNotificationDisabledAlert) { Button("OK", role: .cancel) {} @@ -225,7 +225,7 @@ struct SettingsView: View { // MARK: - Settings Tooltip Setup private func setupSettingsOnboarding() { - // Start settings onboarding if not completed, with a slight delay for better UX + if !settingsTipManager.hasCompletedSettingsOnboarding { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { settingsTipManager.startOnboarding() @@ -255,7 +255,7 @@ struct SettingsView: View { await MainActor.run { isSyncing = false - // Check if sync was successful + if syncViewModel.stage == .data { showSyncMessage("Timetable synced successfully!", success: true) } else { @@ -276,7 +276,7 @@ struct SettingsView: View { Task { do { - // Fetch latest timetable from API + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( with: username, authToken: authToken @@ -346,7 +346,7 @@ struct SettingsView: View { } private func preserveSaturdayCustomization(remote: TimeTable, local: TimeTable) -> TimeTable { - // Create new timetable with remote data + let newTimeTable = TimeTable( monday: remote.monday.map { $0.deepCopy() }, tuesday: remote.tuesday.map { $0.deepCopy() }, @@ -357,7 +357,7 @@ struct SettingsView: View { sunday: remote.sunday.map { $0.deepCopy() } ) - // Preserve Saturday customization from local if it exists + if let saturdaySourceDay = local.saturdaySourceDay { print("Preserving Saturday customization from: \(saturdaySourceDay)") @@ -375,7 +375,7 @@ struct SettingsView: View { showSyncAlert = true } - // MARK: - Existing Functions + private func loadSelectedDay() { selectedDay = timeTables.first?.saturdaySourceDay } @@ -772,7 +772,7 @@ struct ResetSaturdayAlert: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .onTapGesture { - // Prevent dismissal on tap + } } } @@ -837,7 +837,7 @@ struct DeleteUserAlert: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .onTapGesture { - // Prevent dismissal on tap + } } } diff --git a/VITTY/VITTY/Shared/UserDefaultKeys.swift b/VITTY/VITTY/Shared/UserDefaultKeys.swift index cf9a859..758174d 100644 --- a/VITTY/VITTY/Shared/UserDefaultKeys.swift +++ b/VITTY/VITTY/Shared/UserDefaultKeys.swift @@ -13,4 +13,5 @@ class UserDefaultKeys { static let nameKey = "name" static let pictureKey = "image" static let roleKey = "role" + static let campusKey = "campus" } diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index d6a8fc9..a19c746 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -57,7 +57,7 @@ class TimeTable: Codable { self.friday = friday self.saturday = saturday self.sunday = sunday - self.saturdaySourceDay = saturdaySourceDay // Set in initializer + self.saturdaySourceDay = saturdaySourceDay } enum CodingKeys: String, CodingKey,Codable { @@ -70,6 +70,7 @@ class TimeTable: Codable { case sunday = "Sunday" } + required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -224,11 +225,11 @@ extension TimeTable { Classes( title: $0.name, time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", - slot: $0.venue // NOTE: Passing venue instead of slot for display purposes + slot: $0.venue ) } - // Sort using the original lecture objects instead of formatted strings + return lectures.sorted { lecture1, lecture2 in guard let time1 = extractStartTime(from: lecture1), let time2 = extractStartTime(from: lecture2) else { @@ -239,7 +240,7 @@ extension TimeTable { Classes( title: $0.name, time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", - slot: $0.venue // NOTE: Passing venue instead of slot for display purposes + slot: $0.venue ) } } diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index 4775855..a41a0a7 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -15,6 +15,7 @@ public enum Stage { case loading case error case data + case empty } extension TimeTableView { @@ -25,6 +26,7 @@ extension TimeTableView { var stage: Stage = .loading var lectures = [Lecture]() var dayNo = Date.convertToMondayWeek() + var isEmpty: Bool = false private var networkMonitor = NetworkMonitor() @@ -59,38 +61,56 @@ extension TimeTableView { } private func forceRefreshCurrentDay() { - logger.info("Forcing refresh of current day due to timetable change") - - - changeDay() - - - let currentStage = stage - stage = .loading - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - self.stage = currentStage - } - } + logger.info("Forcing refresh of current day due to timetable change") + + changeDay() + + let currentStage = stage + stage = .loading + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.stage = currentStage + } + } - @MainActor - func refreshFromDatabase(_ updatedTimeTable: TimeTable?) { - logger.info("Refreshing from database") - - guard let updatedTimeTable = updatedTimeTable else { - self.timeTable = nil - self.lectures = [] - self.stage = .error - logger.error("No updated timetable provided") - return - } - - self.timeTable = updatedTimeTable - changeDay() - self.stage = .data - - logger.info("Timetable refreshed from database successfully") - } + @MainActor + func refreshFromDatabase(_ updatedTimeTable: TimeTable?) { + logger.info("Refreshing from database") + + guard let updatedTimeTable = updatedTimeTable else { + self.timeTable = nil + self.lectures = [] + self.stage = .empty + logger.error("No updated timetable provided") + return + } + + + if isTimeTableEmpty(updatedTimeTable) { + self.timeTable = updatedTimeTable + self.lectures = [] + self.stage = .empty + logger.info("Timetable is empty") + return + } + + self.timeTable = updatedTimeTable + changeDay() + self.stage = .data + + logger.info("Timetable refreshed from database successfully") + } + + + 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 + } func changeDay() { guard let timeTable = timeTable else { @@ -120,18 +140,25 @@ extension TimeTableView { ) async { logger.info("Starting local-first timetable loading process") - // Step 1: Check if we have local data if let existingTimeTable = existingTimeTable { - logger.info("Found local timetable data - using it") + logger.info("Found local timetable data - checking if empty") + + + if isTimeTableEmpty(existingTimeTable) { + self.timeTable = existingTimeTable + self.lectures = [] + self.stage = .empty + logger.info("Local timetable is empty") + return + } + useLocalData(existingTimeTable: existingTimeTable) return } - // Step 2: No local data exists - fetch from API and save logger.info("No local timetable found - fetching from API") stage = .loading - // Only fetch if we have credentials if !username.isEmpty && !authToken.isEmpty { await fetchAndSaveFromAPI( username: username, @@ -144,7 +171,7 @@ extension TimeTableView { } } - // MARK: - Use Local Data (Always prioritized) + // MARK: - Use Local Data @MainActor private func useLocalData(existingTimeTable: TimeTable) { logger.info("Using local timetable data") @@ -167,13 +194,27 @@ extension TimeTableView { authToken: authToken ) - // Save to local storage + + if isTimeTableEmpty(remoteTimeTable) { + logger.info("API returned empty timetable") + self.timeTable = remoteTimeTable + self.lectures = [] + self.stage = .empty + + + await saveToLocalStorage( + newTimeTable: remoteTimeTable, + context: context + ) + return + } + + await saveToLocalStorage( newTimeTable: remoteTimeTable, context: context ) - - // Update UI + self.timeTable = remoteTimeTable changeDay() stage = .data @@ -193,13 +234,13 @@ extension TimeTableView { context: ModelContext ) async { do { - // Insert new timetable + context.insert(newTimeTable) try context.save() logger.info("Successfully saved timetable to local storage") - // Notify other components + NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil @@ -211,7 +252,7 @@ extension TimeTableView { } } - // MARK: - Force Sync (Explicit user action) + // MARK: - Force Sync @MainActor func forceSync( username: String, @@ -220,13 +261,13 @@ extension TimeTableView { ) async { logger.info("Force syncing timetable from server") - // Set loading state + stage = .loading - // Get existing timetable for Saturday preservation + let existingTimeTable = timeTable - // Force fetch from API + if !username.isEmpty && !authToken.isEmpty { await fetchAndUpdateFromAPI( existingTimeTable: existingTimeTable, @@ -240,7 +281,7 @@ extension TimeTableView { } } - // MARK: - Fetch and Update from API (for sync) + // MARK: - Fetch and Update from API @MainActor private func fetchAndUpdateFromAPI( existingTimeTable: TimeTable?, @@ -255,20 +296,36 @@ extension TimeTableView { authToken: authToken ) - // Preserve Saturday customization if it exists + + if isTimeTableEmpty(remoteTimeTable) { + logger.info("API returned empty timetable during sync") + self.timeTable = remoteTimeTable + self.lectures = [] + self.stage = .empty + + + await updateLocalStorage( + newTimeTable: remoteTimeTable, + oldTimeTable: existingTimeTable, + context: context + ) + return + } + + let finalTimeTable = preserveSaturdayCustomization( remote: remoteTimeTable, local: existingTimeTable ) - // Update local storage + await updateLocalStorage( newTimeTable: finalTimeTable, oldTimeTable: existingTimeTable, context: context ) - // Update UI + self.timeTable = finalTimeTable changeDay() stage = .data @@ -289,18 +346,18 @@ extension TimeTableView { context: ModelContext ) async { do { - // Remove old timetable if it exists + if let oldTimeTable = oldTimeTable { context.delete(oldTimeTable) } - // Insert new timetable + context.insert(newTimeTable) try context.save() logger.info("Successfully updated local storage") - // Notify other components + NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil @@ -310,7 +367,7 @@ extension TimeTableView { logger.error("Failed to update local storage: \(error.localizedDescription)") context.rollback() - // Re-insert old timetable if it existed + if let oldTimeTable = oldTimeTable { context.insert(oldTimeTable) try? context.save() @@ -318,12 +375,12 @@ extension TimeTableView { } } - // MARK: - Preserve Saturday Customization + private func preserveSaturdayCustomization( remote: TimeTable, local: TimeTable? ) -> TimeTable { - // Create new timetable with remote data + let newTimeTable = TimeTable( monday: remote.monday.map { $0.deepCopy() }, tuesday: remote.tuesday.map { $0.deepCopy() }, @@ -334,7 +391,7 @@ extension TimeTableView { sunday: remote.sunday.map { $0.deepCopy() } ) - // Preserve Saturday customization from local if it exists + if let local = local, let saturdaySourceDay = local.saturdaySourceDay { logger.info("Preserving Saturday customization from: \(saturdaySourceDay)") @@ -347,6 +404,7 @@ extension TimeTableView { return newTimeTable } + // MARK: - Saturday Management @MainActor func copyLecturesToSaturday( diff --git a/VITTY/VITTY/TimeTable/Views/EmptyTimeTable.swift b/VITTY/VITTY/TimeTable/Views/EmptyTimeTable.swift new file mode 100644 index 0000000..06b6955 --- /dev/null +++ b/VITTY/VITTY/TimeTable/Views/EmptyTimeTable.swift @@ -0,0 +1,105 @@ +// +// EmptyTimeTable.swift +// VITTY +// +// Created by Rujin Devkota on 7/17/25. +// + +import SwiftUI + +struct EmptyTimetableView: View { + @Environment(AuthViewModel.self) private var authViewModel + @State private var showingInstructionView = false + + + let onReload: () -> Void + let isRefreshing: Bool + + + init(onReload: @escaping () -> Void = {}, isRefreshing: Bool = false) { + self.onReload = onReload + self.isRefreshing = isRefreshing + } + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "calendar.badge.plus") + .font(.system(size: 60)) + .foregroundColor(Color("Accent")) + .padding(.bottom, 8) + + Text("No Timetable Found") + .font(Font.custom("Poppins-Bold", size: 24)) + .foregroundColor(.primary) + + Text("It looks like you haven't uploaded your timetable yet. Upload it on our website to get started!") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + VStack(spacing: 12) { + Button(action: { + if let url = URL(string: "https://dscv.it/vittyconnect") { + UIApplication.shared.open(url) + } + }) { + HStack { + Image(systemName: "safari") + Text("Upload Timetable") + } + .foregroundColor(.black) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Accent")) + .cornerRadius(12) + } + + // Add reload button + Button(action: { + onReload() + }) { + HStack { + if isRefreshing { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(Color("Accent")) + } else { + Image(systemName: "arrow.clockwise") + } + Text(isRefreshing ? "Checking..." : "Check Again") + } + .foregroundColor(Color("Accent")) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Secondary")) + .cornerRadius(12) + } + .disabled(isRefreshing) + + Button(action: { + showingInstructionView = true + }) { + HStack { + Image(systemName: "info.circle") + Text("View Instructions") + } + .foregroundColor(Color("Accent")) + .padding() + .frame(maxWidth: .infinity) + .background(Color("Secondary")) + .cornerRadius(12) + } + } + .padding(.horizontal, 32) + .padding(.top, 16) + + Spacer() + } + .sheet(isPresented: $showingInstructionView) { + InstructionView() + } + } +} diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index a091267..709582e 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -87,87 +87,108 @@ struct TimeTableView: View { Spacer() } + case .empty: + // Show empty timetable view with reload functionality + EmptyTimetableView( + onReload: { + Task { + await refreshTimetable() + } + }, + isRefreshing: isRefreshing + ) case .data: - 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) + 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) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .id(day) + } } + .padding(.horizontal, 8) } - .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) + .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) + .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) + if viewModel.lectures.isEmpty { + Spacer() + VStack(spacing: 16) { + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) .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 + + 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) } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 100) } } } @@ -209,8 +230,8 @@ struct TimeTableView: View { if newPhase == .active { viewModel.resetSyncStatus() - // Reload if in error state - if viewModel.stage == .error { + // Reload if in error state or empty state + if viewModel.stage == .error || viewModel.stage == .empty { loadTimetable() } } @@ -220,11 +241,9 @@ struct TimeTableView: View { private func loadTimetable() { logger.debug("Loading timetable with local-first approach") - let calendar = Calendar.current let today = calendar.component(.weekday, from: Date()) - let dayIndex = (today == 1) ? 6 : today - 2 if dayIndex >= 0 && dayIndex < daysOfWeek.count { @@ -233,7 +252,6 @@ struct TimeTableView: View { viewModel.dayNo = 0 } - Task { await viewModel.loadTimeTable( existingTimeTable: timetableItem.first, diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 6833873..886c3b7 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -41,10 +41,13 @@ struct UserProfileSidebar: View { Divider().background(Color.clear) - NavigationLink { - EmptyClassRoom() - } label: { - MenuOption(icon: "emptyclassroom", title: "Find Empty Classroom") + if(authViewModel.loggedInBackendUser?.campus == "vellore"){ + + NavigationLink { + EmptyClassRoom() + } label: { + MenuOption(icon: "emptyclassroom", title: "Find Empty Classroom") + } } NavigationLink { diff --git a/VITTY/VITTY/Username/Views/UsernameView.swift b/VITTY/VITTY/Username/Views/UsernameView.swift index 4aa7ef9..146bf7e 100644 --- a/VITTY/VITTY/Username/Views/UsernameView.swift +++ b/VITTY/VITTY/Username/Views/UsernameView.swift @@ -1,5 +1,5 @@ // -// Usernameview.swift +// UsernameView.swift // VITTY // // Created by Chandram Dutta on 05/02/24. @@ -10,141 +10,383 @@ import SwiftUI struct UsernameView: View { @State private var username = "" @State private var regNo = "" + @State private var selectedCampus = "vellore" @State private var userNameErrorString = "" @State private var regNoErrorString = "" @State private var usernameError = true @State private var regNoError = true - @State private var isLoading = false + @State private var isCheckingExistingUser = true + @State private var userExists = false + + @State private var showAlert = false + @State private var alertTitle = "" + @State private var alertMessage = "" @Environment(AuthViewModel.self) private var authViewModel @Environment(\.dismiss) private var dismiss + + + private let campusOptions = [ + ("VIT Vellore", "vellore"), + ("VIT Chennai", "chennai"), + ("VIT Bhopal", "bhopal") + ] var body: some View { NavigationStack { ZStack { BackgroundView() - VStack(alignment: .leading) { - headerView + + if isCheckingExistingUser { + - Text("Enter username and your registration number below.") - .font(.footnote) - .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: username) { _, _ in - checkUserExists { result in - switch result { - case .success(let exists): - - if exists { + VStack(spacing: 20) { + ProgressView() + .tint(.white) + .scaleEffect(1.1) + + Text("Checking your account...") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + } + } else if userExists { + + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 80)) + .foregroundColor(.green) + + VStack(spacing: 12) { + Text("Welcome back!") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + + Text("Your account has been found and you're ready to go.") + .font(.body) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + } + + NavigationLink(destination: HomeView()) { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + .frame(height: 54) + + Text("Continue to Home") + .fontWeight(.bold) + .foregroundColor(.black) + .font(.system(size: 18)) + } + } + .padding(.top, 20) + } + .padding(.horizontal, 20) + } else { + + VStack(alignment: .leading, spacing: 20) { + headerView + + VStack(alignment: .leading, spacing: 4) { + Text("Enter username, registration number, and select your campus below.") + .font(.footnote) + .foregroundColor(.white.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: username) { _, _ in + checkUserExists { result in + switch result { + case .success(let exists): + if exists { + usernameError = true + } else { + usernameError = false + } + case .failure(_): + userNameErrorString = "An error occurred. Please try again." usernameError = true } - else { - usernameError = false - } - case .failure(_): - userNameErrorString = "An error occurred. Please try again." - usernameError = true + } + } + + Text("Your username will help your friends find you!") + .font(.footnote) + .foregroundColor(.white.opacity(0.7)) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: regNo) { _, _ in + let regex = #"^\d{2}[A-Za-z]{3}\d{4}$"# + let regNoTest = NSPredicate(format: "SELF MATCHES %@", regex) + if regNoTest.evaluate(with: regNo) { + regNoErrorString = "" + regNoError = false + } else { + regNoErrorString = "Please enter a valid registration number." + regNoError = true + } } + } + + + VStack(alignment: .leading, spacing: 8) { + Text("Username") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.white) + + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.white.opacity(0.1)) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + .frame(height: 50) + + TextField("Enter your username", text: $username) + .padding(.horizontal, 16) + .foregroundColor(.white) + .font(.system(size: 16)) + } + + if !userNameErrorString.isEmpty { + Text(userNameErrorString) + .font(.caption) + .foregroundStyle(.red) } } - Text("Your username will help your friends find you!") - .font(.footnote) - .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: regNo) { _, _ in - let regex = #"^\d{2}[A-Za-z]{3}\d{4}$"# - let regNoTest = NSPredicate(format: "SELF MATCHES %@", regex) - if regNoTest.evaluate(with: regNo) { - regNoErrorString = "" - regNoError = false + + + VStack(alignment: .leading, spacing: 8) { + Text("Registration Number") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.white) + + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.white.opacity(0.1)) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + .frame(height: 50) + + TextField("e.g., 21BCE1234", text: $regNo) + .padding(.horizontal, 16) + .foregroundColor(.white) + .font(.system(size: 16)) } - else { - regNoErrorString = "Please enter a valid registration number." - regNoError = true + + if !regNoErrorString.isEmpty { + Text(regNoErrorString) + .font(.caption) + .foregroundStyle(.red) } } - ZStack { - TextField("Username", text: $username) - .padding() - } - - .cornerRadius(18) - .padding(.top) - Text(userNameErrorString) - .font(.footnote) - .foregroundStyle(.red) - ZStack { - TextField("Registration No.", text: $regNo) - .padding() - } - - .cornerRadius(18) - .padding(.top) - Text(regNoErrorString) - .font(.footnote) - .foregroundStyle(.red) - Spacer() - - Button(action: { - print("is this button being ") - Task { - - - isLoading = true - await authViewModel.signInServer(username: username, regNo: regNo) - isLoading = false + + + VStack(alignment: .leading, spacing: 8) { + Text("Campus") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.white) + + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.white.opacity(0.1)) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + .frame(height: 50) + + HStack { + Menu { + ForEach(campusOptions, id: \.1) { campus in + Button(action: { + selectedCampus = campus.1 + }) { + HStack { + Text(campus.0) + .foregroundColor(.primary) + if selectedCampus == campus.1 { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + } + } label: { + HStack { + Text(campusOptions.first(where: { $0.1 == selectedCampus })?.0 ?? "Select Campus") + .foregroundColor(.white) + .font(.system(size: 16)) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.white.opacity(0.7)) + .font(.system(size: 14)) + } + .padding(.horizontal, 16) + } + } + } } - }) { - if isLoading { - Spacer() - ProgressView() - .padding(.vertical, 16) + Spacer() - - } - else { - Spacer() - Text("Done") - .fontWeight(.bold) - .foregroundColor(Color.white) - .padding(.vertical, 16) - Spacer() + + + Button(action: { + print("is this button being pressed") + Task { + isLoading = true + + do { + try await authViewModel.signInServer(username: username, regNo: regNo, campus: selectedCampus) + } catch { + alertTitle = "Sign In Error" + alertMessage = "Unable to sign in. Please contact VITTY support for assistance." + showAlert = true + print("Sign in error: \(error)") + } + + isLoading = false + } + }) { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill( + (usernameError || regNoError || isLoading || username.isEmpty || regNo.isEmpty) ? + Color.white.opacity(0.3) : + Color.white + ) + .frame(height: 54) + + if isLoading { + ProgressView() + .tint(Color.black) + } else { + Text("Done") + .fontWeight(.bold) + .foregroundColor( + (usernameError || regNoError || isLoading || username.isEmpty || regNo.isEmpty) ? + Color.white.opacity(0.7) : + Color.black + ) + .font(.system(size: 18)) + } + } } + .disabled(usernameError || regNoError || isLoading || username.isEmpty || regNo.isEmpty) + .padding(.bottom, 20) } - - - .cornerRadius(18) + .padding(.horizontal, 20) } - .padding(.horizontal) - } .navigationBarBackButtonHidden(true) + .alert(alertTitle, isPresented: $showAlert) { + Button("OK") { + showAlert = false + } + } message: { + Text(alertMessage) + } + .onAppear { + checkExistingUser() + } } .accentColor(.white) } + private var headerView: some View { - VStack{ + VStack(spacing: 16) { HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") .foregroundColor(.white) - .font(.title2) + .font(.title3) + .fontWeight(.medium) + .frame(width: 40, height: 40) } Spacer() - - } - - .padding(.top) - - HStack{ - Text("Let's Sign you in ") - .font(.title) + + HStack { + Text("Let's Sign you in") + .font(.largeTitle) .fontWeight(.bold) .foregroundColor(.white) Spacer() - }.padding([.top,.bottom]) + } + } + .padding(.top, 10) + } + + private func checkExistingUser() { + guard let firebaseUUID = authViewModel.loggedInFirebaseUser?.uid else { + isCheckingExistingUser = false + return + } + + Task { + do { + + let backendUser = try await authenticateWithFirebase(uuid: firebaseUUID) + + + DispatchQueue.main.async { + self.authViewModel.loggedInBackendUser = AppUser( + name: backendUser.name, + picture: backendUser.picture, + role: backendUser.role, + token: backendUser.token, + username: backendUser.username, + campus: backendUser.campus + ) + + + UserDefaults.standard.set(backendUser.token, forKey: UserDefaultKeys.tokenKey) + UserDefaults.standard.set(backendUser.username, forKey: UserDefaultKeys.usernameKey) + UserDefaults.standard.set(backendUser.name, forKey: UserDefaultKeys.nameKey) + UserDefaults.standard.set(backendUser.picture, forKey: UserDefaultKeys.pictureKey) + UserDefaults.standard.set(backendUser.role, forKey: UserDefaultKeys.roleKey) + + if let campus = backendUser.campus { + UserDefaults.standard.set(campus, forKey: UserDefaultKeys.campusKey) + } else { + UserDefaults.standard.removeObject(forKey: UserDefaultKeys.campusKey) + } + + self.userExists = true + self.isCheckingExistingUser = false + } + } catch { + + DispatchQueue.main.async { + self.userExists = false + self.isCheckingExistingUser = false + } + } + } + } + + private func authenticateWithFirebase(uuid: String) async throws -> FirebaseAuthResponse { + guard let url = URL(string: "\(Constants.url)auth/firebase") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = FirebaseAuthRequest(uuid: uuid) + request.httpBody = try JSONEncoder().encode(requestBody) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if httpResponse.statusCode == 200 { + return try JSONDecoder().decode(FirebaseAuthResponse.self, from: data) + } else { + throw URLError(.badServerResponse) } } @@ -153,34 +395,42 @@ struct UsernameView: View { completion(.failure(AuthAPIServiceError.invalidUrl)) return } + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") + do { let encoder = JSONEncoder() request.httpBody = try encoder.encode(["username": username]) - } - catch { + } catch { completion(.failure(error)) return } + let task = URLSession.shared.dataTask(with: request) { data, response, error in guard let data = data else { completion(.failure(AuthAPIServiceError.invalidUrl)) return } + guard let response = response as? HTTPURLResponse else { return } + if response.statusCode == 200 { - userNameErrorString = "" + DispatchQueue.main.async { + userNameErrorString = "" + } completion(.success(false)) - } - else { + } else { do { let res = try JSONDecoder().decode([String: String].self, from: data) - userNameErrorString = res["detail"]! - } - catch { - completion(.success(true)) + DispatchQueue.main.async { + userNameErrorString = res["detail"] ?? "Username already exists" + } + } catch { + DispatchQueue.main.async { + userNameErrorString = "Username already exists" + } } completion(.success(true)) } @@ -188,4 +438,3 @@ struct UsernameView: View { task.resume() } } -