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)