diff --git a/README.md b/README.md index 9c0076c..0e7c9f2 100644 --- a/README.md +++ b/README.md @@ -7,24 +7,21 @@

--- + [![Join Us](https://img.shields.io/badge/Join%20Us-Developer%20Student%20Clubs-red)](https://dsc.community.dev/vellore-institute-of-technology/) [![Discord Chat](https://img.shields.io/discord/760928671698649098.svg)](https://discord.gg/498KVdSKWR) - [![DOCS](https://img.shields.io/badge/Documentation-see%20docs-green?style=flat-square&logo=appveyor)](INSERT_LINK_FOR_DOCS_HERE) - [![UI ](https://img.shields.io/badge/User%20Interface-Link%20to%20UI-orange?style=flat-square&logo=appveyor)](INSERT_UI_LINK_HERE) +[![UI ](https://img.shields.io/badge/User%20Interface-Link%20to%20UI-orange?style=flat-square&logo=appveyor)](INSERT_UI_LINK_HERE)
- ## Running - Open in Xcode and run the app in simulator. ## Contributors - - - -
+ + + +
Chandram Dutta

Chandram Dutta @@ -38,6 +35,20 @@ Open in Xcode and run the app in simulator.

+ Rujin Devkota +

+ Rujin Devkota +

+

+ + GitHub + + + LinkedIn + +

+
diff --git a/VITTY/GoogleService-Info.plist b/VITTY/GoogleService-Info.plist new file mode 100644 index 0000000..57973b4 --- /dev/null +++ b/VITTY/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + 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 f6dafb4..1b57bd7 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 4BD63D742D70547E00EEF5D7 /* EmptyClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD63D722D70547E00EEF5D7 /* EmptyClass.swift */; }; 4BD63D772D70610B00EEF5D7 /* EmptyClassAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD63D762D70610000EEF5D7 /* EmptyClassAPIService.swift */; }; 4BD63D7A2D70636400EEF5D7 /* EmptyClassRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD63D792D70635D00EEF5D7 /* EmptyClassRoomViewModel.swift */; }; + 4BD77EA22EBBCE050027F234 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4BD77EA12EBBCE050027F234 /* GoogleService-Info.plist */; }; 4BF03C992D7819E30098C803 /* Notes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF03C982D7819E00098C803 /* Notes.swift */; }; 4BF03C9B2D7838C80098C803 /* NotesHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF03C9A2D7838C50098C803 /* NotesHelper.swift */; }; 4BF0C77B2D93108B00016202 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF0C77A2D93108500016202 /* SearchBar.swift */; }; @@ -241,6 +242,7 @@ 4BD63D722D70547E00EEF5D7 /* EmptyClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyClass.swift; sourceTree = ""; }; 4BD63D762D70610000EEF5D7 /* EmptyClassAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyClassAPIService.swift; sourceTree = ""; }; 4BD63D792D70635D00EEF5D7 /* EmptyClassRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyClassRoomViewModel.swift; sourceTree = ""; }; + 4BD77EA12EBBCE050027F234 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4BF03C982D7819E00098C803 /* Notes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notes.swift; sourceTree = ""; }; 4BF03C9A2D7838C50098C803 /* NotesHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesHelper.swift; sourceTree = ""; }; 4BF0C77A2D93108500016202 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; @@ -418,6 +420,7 @@ children = ( 4BC853C52DF6F71B0092B2E2 /* VittyWidgetExtension.entitlements */, 4B1A500E2E3E61530060314D /* GoogleService-Info.plist */, + 4BD77EA12EBBCE050027F234 /* GoogleService-Info.plist */, 4B8FB2C72E39D29F00E50AE2 /* GoogleService-Info.plist */, 5251A7FF2B46E3C000D44CFE /* .swift-format */, 314A408E27383BEC0058082F /* VITTYApp.swift */, @@ -1127,6 +1130,7 @@ 31128CFA2772F57E0084C9EA /* Poppins-SemiBoldItalic.ttf in Resources */, 31128CFC2772F57E0084C9EA /* Poppins-Regular.ttf in Resources */, 4B8FB2C82E39D29F00E50AE2 /* GoogleService-Info.plist in Resources */, + 4BD77EA22EBBCE050027F234 /* GoogleService-Info.plist in Resources */, 314A409627383BEE0058082F /* Preview Assets.xcassets in Resources */, 314A409327383BEE0058082F /* Assets.xcassets in Resources */, ); @@ -1437,6 +1441,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"VITTY/Preview Content\""; + DEVELOPMENT_TEAM = C7RX29D33F; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VITTY/Info.plist; diff --git a/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift b/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift index 5cf4d87..db40700 100644 --- a/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift +++ b/VITTY/VITTY/Academics/VIewModel/ReminderNotifcationManager.swift @@ -24,39 +24,97 @@ class NotificationManager { } func scheduleNotification(title: String, body: String, date: Date, identifier: String) { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default + // Check if notifications are enabled + guard UserDefaults.standard.bool(forKey: "notificationsEnabled") else { + print("Notifications are disabled. Skipping reminder notification scheduling.") + return + } + + // Check if date is in the past + guard date > Date() else { + print("Cannot schedule notification for past date: \(date)") + return + } + + // Check notification authorization + UNUserNotificationCenter.current().getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { + print("Notification authorization not granted. Cannot schedule reminder.") + return + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default - let triggerDate = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) - let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false) + let triggerDate = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false) - let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - print("Failed to schedule notification: \(error)") + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Failed to schedule notification: \(error)") + } else { + print("Successfully scheduled reminder notification: '\(title)' at \(date)") + } } } } - func scheduleReminderNotifications(title: String, date: Date,subject: String) { - + func scheduleReminderNotifications(title: String, date: Date, subject: String) { + // Check if notifications are enabled + guard UserDefaults.standard.bool(forKey: "notificationsEnabled") else { + print("Notifications are disabled. Skipping reminder notification scheduling.") + return + } + + // Check if date is in the past + guard date > Date() else { + print("Cannot schedule reminder notifications for past date: \(date)") + return + } + + // Create unique identifier using title, subject, and timestamp + let timestamp = Int(date.timeIntervalSince1970) + let baseIdentifier = "reminder-\(subject)-\(title)-\(timestamp)" + + // Schedule exact time notification scheduleNotification( title: "\(subject): \(title)", body: "Your scheduled reminder is now.", date: date, - identifier: "\(title)-exact" + identifier: "\(baseIdentifier)-exact" ) + // Calculate 10 minutes before, handling edge cases safely + guard let tenMinutesBefore = Calendar.current.date(byAdding: .minute, value: -10, to: date), + tenMinutesBefore > Date() else { + print("Cannot schedule 10-minute reminder notification (too close to current time or in the past)") + // Still schedule the exact time notification + return + } - let tenMinutesBefore = Calendar.current.date(byAdding: .minute, value: -10, to: date)! scheduleNotification( title: "Upcoming Reminder: \(title)", body: "Your reminder is in 10 minutes.", date: tenMinutesBefore, - identifier: "\(title)-early" + identifier: "\(baseIdentifier)-early" ) } + + /// Cancel notifications for a specific reminder + func cancelReminderNotifications(title: String, date: Date, subject: String) { + let timestamp = Int(date.timeIntervalSince1970) + let baseIdentifier = "reminder-\(subject)-\(title)-\(timestamp)" + + let identifiers = [ + "\(baseIdentifier)-exact", + "\(baseIdentifier)-early" + ] + + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers) + print("Cancelled reminder notifications for: \(title)") + } } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index 4090398..eaf7987 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -28,19 +28,21 @@ struct JoinGroup: View { var body: some View { ZStack { - VStack(spacing: 20) { + VStack(spacing: 0) { + // Drag indicator Capsule() .fill(Color.gray.opacity(0.5)) .frame(width: 50, height: 5) - .padding(.top, 10) + .padding(.top, 8) - Spacer().frame(height: 7) + // Title Text("Join Circle") .font(.system(size: 21, weight: .bold)) .foregroundColor(.white) + .padding(.top, 20) + .padding(.bottom, 24) - Spacer().frame(width: 20) - + // Input section VStack(alignment: .leading, spacing: 10) { Text("Enter circle code") .font(.system(size: 16, weight: .bold)) @@ -63,54 +65,9 @@ struct JoinGroup: View { } .padding(.horizontal, 20) - HStack { - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) - Text("OR") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(.white) - .padding(.horizontal, 10) - Rectangle() - .fill(Color.gray.opacity(0.5)) - .frame(height: 1) - } - .padding(.horizontal, 20) - - HStack { - Text("Scan QR Code") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(Color("Accent")) - .padding(.leading, 20) - Spacer() - } - - Button(action: { - openCameraApp() - }) { - VStack { - Image(systemName: "qrcode.viewfinder") - .resizable() - .scaledToFit() - .frame(width: 100, height: 100) - .foregroundColor(Color.white) - - Text("Tap to open camera") - .font(.system(size: 14)) - .foregroundColor(.gray) - } - .frame(width: screenWidth * 0.8, height: screenHeight * 0.25) - .background(Color.black.opacity(0.3)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray.opacity(0.5), lineWidth: 1) - ) - } - .disabled(isJoining) - Spacer() + // Join button HStack { Spacer() Button(action: { @@ -135,7 +92,7 @@ struct JoinGroup: View { } .padding(.bottom, 20) } - .presentationDetents([.height(screenHeight * 0.65)]) + .presentationDetents([.height(screenHeight * 0.35)]) .background(Color("Secondary")) if showToast { @@ -174,26 +131,6 @@ struct JoinGroup: View { } } - // MARK: - Open Camera App - - private func openCameraApp() { - guard let url = URL(string: "camera:") else { - showToast(message: "Camera not available", isError: true) - return - } - - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } else { - - if let settingsURL = URL(string: UIApplication.openSettingsURLString) { - showToast(message: "Please open Camera app manually", isError: false) - } else { - showToast(message: "Camera app not available", isError: true) - } - } - } - // MARK: - Handle Deep Link private func handleDeepLink(_ url: URL) { diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index e0928e2..d879b32 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -417,7 +417,7 @@ struct HomeView: View { ZStack { if !showProfileSidebar { Button { - withAnimation(.easeInOut(duration: 0.8)) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showProfileSidebar = true } } label: { @@ -455,7 +455,7 @@ struct HomeView: View { .ignoresSafeArea() .transition(.opacity) .onTapGesture { - withAnimation(.easeInOut(duration: 0.8)) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showProfileSidebar = false } } @@ -468,7 +468,7 @@ struct HomeView: View { showLogoutAlert: $showLogoutAlert ) .frame(width: UIScreen.main.bounds.width * 0.75) - .transition(.move(edge: .trailing)) + .transition(.move(edge: .trailing).combined(with: .opacity)) } } } diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index b504576..835cc68 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -270,22 +270,21 @@ struct SettingsView: View { isSyncing = true Task { - let syncViewModel = TimeTableView.TimeTableViewModel() - - await syncViewModel.forceSync( - username: username, - authToken: authToken, - context: modelContext - ) - - await MainActor.run { - isSyncing = false + do { + // Fetch updated timetable from API + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) - - if syncViewModel.stage == .data { - showSyncMessage("Timetable synced successfully!", success: true) - } else { - showSyncMessage("Sync failed. Please try again.", success: false) + await MainActor.run { + updateLocalTimetable(with: remoteTimeTable) + } + + } catch { + await MainActor.run { + isSyncing = false + showSyncMessage("Sync failed: \(error.localizedDescription)", success: false) } } } @@ -340,10 +339,22 @@ struct SettingsView: View { isSyncing = false showSyncMessage("Timetable synced successfully!", success: true) - NotificationCenter.default.post( - name: NSNotification.Name("TimetableDidChange"), - object: nil - ) + // Update viewModel timetable to trigger notification rescheduling + viewModel.timetable = finalTimeTable + + // Post notification to update TimeTableView + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + + // Also post a refresh notification + NotificationCenter.default.post( + name: NSNotification.Name("RefreshTimetableFromSettings"), + object: nil + ) + } } catch { isSyncing = false @@ -360,6 +371,9 @@ struct SettingsView: View { isSyncing = false showSyncMessage("Timetable synced successfully!", success: true) + // Update viewModel timetable to trigger notification rescheduling + viewModel.timetable = timeTable + NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift index 3873306..7bcb385 100644 --- a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -10,21 +10,46 @@ class SettingsViewModel: ObservableObject { requestPermissionAndSchedule() } else { - + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - print("All pending notifications have been cleared.") + print("All pending notifications have been cleared (notifications disabled).") showNotificationDisabledAlert = true } } } - @Published var timetable: TimeTable? + @Published var timetable: TimeTable? { + didSet { + + if notificationsEnabled, let timetable = timetable { + + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + if settings.authorizationStatus == .authorized { + self.scheduleAllNotifications(from: timetable) + } + } + } + } + } + } @Published var showNotificationDisabledAlert = false init(timetable: TimeTable? = nil) { self.timetable = timetable checkNotificationAuthorization() + + + if notificationsEnabled, let timetable = timetable { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + if settings.authorizationStatus == .authorized { + self.scheduleAllNotifications(from: timetable) + } + } + } + } } @@ -58,8 +83,14 @@ class SettingsViewModel: ObservableObject { func scheduleAllNotifications(from timetable: TimeTable) { + + guard notificationsEnabled else { + print("Notifications are disabled. Skipping scheduling.") + return + } - UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + + removeClassNotifications() let weekdays: [(Int, [Lecture])] = [ (1, timetable.sunday), (2, timetable.monday), (3, timetable.tuesday), @@ -69,65 +100,98 @@ class SettingsViewModel: ObservableObject { for (weekday, lectures) in weekdays { for lecture in lectures { - - scheduleNotificationForNextOccurence(lecture: lecture, weekday: weekday) + scheduleRecurringNotification(lecture: lecture, weekday: weekday) + } + } + print("Scheduled all recurring weekly notifications.") + } + + /// Remove only class notifications, preserving reminder notifications + private func removeClassNotifications() { + UNUserNotificationCenter.current().getPendingNotificationRequests { requests in + let classNotificationIdentifiers = requests + .filter { request in + + let identifier = request.identifier + return !identifier.hasPrefix("reminder-") && + (identifier.contains("-reminder-") || identifier.contains("-start-")) + } + .map { $0.identifier } + + if !classNotificationIdentifiers.isEmpty { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: classNotificationIdentifiers) + print("Removed \(classNotificationIdentifiers.count) class notification(s)") } } - print("Scheduled all notifications for the next 7 days.") } - private func scheduleNotificationForNextOccurence(lecture: Lecture, weekday: Int) { + private func scheduleRecurringNotification(lecture: Lecture, weekday: Int) { guard let time = parseTime(from: lecture.startTime) else { return } - var dateComponents = DateComponents() - dateComponents.hour = Calendar.current.component(.hour, from: time) - dateComponents.minute = Calendar.current.component(.minute, from: time) - dateComponents.weekday = weekday + let hour = Calendar.current.component(.hour, from: time) + let minute = Calendar.current.component(.minute, from: time) + + var reminderComponents = DateComponents() + reminderComponents.weekday = weekday - guard let nextTriggerDate = Calendar.current.nextDate(after: Date(), matching: dateComponents, matchingPolicy: .nextTime) else { return } - - scheduleNotification( + if minute >= 10 { + reminderComponents.hour = hour + reminderComponents.minute = minute - 10 + } else { + + reminderComponents.hour = hour > 0 ? hour - 1 : 23 + reminderComponents.minute = 60 + minute - 10 + } + + + var startComponents = DateComponents() + startComponents.weekday = weekday + startComponents.hour = hour + startComponents.minute = minute + + scheduleWeeklyNotification( lectureName: lecture.name, - date: nextTriggerDate, + components: reminderComponents, title: "Upcoming Class", body: "\(lecture.name) starts in 10 minutes.", - minutesBefore: 10 + identifier: "\(lecture.name)-reminder-\(weekday)" ) - - scheduleNotification( + scheduleWeeklyNotification( lectureName: lecture.name, - date: nextTriggerDate, + components: startComponents, title: "Class Starting!", body: "\(lecture.name) is starting now.", - minutesBefore: 0 + identifier: "\(lecture.name)-start-\(weekday)" ) } - private func scheduleNotification(lectureName: String, date: Date, title: String, body: String, minutesBefore: Int) { + private func scheduleWeeklyNotification( + lectureName: String, + components: DateComponents, + title: String, + body: String, + identifier: String + ) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default - + - guard let triggerDate = Calendar.current.date(byAdding: .minute, value: -minutesBefore, to: date) else { return } - let triggerComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) - let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false) - - let identifier = "\(lectureName)-\(title)-\(triggerDate.timeIntervalSince1970)" let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in if let error = error { - print("Error scheduling notification for \(lectureName): \(error.localizedDescription)") + print("Error scheduling recurring notification for \(lectureName): \(error.localizedDescription)") } else { - print("Successfully scheduled notification: '\(title)' for \(lectureName)") + print("Successfully scheduled recurring notification: '\(title)' for \(lectureName) on weekday \(components.weekday ?? 0)") } } } diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 0782046..52ba40e 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -55,6 +55,10 @@ struct TimeTableView: View { .onChange(of: scenePhase) { _, newPhase in handleScenePhaseChange(newPhase) } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("RefreshTimetableFromSettings"))) { _ in + logger.debug("Received refresh notification from settings") + loadTimetable() + } } @ViewBuilder diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index f96f4d6..fcfbfdf 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -119,9 +119,8 @@ struct UserProfileSidebar: View { .frame(width: UIScreen.main.bounds.width * 0.75, alignment: .leading) .frame(maxHeight: .infinity) .background(Color("Background")) - .transition(.move(edge: .trailing)) + .transition(.move(edge: .trailing).combined(with: .opacity)) } - .animation(.easeInOut(duration: 0.3), value: isPresented) .sheet(isPresented: $showSupportDialog) { SupportDialog() } diff --git a/VITTY/VittyWidget/AppIntent.swift b/VITTY/VittyWidget/AppIntent.swift index 23db796..70c9844 100644 --- a/VITTY/VittyWidget/AppIntent.swift +++ b/VITTY/VittyWidget/AppIntent.swift @@ -16,3 +16,4 @@ struct ConfigurationAppIntent: WidgetConfigurationIntent { @Parameter(title: "Favorite Emoji", default: "😃") var favoriteEmoji: String } + diff --git a/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift b/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift index c2935b8..6611a94 100644 --- a/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift +++ b/VITTY/VittyWidget/Control/EntryControlViews/ScheduleEntryControlView.swift @@ -13,23 +13,34 @@ struct VittyWidgetEntryView: View { @Environment(\.widgetFamily) var family var body: some View { - ZStack { - Color(hex: "#041727") - .ignoresSafeArea() + switch family { + case .accessoryCircular: + CircularLockScreenWidgetView(entry: entry) + + case .accessoryRectangular: + RectangularLockScreenWidgetView(entry: entry) + + case .accessoryInline: + InlineLockScreenWidgetView(entry: entry) - switch family { - case .systemSmall: - ScheduleSmallWidgetView(entry: entry) - case .systemMedium: - ScheduleMediumWidgetView(entry: entry) - case .systemLarge: - ScheduleLargeWidgetView(entry: entry) + default: + ZStack { + Color(hex: "#041727") + .ignoresSafeArea() - default: - Text("Unsupported size") + switch family { + case .systemSmall: + ScheduleSmallWidgetView(entry: entry) + case .systemMedium: + ScheduleMediumWidgetView(entry: entry) + case .systemLarge: + ScheduleLargeWidgetView(entry: entry) + default: + EmptyView() + } } + } - .containerBackground(for: .widget) { Color(hex: "#041727") } } } @@ -44,6 +55,13 @@ struct VittyWidget: Widget { } .configurationDisplayName("Vitty Widget") .description("Widget with different designs based on size.") - .supportedFamilies([.systemSmall, .systemMedium,.systemLarge]) + .supportedFamilies([ + .systemSmall, + .systemMedium, + .systemLarge, + .accessoryCircular, + .accessoryRectangular, + .accessoryInline + ]) } } diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index fa82c4d..99a92f6 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -67,6 +67,8 @@ struct LargeDueWidgetView: View { } } + + struct ScheduleLargeWidgetView: View { var entry: ScheduleEntry diff --git a/VITTY/VittyWidget/Views/LockScreenWidgets.swift b/VITTY/VittyWidget/Views/LockScreenWidgets.swift new file mode 100644 index 0000000..32cc50f --- /dev/null +++ b/VITTY/VittyWidget/Views/LockScreenWidgets.swift @@ -0,0 +1,342 @@ +// +// LockScreenWidgets.swift +// VittyWidget +// +// Created on 6/12/25. +// + +import SwiftUI +import WidgetKit + + +struct CircularLockScreenWidgetView: View { + var entry: ScheduleEntry + + var body: some View { + ZStack { + + Circle() + .stroke(.white.opacity(0.2), lineWidth: 4.5) + + + if entry.total > 0 { + let progress = CGFloat(entry.completed) / CGFloat(entry.total) + Circle() + .trim(from: 0, to: progress) + .stroke( + .tint, + style: StrokeStyle(lineWidth: 4.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + } + + + VStack(spacing: 1) { + if let nextClass = entry.classes.first { + + Text(formatTime(nextClass.time)) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.6) + + + if entry.total > 0 { + Text("\(entry.completed)/\(entry.total)") + .font(.system(size: 7, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + } else if entry.completed == entry.total && entry.total > 0 { + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(.tint) + + Text("Done") + .font(.system(size: 7, weight: .bold, design: .rounded)) + .foregroundStyle(.secondary) + } else { + + Image(systemName: "calendar") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + } + } + } + + private func formatTime(_ timeString: String) -> String { + let components = timeString.components(separatedBy: " - ") + guard let startTime = components.first else { return "" } + return startTime + } +} + +// MARK: - Rectangular Lock Screen Widget +struct RectangularLockScreenWidgetView: View { + var entry: ScheduleEntry + + var body: some View { + HStack(spacing: 10) { + + VStack(spacing: 1) { + if entry.total > 0 { + ZStack { + + Circle() + .stroke(.white.opacity(0.15), lineWidth: 3.5) + .frame(width: 26, height: 26) + + + Circle() + .trim(from: 0, to: CGFloat(entry.completed) / CGFloat(entry.total)) + .stroke( + .tint, + style: StrokeStyle(lineWidth: 3.5, lineCap: .round) + ) + .frame(width: 26, height: 26) + .rotationEffect(.degrees(-90)) + + + Text("\(entry.completed)") + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + } + + + Text("/\(entry.total)") + .font(.system(size: 7, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } else { + Image(systemName: "calendar") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } + .frame(width: 32) + + + VStack(alignment: .leading, spacing: 3) { + if let currentClass = getCurrentClass() { + + HStack(spacing: 5) { + Circle() + .fill(.tint) + .frame(width: 5, height: 5) + + Text("Now: \(currentClass.title)") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + } + + if let nextClass = getNextUpcomingClass() { + Text("Next: \(nextClass.title) • \(formatTime(nextClass.time))") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text("\(formatTime(currentClass.time))") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } else if let nextClass = entry.classes.first { + + Text(nextClass.title) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.system(size: 8)) + Text("\(formatTime(nextClass.time))") + .font(.system(size: 10, weight: .medium, design: .rounded)) + } + .foregroundStyle(.secondary) + .lineLimit(1) + } else if entry.completed == entry.total && entry.total > 0 { + + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10)) + Text("All \(entry.total) classes completed") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + } + .foregroundStyle(.primary) + .lineLimit(1) + + Text("Great work today! 🎉") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + // No classes + HStack(spacing: 4) { + Image(systemName: "calendar.badge.plus") + .font(.system(size: 10)) + Text("No classes today") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + } + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 6) + } + + private func getCurrentClass() -> Classes? { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + let calendar = Calendar.current + let now = Date() + + return entry.classes.first { classItem in + let components = classItem.time.components(separatedBy: " - ") + guard components.count == 2, + let startTime = formatter.date(from: components[0]), + let endTime = formatter.date(from: components[1]) else { + return false + } + + let startToday = calendar.date( + bySettingHour: calendar.component(.hour, from: startTime), + minute: calendar.component(.minute, from: startTime), + second: 0, + of: now + ) + + let endToday = calendar.date( + bySettingHour: calendar.component(.hour, from: endTime), + minute: calendar.component(.minute, from: endTime), + second: 0, + of: now + ) + + guard let start = startToday, let end = endToday else { return false } + return now >= start && now <= end + } + } + + private func getNextUpcomingClass() -> Classes? { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + let calendar = Calendar.current + let now = Date() + + let upcoming = entry.classes.filter { classItem in + let components = classItem.time.components(separatedBy: " - ") + guard components.count == 2, + let endTime = formatter.date(from: components[1]) else { + return false + } + + let endToday = calendar.date( + bySettingHour: calendar.component(.hour, from: endTime), + minute: calendar.component(.minute, from: endTime), + second: 0, + of: now + ) + + guard let end = endToday else { return false } + return now < end + } + + return upcoming.first + } + + private func formatTime(_ timeString: String) -> String { + let components = timeString.components(separatedBy: " - ") + guard let startTime = components.first else { return "" } + return startTime + } +} + +// MARK: - Inline Lock Screen Widget +struct InlineLockScreenWidgetView: View { + var entry: ScheduleEntry + + var body: some View { + HStack(spacing: 5) { + if let currentClass = getCurrentClass() { + Image(systemName: "clock.fill") + .font(.system(size: 10)) + .foregroundStyle(.tint) + Text("\(currentClass.title) • \(formatTime(currentClass.time))") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + } else if let nextClass = entry.classes.first { + Image(systemName: "calendar") + .font(.system(size: 10)) + .foregroundStyle(.tint) + Text("Next: \(nextClass.title) at \(formatTime(nextClass.time))") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + } else if entry.completed == entry.total && entry.total > 0 { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10)) + .foregroundStyle(.tint) + Text("All \(entry.total) classes completed") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + } else { + Image(systemName: "calendar.badge.plus") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Text("No classes today") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + private func getCurrentClass() -> Classes? { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + let calendar = Calendar.current + let now = Date() + + return entry.classes.first { classItem in + let components = classItem.time.components(separatedBy: " - ") + guard components.count == 2, + let startTime = formatter.date(from: components[0]), + let endTime = formatter.date(from: components[1]) else { + return false + } + + let startToday = calendar.date( + bySettingHour: calendar.component(.hour, from: startTime), + minute: calendar.component(.minute, from: startTime), + second: 0, + of: now + ) + + let endToday = calendar.date( + bySettingHour: calendar.component(.hour, from: endTime), + minute: calendar.component(.minute, from: endTime), + second: 0, + of: now + ) + + guard let start = startToday, let end = endToday else { return false } + return now >= start && now <= end + } + } + + private func formatTime(_ timeString: String) -> String { + let components = timeString.components(separatedBy: " - ") + guard let startTime = components.first else { return "" } + return startTime + } +} + diff --git a/VITTY/VittyWidget/Views/SmallWidget.swift b/VITTY/VittyWidget/Views/SmallWidget.swift index 445c285..a1cafa9 100644 --- a/VITTY/VittyWidget/Views/SmallWidget.swift +++ b/VITTY/VittyWidget/Views/SmallWidget.swift @@ -5,13 +5,6 @@ // Created by Rujin Devkota on 2/25/25. // -// -// SmallWidget.swift -// VITTY -// -// Created by Rujin Devkota on 2/25/25. -// - import WidgetKit import SwiftUI