diff --git a/VITTY/GoogleService-Info.plist b/VITTY/GoogleService-Info.plist deleted file mode 100644 index 57973b4..0000000 --- a/VITTY/GoogleService-Info.plist +++ /dev/null @@ -1,36 +0,0 @@ - - - - - CLIENT_ID - 272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63.apps.googleusercontent.com - REVERSED_CLIENT_ID - com.googleusercontent.apps.272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63 - ANDROID_CLIENT_ID - 272763363329-143lqjkb0i5a75lc0iglc26jlb61po0c.apps.googleusercontent.com - API_KEY - AIzaSyCJYYDMdzQiNiY0pxqbrglEw85BSlGgHBc - GCM_SENDER_ID - 272763363329 - PLIST_VERSION - 1 - BUNDLE_ID - com.gdscvit.vittyios - PROJECT_ID - vitty-dscvit - STORAGE_BUCKET - vitty-dscvit.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:272763363329:ios:3b020b67f7527e83e2e000 - - \ No newline at end of file diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index ae0e5f5..4fdd5ae 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; 4B1BDBCC2E1396B1008C2DE9 /* ToolTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */; }; + 4B2D648F2E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; + 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; + 4B2D64922E20C1AC00412CB7 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */; }; 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */; }; 4B341C0E2E1802910073906B /* FreindRequestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */; }; 4B341C102E1803070073906B /* FreindRequestViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */; }; @@ -37,7 +40,6 @@ 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; - 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; @@ -190,6 +192,8 @@ 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = ""; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = ""; }; 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolTip.swift; sourceTree = ""; }; + 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = ""; }; 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestModel.swift; sourceTree = ""; }; 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestViewModel.swift; sourceTree = ""; }; @@ -199,7 +203,6 @@ 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCode.swift; sourceTree = ""; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = ""; }; - 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = ""; }; 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseFile.swift; sourceTree = ""; }; 4B74D8762E0BF77400B390E9 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; @@ -401,7 +404,7 @@ isa = PBXGroup; children = ( 4BC853C52DF6F71B0092B2E2 /* VittyWidgetExtension.entitlements */, - 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */, + 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */, 52EE849D2CB9CD1F00CD864C /* GoogleService-Info.plist */, 5251A7FF2B46E3C000D44CFE /* .swift-format */, 314A408E27383BEC0058082F /* VITTYApp.swift */, @@ -865,6 +868,7 @@ 528CF1712B769AF2007298A0 /* Models */ = { isa = PBXGroup; children = ( + 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */, 528CF1722B769B18007298A0 /* TimeTable.swift */, ); path = Models; @@ -1096,7 +1100,7 @@ 31128CFA2772F57E0084C9EA /* Poppins-SemiBoldItalic.ttf in Resources */, 31128CFC2772F57E0084C9EA /* Poppins-Regular.ttf in Resources */, 52EE849E2CB9CD1F00CD864C /* GoogleService-Info.plist in Resources */, - 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */, + 4B2D64922E20C1AC00412CB7 /* GoogleService-Info.plist in Resources */, 314A409627383BEE0058082F /* Preview Assets.xcassets in Resources */, 314A409327383BEE0058082F /* Assets.xcassets in Resources */, ); @@ -1123,6 +1127,7 @@ 4B7DA5DF2D7094E8007354A3 /* Academics.swift in Sources */, 4B341C0E2E1802910073906B /* FreindRequestModel.swift in Sources */, 4B7DA5F22D7228F9007354A3 /* JoinGroup.swift in Sources */, + 4B2D648F2E20BA6300412CB7 /* NetworkMonitor.swift in Sources */, 524B842F2B46EBBD006D18BD /* HomeView.swift in Sources */, 527E3E082B7662920086F23D /* TimeTableView.swift in Sources */, 524B843A2B46F5C6006D18BD /* AddFriendsView.swift in Sources */, @@ -1213,6 +1218,7 @@ files = ( 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */, 4BC853C42DF6DA7A0092B2E2 /* TimeTable.swift in Sources */, + 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */, 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, ); @@ -1264,7 +1270,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 05a21c3..2fdcd71 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -136,7 +136,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { 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) @@ -271,8 +271,9 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { if let firebaseUser = self.loggedInFirebaseUser { await checkBackendUserExists(uuid: firebaseUser.uid,url: APIConstants.base_url) } - - + + + } private func signInWithApple() { diff --git a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift index 8215cb2..86231d6 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift @@ -4,10 +4,10 @@ // // Created by Rujin Devkota on 2/27/25. // -import SwiftUI import SwiftUI + struct CirclesView: View { @Binding var isCreatingGroup: Bool @State private var searchText = "" diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 045b66f..eb744a2 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -372,6 +372,7 @@ class CommunityPageViewModel { let detail: String } + func createCircle(name: String, token: String, completion: @escaping (Result) -> Void) { guard let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { @@ -390,20 +391,21 @@ class CommunityPageViewModel { case .success(let data): if data.detail.lowercased().contains("successfully") { self.logger.info("Successfully created circle: \(name)") - completion(.success(name)) + + // Now fetch the updated circles data and wait for completion + self.fetchCircleDataWithCompletion( + from: "\(APIConstants.base_url)circles", + token: token, + circleName: name, + completion: completion + ) + } else { let error = NSError(domain: "CreateCircleError", code: 1, userInfo: [NSLocalizedDescriptionKey: data.detail]) self.logger.error("Error creating circle: \(data.detail)") completion(.failure(error)) } - - self.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: token, - loading: false - ) - case .failure(let error): self.logger.error("Error creating circle: \(error)") completion(.failure(error)) @@ -411,6 +413,37 @@ class CommunityPageViewModel { } } } + + + private func fetchCircleDataWithCompletion(from url: String, token: String, circleName: String, completion: @escaping (Result) -> Void) { + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: CircleResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + self.circles = data.data + self.errorCircle = false + print("Successfully fetched circles after creation: \(data.data)") + + + if let createdCircle = self.circles.first(where: { $0.circleName == circleName }) { + print("Found created circle with ID: \(createdCircle.circleID)") + completion(.success(createdCircle.circleID)) + } else { + let error = NSError(domain: "CreateCircleError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not find created circle in updated data"]) + self.logger.error("Could not find created circle: \(circleName)") + completion(.failure(error)) + } + + case .failure(let error): + self.logger.error("Error fetching circles after creation: \(error)") + completion(.failure(error)) + } + } + } + } func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { diff --git a/VITTY/VITTY/Info.plist b/VITTY/VITTY/Info.plist index 5299857..d1aca8e 100644 --- a/VITTY/VITTY/Info.plist +++ b/VITTY/VITTY/Info.plist @@ -13,7 +13,7 @@ Google SignIn CFBundleURLSchemes - com.googleusercontent.apps.272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63 + com.googleusercontent.apps.266303676876-77duk9tr18717lspccrvjuqcnuv0dp2s @@ -30,7 +30,7 @@ FirebaseAppDelegateProxyEnabled GIDClientID - 272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63.apps.googleusercontent.com + 266303676876-77duk9tr18717lspccrvjuqcnuv0dp2s.apps.googleusercontent.com LSApplicationQueriesSchemes comgooglemaps diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index d6a3bfc..b66f4db 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -14,6 +14,10 @@ struct SettingsView: View { @State private var showResetAlert = false @State private var showDeleteUserAlert = false @State private var isDeletingUser = false + @State private var isSyncing = false + @State private var showSyncAlert = false + @State private var syncMessage = "" + @State private var syncSuccess = false private let selectedDayKey = "SelectedSaturdayDay" @@ -45,6 +49,36 @@ struct SettingsView: View { } } + SettingsSectionView(title: "Timetable Management") { + VStack(alignment: .leading, spacing: 12) { + Button { + syncTimetable() + } label: { + SettingsRowView( + icon: "arrow.clockwise.circle.fill", + title: "Sync Timetable", + subtitle: isSyncing ? "Syncing..." : "Update timetable from server", + isLoading: isSyncing + ) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isSyncing) + + Button { + if let url = URL(string: "https://vitty.dscvit.com") { + UIApplication.shared.open(url) + } + } label: { + SettingsRowView( + icon: "pencil.and.ellipsis.rectangle", + title: "Update Timetable Online", + subtitle: "Modify your timetable on the web portal" + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + SettingsSectionView(title: "Class Settings") { VStack(alignment: .leading, spacing: 12) { Button { @@ -94,19 +128,6 @@ struct SettingsView: View { ) } .buttonStyle(PlainButtonStyle()) - - Button { - if let url = URL(string: "https://vitty.dscvit.com") { - UIApplication.shared.open(url) - } - } label: { - SettingsRowView( - icon: "pencil.and.ellipsis.rectangle", - title: "Update Timetable", - subtitle: "Keep your timetable up-to-date. Don't miss a class." - ) - } - .buttonStyle(PlainButtonStyle()) } } @@ -168,6 +189,17 @@ struct SettingsView: View { ) .zIndex(1) } + + if showSyncAlert { + SyncAlert( + message: syncMessage, + isSuccess: syncSuccess, + onDismiss: { + showSyncAlert = false + } + ) + .zIndex(1) + } } .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) @@ -175,7 +207,6 @@ struct SettingsView: View { viewModel.timetable = timeTables.first viewModel.checkNotificationAuthorization() loadSelectedDay() - print("Saturday before save:", timeTables.first?.saturday.map { $0.name } ?? []) } .alert("Notifications Disabled", isPresented: $viewModel.showNotificationDisabledAlert) { Button("OK", role: .cancel) {} @@ -185,6 +216,163 @@ struct SettingsView: View { } } + // MARK: - Sync Timetable Functions + + // MARK: - Updated Sync Timetable Functions for SettingsView + + private func syncTimetable() { + guard let username = authViewModel.loggedInBackendUser?.username, + let authToken = authViewModel.loggedInBackendUser?.token else { + showSyncMessage("Unable to sync: No authentication credentials", success: false) + return + } + + isSyncing = true + + Task { + + let syncViewModel = TimeTableView.TimeTableViewModel() + + await syncViewModel.forceSync( + username: username, + authToken: authToken, + context: modelContext + ) + + await MainActor.run { + isSyncing = false + + // Check if sync was successful + if syncViewModel.stage == .data { + showSyncMessage("Timetable synced successfully!", success: true) + } else { + showSyncMessage("Sync failed. Please try again.", success: false) + } + } + } + } + + + private func syncTimetableAlternative() { + guard let username = authViewModel.loggedInBackendUser?.username, + let authToken = authViewModel.loggedInBackendUser?.token else { + showSyncMessage("Unable to sync: No authentication credentials", success: false) + return + } + + isSyncing = true + + Task { + do { + // Fetch latest timetable from API + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + await MainActor.run { + updateLocalTimetable(with: remoteTimeTable) + } + + } catch { + await MainActor.run { + isSyncing = false + showSyncMessage("Sync failed: \(error.localizedDescription)", success: false) + } + } + } + } + + private func updateLocalTimetable(with remoteTimeTable: TimeTable) { + guard let currentTimeTable = timeTables.first else { + + insertNewTimetable(remoteTimeTable) + return + } + + + let finalTimeTable = preserveSaturdayCustomization( + remote: remoteTimeTable, + local: currentTimeTable + ) + + do { + + modelContext.delete(currentTimeTable) + + + modelContext.insert(finalTimeTable) + + + try modelContext.save() + + isSyncing = false + showSyncMessage("Timetable synced successfully!", success: true) + + + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + + } catch { + isSyncing = false + showSyncMessage("Failed to save synced timetable: \(error.localizedDescription)", success: false) + modelContext.rollback() + } + } + + private func insertNewTimetable(_ timeTable: TimeTable) { + do { + modelContext.insert(timeTable) + try modelContext.save() + + isSyncing = false + showSyncMessage("Timetable synced successfully!", success: true) + + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + + } catch { + isSyncing = false + showSyncMessage("Failed to save new timetable: \(error.localizedDescription)", success: false) + } + } + + 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() }, + wednesday: remote.wednesday.map { $0.deepCopy() }, + thursday: remote.thursday.map { $0.deepCopy() }, + friday: remote.friday.map { $0.deepCopy() }, + saturday: remote.saturday.map { $0.deepCopy() }, + 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)") + + let lecturesToCopy = newTimeTable.lectures(forDay: saturdaySourceDay) + newTimeTable.saturday = lecturesToCopy.map { $0.deepCopy() } + newTimeTable.saturdaySourceDay = saturdaySourceDay + } + + return newTimeTable + } + + private func showSyncMessage(_ message: String, success: Bool) { + syncMessage = message + syncSuccess = success + showSyncAlert = true + } + + // MARK: - Existing Functions + private func loadSelectedDay() { selectedDay = timeTables.first?.saturdaySourceDay } @@ -202,7 +390,6 @@ struct SettingsView: View { try await deleteUserFromServer(username: username) await MainActor.run { - Task { await cleanupLocalData() authViewModel.signOut() @@ -242,7 +429,6 @@ struct SettingsView: View { private func cleanupLocalData() async { do { - await Task.detached { [modelContext] in do { try modelContext.delete(model: TimeTable.self) @@ -261,65 +447,75 @@ struct SettingsView: View { } - private func copyLecturesToSaturday(from day: String) { guard let currentTimeTable = timeTables.first else { print("No timetable found") return } - + print("Starting SAFE copy from \(day) to Saturday - DELETE & RECREATE approach") - let lecturesToCopy = currentTimeTable.lectures(forDay: day) - print("Found \(lecturesToCopy.count) lectures to copy") - - - let newSaturdayLectures = lecturesToCopy.map { originalLecture in - let newLecture = Lecture( - name: originalLecture.name, - code: originalLecture.code, - venue: originalLecture.venue, - slot: originalLecture.slot, - type: originalLecture.type, - startTime: originalLecture.startTime, - endTime: originalLecture.endTime - ) - return newLecture - } - - - let newTimeTable = TimeTable( + print("Found \(lecturesToCopy.count) lectures to copy from \(day)") + + let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, thursday: currentTimeTable.thursday.map { $0.deepCopy() }, friday: currentTimeTable.friday.map { $0.deepCopy() }, - saturday: newSaturdayLectures, + saturday: currentTimeTable.saturday.map { $0.deepCopy() }, sunday: currentTimeTable.sunday.map { $0.deepCopy() }, - saturdaySourceDay: day + saturdaySourceDay: currentTimeTable.saturdaySourceDay ) - do { - print("Deleting old timetable") + modelContext.delete(currentTimeTable) + print("Deleted existing timetable") + + + let newSaturdayLectures = lecturesToCopy.map { originalLecture in + Lecture( + name: originalLecture.name, + code: originalLecture.code, + venue: originalLecture.venue, + slot: originalLecture.slot, + type: originalLecture.type, + startTime: originalLecture.startTime, + endTime: originalLecture.endTime + ) + } + + print("Created \(newSaturdayLectures.count) new lectures for Saturday") + + + let newTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: newSaturdayLectures, + sunday: backupData.sunday, + saturdaySourceDay: day + ) - print("Inserting new timetable with Saturday lectures") modelContext.insert(newTimeTable) + print("Inserted new timetable with Saturday lectures") - + try modelContext.save() self.selectedDay = day - print("Successfully copied \(day) to Saturday using orthodox method") + print("Successfully recreated timetable with \(day) copied to Saturday") print("New Saturday has \(newTimeTable.saturday.count) lectures") - Task { @MainActor in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil @@ -327,41 +523,72 @@ struct SettingsView: View { } } catch { - print("Error during orthodox copy: \(error)") - + print("Error during timetable recreation: \(error)") modelContext.rollback() + + + print("Attempting to restore backup data...") + do { + let restoredTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: backupData.saturday, + sunday: backupData.sunday, + saturdaySourceDay: backupData.saturdaySourceDay + ) + + modelContext.insert(restoredTimeTable) + try modelContext.save() + print("Successfully restored backup data") + } catch { + print("Failed to restore backup data: \(error)") + } } } - - + private func resetSaturdayClasses() { guard let currentTimeTable = timeTables.first else { print("No timetable found") return } - print("Starting orthodox reset of Saturday classes") + print("Starting SAFE reset of Saturday classes - DELETE & RECREATE approach") - - let newTimeTable = TimeTable( + + let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, thursday: currentTimeTable.thursday.map { $0.deepCopy() }, friday: currentTimeTable.friday.map { $0.deepCopy() }, - saturday: [], + saturday: currentTimeTable.saturday.map { $0.deepCopy() }, sunday: currentTimeTable.sunday.map { $0.deepCopy() }, - saturdaySourceDay: nil + saturdaySourceDay: currentTimeTable.saturdaySourceDay ) - do { - print("Deleting old timetable") + modelContext.delete(currentTimeTable) + print("Deleted existing timetable") + + + let newTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: [], + sunday: backupData.sunday, + saturdaySourceDay: nil + ) - - print("Inserting new timetable with empty Saturday") + modelContext.insert(newTimeTable) + print("Inserted new timetable with empty Saturday") try modelContext.save() @@ -369,10 +596,10 @@ struct SettingsView: View { self.selectedDay = nil - print("Successfully reset Saturday classes using orthodox method") + print("Successfully recreated timetable with empty Saturday") - - Task { @MainActor in + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil @@ -380,11 +607,32 @@ struct SettingsView: View { } } catch { - print("Error during orthodox reset: \(error)") - + print("Error during timetable reset: \(error)") modelContext.rollback() + + + print("Attempting to restore backup data...") + do { + let restoredTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: backupData.saturday, + sunday: backupData.sunday, + saturdaySourceDay: backupData.saturdaySourceDay + ) + + modelContext.insert(restoredTimeTable) + try modelContext.save() + print("Successfully restored backup data") + } catch { + print("Failed to restore backup data: \(error)") + } } } + private var headerView: some View { HStack { @@ -423,12 +671,27 @@ struct SettingsView: View { let icon: String let title: String let subtitle: String + let isLoading: Bool + + init(icon: String, title: String, subtitle: String, isLoading: Bool = false) { + self.icon = icon + self.title = title + self.subtitle = subtitle + self.isLoading = isLoading + } var body: some View { HStack(alignment: .top, spacing: 12) { - Image(systemName: icon) - .foregroundColor(.white) - .frame(width: 30, height: 30) + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + .frame(width: 30, height: 30) + } else { + Image(systemName: icon) + .foregroundColor(.white) + .frame(width: 30, height: 30) + } VStack(alignment: .leading, spacing: 4) { Text(title) @@ -473,7 +736,8 @@ struct SettingsView: View { } } -// Custom Reset Alert Component +// MARK: - Alert Components + struct ResetSaturdayAlert: View { let onCancel: () -> Void let onReset: () -> Void @@ -523,12 +787,11 @@ struct ResetSaturdayAlert: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .onTapGesture { - // Empty tap gesture to prevent dismissal + } } } -// Custom Delete User Alert Component struct DeleteUserAlert: View { let isDeleting: Bool let onCancel: () -> Void @@ -589,7 +852,54 @@ struct DeleteUserAlert: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .onTapGesture { - // Empty tap gesture to prevent dismissal + + } + } +} + +struct SyncAlert: View { + let message: String + let isSuccess: Bool + let onDismiss: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 16) { + Image(systemName: isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 40)) + .foregroundColor(isSuccess ? .green : .red) + + Text(isSuccess ? "Sync Successful" : "Sync Failed") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text(message) + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + Button(action: onDismiss) { + Text("OK") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(isSuccess ? Color.green : Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .frame(minHeight: 180) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .onTapGesture { + } } } diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 2f8ca1f..5a8ed9d 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/" - "http://localhost:80/api/v2/" + "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" // "https://f4df-2409-40e3-30a4-8539-6d49-631b-ddd8-60a3.ngrok-free.app/api/v2/" diff --git a/VITTY/VITTY/TimeTable/Models/NetworkMonitor.swift b/VITTY/VITTY/TimeTable/Models/NetworkMonitor.swift new file mode 100644 index 0000000..02a7cc4 --- /dev/null +++ b/VITTY/VITTY/TimeTable/Models/NetworkMonitor.swift @@ -0,0 +1,31 @@ +// +// NetworkMonitor.swift +// VITTY +// +// Created by Rujin Devkota on 7/11/25. +// + + + +import Foundation +import Network + + + +class NetworkMonitor: ObservableObject { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + + @Published var isConnected = true + + init() { + monitor.pathUpdateHandler = { path in + + DispatchQueue.main.async { + self.isConnected = path.status == .satisfied + } + } + monitor.start(queue: queue) + } +} diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index ca90c05..d6a8fc9 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -4,19 +4,12 @@ // // Created by Chandram Dutta on 09/02/24. // -// -// TimeTable.swift -// VITTY -// -// Created by Chandram Dutta on 09/02/24. -// + import Foundation import OSLog import SwiftData - - class TimeTableRaw: Codable { let data: TimeTable @@ -231,7 +224,7 @@ extension TimeTable { Classes( title: $0.name, time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", - slot: $0.slot + slot: $0.venue // NOTE: Passing venue instead of slot for display purposes ) } @@ -246,7 +239,7 @@ extension TimeTable { Classes( title: $0.name, time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", - slot: $0.slot + slot: $0.venue // NOTE: Passing venue instead of slot for display purposes ) } } @@ -278,4 +271,3 @@ extension TimeTable { sunday != other.sunday } } - diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index ec4cf2b..4775855 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -8,6 +8,8 @@ import Foundation import OSLog import SwiftData +import Network +import SwiftUI public enum Stage { case loading @@ -24,61 +26,72 @@ extension TimeTableView { var lectures = [Lecture]() var dayNo = Date.convertToMondayWeek() - private var hasSyncedThisSession = false - private var isSyncing = false - - // Serial queue for database operations to prevent race conditions - private let databaseQueue = DispatchQueue(label: "com.vitty.database", qos: .userInitiated) + private var networkMonitor = NetworkMonitor() private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: TimeTableViewModel.self - ) + category: String(describing: TimeTableViewModel.self) ) private var notificationObserver: NSObjectProtocol? - - init() { - setupNotificationObserver() - } - - deinit { - if let observer = notificationObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - private func setupNotificationObserver() { - notificationObserver = NotificationCenter.default.addObserver( - forName: NSNotification.Name("TimetableDidChange"), - object: nil, - queue: .main - ) { [weak self] _ in - self?.forceRefreshCurrentDay() - } - } - private func forceRefreshCurrentDay() { - logger.info("Forcing refresh of current day due to timetable change") - changeDay() - } - // NEW: Method to refresh the timetable data from the database - @MainActor - func refreshFromDatabase(_ updatedTimeTable: TimeTable?) { - guard let updatedTimeTable = updatedTimeTable else { - self.timeTable = nil - self.lectures = [] - self.stage = .error - return + + init() { + setupNotificationObserver() + } + + deinit { + if let observer = notificationObserver { + NotificationCenter.default.removeObserver(observer) } - - // Update our cached copy with the fresh data - self.timeTable = updatedTimeTable - changeDay() // Refresh the current day's lectures - - logger.info("Timetable refreshed from database") } + private func setupNotificationObserver() { + notificationObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("TimetableDidChange"), + object: nil, + queue: .main + ) { [weak self] _ in + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self?.forceRefreshCurrentDay() + } + } + } + + 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 + } + } + + @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") + } + func changeDay() { guard let timeTable = timeTable else { self.lectures = [] @@ -86,277 +99,324 @@ extension TimeTableView { } 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 = [] + 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 = [] } } - + // MARK: - Local First Load Implementation @MainActor - func loadTimeTable( - existingTimeTable: TimeTable?, - username: String, - authToken: String, - context: ModelContext - ) async { - logger.info("Starting timetable loading process") - - if let existing = existingTimeTable { - logger.debug("Using existing local timetable.") - self.timeTable = existing - changeDay() - self.stage = .data - - - if !hasSyncedThisSession && !isSyncing && !username.isEmpty && !authToken.isEmpty { - - Task { [weak self] in - await self?.backgroundSync( - localTimeTable: existing, - username: username, - authToken: authToken, - context: context - ) - } - } - } else { - logger.debug("No local timetable, fetching from API.") - await fetchTimeTableFromAPI( - username: username, - authToken: authToken, - context: context - ) - } - } - - private func backgroundSync( - localTimeTable: TimeTable, + func loadTimeTable( + existingTimeTable: TimeTable?, username: String, authToken: String, context: ModelContext ) async { - guard !isSyncing else { - logger.info("Sync already in progress. Skipping.") - return - } + logger.info("Starting local-first timetable loading process") - - await MainActor.run { - isSyncing = true + // Step 1: Check if we have local data + if let existingTimeTable = existingTimeTable { + logger.info("Found local timetable data - using it") + useLocalData(existingTimeTable: existingTimeTable) + return } - logger.info("Starting background sync.") + // Step 2: No local data exists - fetch from API and save + logger.info("No local timetable found - fetching from API") + stage = .loading - defer { - Task { @MainActor in - isSyncing = false - } + // Only fetch if we have credentials + if !username.isEmpty && !authToken.isEmpty { + await fetchAndSaveFromAPI( + username: username, + authToken: authToken, + context: context + ) + } else { + logger.error("No credentials available for API fetch") + stage = .error } - + } + + // MARK: - Use Local Data (Always prioritized) + @MainActor + private func useLocalData(existingTimeTable: TimeTable) { + logger.info("Using local timetable data") + self.timeTable = existingTimeTable + changeDay() + stage = .data + } + + // MARK: - Fetch from API and Save to Local Storage + @MainActor + private func fetchAndSaveFromAPI( + username: String, + authToken: String, + context: ModelContext + ) async { do { + logger.info("Fetching timetable from API") let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( with: username, authToken: authToken ) - logger.info("Background sync: Fetched remote timetable.") - - let mergedTimeTable = await createMergedTimeTable( - remote: remoteTimeTable, - local: localTimeTable - ) - - - await updateLocalDatabaseSafely( - with: mergedTimeTable, - oldTimeTable: localTimeTable, + // Save to local storage + await saveToLocalStorage( + newTimeTable: remoteTimeTable, context: context ) - await MainActor.run { - hasSyncedThisSession = true - } + // Update UI + self.timeTable = remoteTimeTable + changeDay() + stage = .data + + logger.info("Successfully fetched and saved timetable from API") } catch { - logger.error("Background sync failed: \(error.localizedDescription)") - - } - } - - private func createMergedTimeTable( - remote: TimeTable, - local: TimeTable - ) async -> TimeTable { - let saturdaySourceDay = local.saturdaySourceDay - - let finalTimeTable = TimeTable( - monday: remote.monday.map { $0.deepCopy() }, - tuesday: remote.tuesday.map { $0.deepCopy() }, - wednesday: remote.wednesday.map { $0.deepCopy() }, - thursday: remote.thursday.map { $0.deepCopy() }, - friday: remote.friday.map { $0.deepCopy() }, - saturday: [], - sunday: remote.sunday.map { $0.deepCopy() } - ) - - - if let sourceDay = saturdaySourceDay { - logger.info("Re-applying Saturday rule from source day: \(sourceDay).") - let lecturesToCopy = finalTimeTable.lectures(forDay: sourceDay) - finalTimeTable.saturday = lecturesToCopy.map { $0.deepCopy() } - finalTimeTable.saturdaySourceDay = sourceDay + logger.error("Failed to fetch from API: \(error.localizedDescription)") + stage = .error } - - return finalTimeTable } + // MARK: - Save to Local Storage @MainActor - private func updateLocalDatabaseSafely( - with newTimeTable: TimeTable, - oldTimeTable: TimeTable, + private func saveToLocalStorage( + newTimeTable: TimeTable, context: ModelContext ) async { - logger.info("Updating local database with merged timetable.") - - do { - - context.delete(oldTimeTable) + // Insert new timetable context.insert(newTimeTable) try context.save() - - self.timeTable = newTimeTable - changeDay() - logger.info("Local database successfully updated and persisted.") + logger.info("Successfully saved timetable to local storage") - } catch { - logger.error("Failed to save merged timetable: \(error.localizedDescription)") - - do { - - context.rollback() - + // Notify other components + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) - context.insert(oldTimeTable) - try context.save() - - - self.timeTable = oldTimeTable - changeDay() - logger.info("Rollback successful.") - - } catch { - logger.error("Rollback also failed: \(error.localizedDescription)") - // In this case, trigger a fresh fetch - await handleDatabaseCorruption(context: context) - } + } catch { + logger.error("Failed to save timetable to local storage: \(error.localizedDescription)") + context.rollback() } } + // MARK: - Force Sync (Explicit user action) @MainActor - private func handleDatabaseCorruption(context: ModelContext) async { - logger.warning("Handling potential database corruption.") + func forceSync( + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Force syncing timetable from server") - - self.timeTable = nil - self.lectures = [] - self.stage = .error + // Set loading state + stage = .loading - - hasSyncedThisSession = false - isSyncing = false + // Get existing timetable for Saturday preservation + let existingTimeTable = timeTable - logger.info("Database corruption handled. User will need to reload.") + // Force fetch from API + if !username.isEmpty && !authToken.isEmpty { + await fetchAndUpdateFromAPI( + existingTimeTable: existingTimeTable, + username: username, + authToken: authToken, + context: context + ) + } else { + logger.error("Cannot force sync without credentials") + stage = .error + } } + // MARK: - Fetch and Update from API (for sync) @MainActor - private func fetchTimeTableFromAPI( + private func fetchAndUpdateFromAPI( + existingTimeTable: TimeTable?, username: String, authToken: String, context: ModelContext ) async { - logger.info("Fetching TimeTable from API for initial load.") - stage = .loading - - guard !username.isEmpty && !authToken.isEmpty else { - logger.error("Username or auth token is empty") - stage = .error - return - } - do { + logger.info("Fetching updated timetable from API") let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( with: username, authToken: authToken ) - logger.info("TimeTable fetched from API.") - - context.insert(remoteTimeTable) - try context.save() + // Preserve Saturday customization if it exists + let finalTimeTable = preserveSaturdayCustomization( + remote: remoteTimeTable, + local: existingTimeTable + ) - self.timeTable = remoteTimeTable + // Update local storage + await updateLocalStorage( + newTimeTable: finalTimeTable, + oldTimeTable: existingTimeTable, + context: context + ) + + // Update UI + self.timeTable = finalTimeTable changeDay() stage = .data - hasSyncedThisSession = true + + logger.info("Successfully synced timetable from API") } catch { - logger.error("API fetch failed: \(error.localizedDescription)") + logger.error("Failed to sync from API: \(error.localizedDescription)") stage = .error } } + // MARK: - Update Local Storage (for sync) + @MainActor + private func updateLocalStorage( + newTimeTable: TimeTable, + oldTimeTable: TimeTable?, + 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 + ) + + } catch { + 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() + } + } + } + + // 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() }, + wednesday: remote.wednesday.map { $0.deepCopy() }, + thursday: remote.thursday.map { $0.deepCopy() }, + friday: remote.friday.map { $0.deepCopy() }, + saturday: remote.saturday.map { $0.deepCopy() }, + 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)") + + let lecturesToCopy = newTimeTable.lectures(forDay: saturdaySourceDay) + newTimeTable.saturday = lecturesToCopy.map { $0.deepCopy() } + newTimeTable.saturdaySourceDay = saturdaySourceDay + } + + return newTimeTable + } + + // MARK: - Saturday Management + @MainActor + func copyLecturesToSaturday( + from day: String, + context: ModelContext + ) async { + guard let currentTimeTable = timeTable else { + logger.error("No timetable available to copy from") + return + } + + logger.info("Copying lectures from \(day) to Saturday") + + let lecturesToCopy = currentTimeTable.lectures(forDay: day) + let newSaturdayLectures = lecturesToCopy.map { originalLecture in + Lecture( + name: originalLecture.name, + code: originalLecture.code, + venue: originalLecture.venue, + slot: originalLecture.slot, + type: originalLecture.type, + startTime: originalLecture.startTime, + endTime: originalLecture.endTime + ) + } + + // Create new timetable with Saturday lectures + let newTimeTable = TimeTable( + monday: currentTimeTable.monday.map { $0.deepCopy() }, + tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, + wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, + thursday: currentTimeTable.thursday.map { $0.deepCopy() }, + friday: currentTimeTable.friday.map { $0.deepCopy() }, + saturday: newSaturdayLectures, + sunday: currentTimeTable.sunday.map { $0.deepCopy() }, + saturdaySourceDay: day + ) + + await updateLocalStorage( + newTimeTable: newTimeTable, + oldTimeTable: currentTimeTable, + context: context + ) + + // Update UI + self.timeTable = newTimeTable + changeDay() + + logger.info("Successfully copied \(day) lectures to Saturday") + } + + // MARK: - Utility Methods func resetSyncStatus() { - hasSyncedThisSession = false - logger.debug("Sync status reset.") + // Simple reset - just refresh current day + changeDay() } var updatedTimeTable: TimeTable? { timeTable } + // MARK: - Deprecated Methods (kept for compatibility) @MainActor func forceRefresh( username: String, authToken: String, context: ModelContext ) async { - logger.info("Force refreshing timetable data.") - hasSyncedThisSession = false - isSyncing = false - - - if let existingTimeTable = timeTable { - do { - context.delete(existingTimeTable) - try context.save() - } catch { - logger.error("Failed to delete existing timetable: \(error.localizedDescription)") - - } - } - - - self.timeTable = nil - self.lectures = [] - - - await fetchTimeTableFromAPI( + // Redirect to forceSync for consistency + await forceSync( username: username, authToken: authToken, context: context diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index eb75e5f..a091267 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -109,7 +109,6 @@ struct TimeTableView: View { )! viewModel.changeDay() - proxy.scrollTo(day, anchor: .center) } } @@ -121,12 +120,10 @@ struct TimeTableView: View { } .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) @@ -199,29 +196,21 @@ struct TimeTableView: View { .onChange(of: timetableItem) { oldValue, newValue in logger.debug("Timetable data changed, reloading view.") - // NEW: Check if this is a meaningful change - let oldCount = oldValue.count - let newCount = newValue.count - - // Handle different change scenarios - if oldCount != newCount { + // Simplified change detection + if oldValue.count != newValue.count { // Data was added or removed loadTimetable() - } else if let oldTable = oldValue.first, let newTable = newValue.first { - // Check if the actual content changed (especially Saturday) - if oldTable.isDifferentFrom(newTable) { - logger.debug("Timetable content changed, refreshing ViewModel") - // Directly refresh the ViewModel with the new data - viewModel.refreshFromDatabase(newTable) - } + } else if let newTable = newValue.first { + // Data content changed + viewModel.refreshFromDatabase(newTable) } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { viewModel.resetSyncStatus() - // Check if we need to reload due to potential data corruption - if viewModel.stage == .error || viewModel.timeTable == nil { + // Reload if in error state + if viewModel.stage == .error { loadTimetable() } } @@ -229,19 +218,30 @@ struct TimeTableView: View { } private func loadTimetable() { - guard !isRefreshing else { return } + logger.debug("Loading timetable with local-first approach") + + + let calendar = Calendar.current + let today = calendar.component(.weekday, from: Date()) - - Task { @MainActor in + + let dayIndex = (today == 1) ? 6 : today - 2 + + if dayIndex >= 0 && dayIndex < daysOfWeek.count { + viewModel.dayNo = dayIndex + } else { + viewModel.dayNo = 0 + } + + + Task { await viewModel.loadTimeTable( existingTimeTable: timetableItem.first, - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), + username: authViewModel.loggedInBackendUser?.username ?? "", authToken: authViewModel.loggedInBackendUser?.token ?? "", context: context ) } - - logger.debug("User token: \(authViewModel.loggedInBackendUser?.token ?? "empty")") } private func refreshTimetable() async { diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 77acbfd..6833873 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -54,13 +54,11 @@ struct UserProfileSidebar: View { } Divider().background(Color.clear) - -// MenuOption(icon: "share", title: "Share") + MenuOption(icon: "support", title: "Support").onTapGesture { let supportUrl = URL(string: "https://github.com/GDGVIT/vitty-ios/issues/new?template=bug_report.md") UIApplication.shared.open(supportUrl!) } -// MenuOption(icon: "about", title: "About") Divider().background(Color.clear) diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index f55add2..6fd55c8 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -10,7 +10,7 @@ import Foundation struct APIConstants { - static let base_url = "http://localhost:80/api/v2/" + static let base_url = "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" static let createCircle = "circles/create/" static let sendRequest = "circles/sendRequest/" static let acceptRequest = "circles/acceptRequest/" diff --git a/VITTY/VittyWidget/Providers/ScheduleProvider.swift b/VITTY/VittyWidget/Providers/ScheduleProvider.swift index bae6de7..3495ea2 100644 --- a/VITTY/VittyWidget/Providers/ScheduleProvider.swift +++ b/VITTY/VittyWidget/Providers/ScheduleProvider.swift @@ -115,9 +115,9 @@ struct Provider: TimelineProvider { }.count } - // MARK: - Widget Content Preparation + // MARK: - Widget Size-Specific Content Preparation - private func prepareWidgetContent() -> (classes: [Classes], total: Int, completed: Int) { + private func prepareSmallMediumContent() -> (classes: [Classes], total: Int, completed: Int) { let allClasses = fetchAllTodaysClasses() let upcomingClasses = fetchUpcomingClasses() let currentClass = fetchCurrentClass() @@ -125,12 +125,12 @@ struct Provider: TimelineProvider { var displayClasses: [Classes] = [] - + // Add current class first if let current = currentClass { displayClasses.append(current) } - + // Add upcoming classes displayClasses.append(contentsOf: upcomingClasses) return ( @@ -140,6 +140,75 @@ struct Provider: TimelineProvider { ) } + private func prepareLargeContent() -> (classes: [Classes], total: Int, completed: Int) { + let allClasses = fetchAllTodaysClasses() + let completedCount = calculateCompletedClassesCount() + + // Get all classes sorted by time + let sortedClasses = getSortedClasses(allClasses) + + // Group classes into batches of 4 + let currentGroupClasses = getCurrentGroupOfFour(sortedClasses) + + return ( + classes: currentGroupClasses, + total: allClasses.count, + completed: completedCount + ) + } + + private func getSortedClasses(_ classes: [Classes]) -> [Classes] { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm a" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + return classes.sorted { class1, class2 in + let time1Components = class1.time.components(separatedBy: " - ") + let time2Components = class2.time.components(separatedBy: " - ") + + guard time1Components.count == 2, time2Components.count == 2 else { + return false + } + + let startTime1Str = time1Components[0].trimmingCharacters(in: .whitespaces) + let startTime2Str = time2Components[0].trimmingCharacters(in: .whitespaces) + + guard let startTime1 = dateFormatter.date(from: startTime1Str), + let startTime2 = dateFormatter.date(from: startTime2Str) else { + return false + } + + return startTime1 < startTime2 + } + } + + private func getCurrentGroupOfFour(_ sortedClasses: [Classes]) -> [Classes] { + let currentTime = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm a" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + let calendar = Calendar.current + + // Find the current or next active class + var pivotIndex = 0 + + for (index, classItem) in sortedClasses.enumerated() { + let status = getClassStatus(classItem, at: currentTime) + if status == .current || status == .upcoming { + pivotIndex = index + break + } + } + + // Create groups of 4 starting from the beginning + let groupSize = 4 + let currentGroupIndex = pivotIndex / groupSize + let startIndex = currentGroupIndex * groupSize + let endIndex = min(startIndex + groupSize, sortedClasses.count) + + return Array(sortedClasses[startIndex.. ScheduleEntry { @@ -154,7 +223,15 @@ struct Provider: TimelineProvider { } func getSnapshot(in context: Context, completion: @escaping (ScheduleEntry) -> ()) { - let content = prepareWidgetContent() + let content: (classes: [Classes], total: Int, completed: Int) + + // Use different content preparation based on widget size + switch context.family { + case .systemLarge: + content = prepareLargeContent() + default: + content = prepareSmallMediumContent() + } completion(ScheduleEntry( date: Date(), @@ -165,9 +242,17 @@ struct Provider: TimelineProvider { } func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - let content = prepareWidgetContent() + let content: (classes: [Classes], total: Int, completed: Int) let currentTime = Date() + // Use different content preparation based on widget size + switch context.family { + case .systemLarge: + content = prepareLargeContent() + default: + content = prepareSmallMediumContent() + } + let entry = ScheduleEntry( date: currentTime, total: content.total, @@ -175,8 +260,14 @@ struct Provider: TimelineProvider { completed: content.completed ) - - let nextRefreshTime = calculateNextRefreshTime(currentTime: currentTime, classes: content.classes) + // Calculate next refresh time based on widget size + let nextRefreshTime: Date + switch context.family { + case .systemLarge: + nextRefreshTime = calculateNextGroupRefreshTime(currentTime: currentTime, classes: content.classes) + default: + nextRefreshTime = calculateNextRefreshTime(currentTime: currentTime, classes: content.classes) + } let timeline = Timeline(entries: [entry], policy: .after(nextRefreshTime)) completion(timeline) @@ -187,20 +278,20 @@ struct Provider: TimelineProvider { private func calculateNextRefreshTime(currentTime: Date, classes: [Classes]) -> Date { let calendar = Calendar.current - + // Find next significant time (class start/end) var nextSignificantTime: Date? for classItem in classes { let (startTime, endTime) = parseClassTime(classItem.time) - + // Check for next class start if let start = startTime, start > currentTime { if nextSignificantTime == nil || start < nextSignificantTime! { nextSignificantTime = start } } - + // Check for current class end if let end = endTime, end > currentTime { if nextSignificantTime == nil || end < nextSignificantTime! { nextSignificantTime = end @@ -208,12 +299,76 @@ struct Provider: TimelineProvider { } } - + // Use significant time if found, otherwise refresh in 15 minutes if let significantTime = nextSignificantTime { return significantTime } - return calendar.date(byAdding: .minute, value: 15, to: currentTime) ?? currentTime } + + private func calculateNextGroupRefreshTime(currentTime: Date, classes: [Classes]) -> Date { + let calendar = Calendar.current + let allClasses = fetchAllTodaysClasses() + let sortedClasses = getSortedClasses(allClasses) + + // Find when the current group of 4 will change + let currentGroupIndex = getCurrentGroupIndex(sortedClasses, currentTime: currentTime) + let groupSize = 4 + let currentGroupStart = currentGroupIndex * groupSize + let currentGroupEnd = min(currentGroupStart + groupSize, sortedClasses.count) + + // Find the next significant time that would cause a group change + var nextGroupChangeTime: Date? + + // Check if we need to move to the next group when current classes in this group end + for i in currentGroupStart.. currentTime { + // Check if this would trigger a group change + if isLastClassInGroup(index: i, groupSize: groupSize, totalClasses: sortedClasses.count) { + if nextGroupChangeTime == nil || end < nextGroupChangeTime! { + nextGroupChangeTime = end + } + } + } + } + } + + // If no group change time found, use regular refresh timing + if let groupChangeTime = nextGroupChangeTime { + return groupChangeTime + } + + // Fallback to regular refresh timing + return calculateNextRefreshTime(currentTime: currentTime, classes: classes) + } + + private func getCurrentGroupIndex(_ sortedClasses: [Classes], currentTime: Date) -> Int { + let groupSize = 4 + + // Find the current or next active class + var pivotIndex = 0 + + for (index, classItem) in sortedClasses.enumerated() { + let status = getClassStatus(classItem, at: currentTime) + if status == .current || status == .upcoming { + pivotIndex = index + break + } + } + + return pivotIndex / groupSize + } + + private func isLastClassInGroup(index: Int, groupSize: Int, totalClasses: Int) -> Bool { + let groupIndex = index / groupSize + let groupStart = groupIndex * groupSize + let groupEnd = min(groupStart + groupSize, totalClasses) + + return index == groupEnd - 1 + } } diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index 68fa3ed..a4ada53 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -75,7 +75,7 @@ struct ScheduleLargeWidgetView: View { HStack(alignment: .top) { Spacer().frame(width: 2) VStack(alignment: .leading, spacing: 15) { - + Spacer().frame(height: 5) WidgetTitle(title: "Today's Schedule", fontSize: 18) Spacer().frame(height: 5)