Sfoglia il codice sorgente

Add scheduled meeting reminders with settings toggles and lifecycle sync.

Mirror classroom-style reminder behavior by storing reminder preferences, scheduling notifications for upcoming meetings, and keeping reminders in sync on premium changes, schedule refreshes, and sign out.

Made-with: Cursor
huzaifahayat12 3 mesi fa
parent
commit
3c183cd85c

+ 24 - 0
meetings_app/AppDelegate.swift

@@ -6,6 +6,7 @@
 //
 
 import Cocoa
+import UserNotifications
 
 @main
 class AppDelegate: NSObject, NSApplicationDelegate {
@@ -17,6 +18,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         let darkEnabled = systemPrefersDarkMode()
         UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
         NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
+        UNUserNotificationCenter.current().delegate = self
+        MeetingReminderManager.shared.requestPermissionIfNeeded()
     }
 
     func applicationWillTerminate(_ aNotification: Notification) {
@@ -37,3 +40,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
 
 }
 
+extension AppDelegate: UNUserNotificationCenterDelegate {
+
+    func userNotificationCenter(
+        _ center: UNUserNotificationCenter,
+        didReceive response: UNNotificationResponse,
+        withCompletionHandler completionHandler: @escaping () -> Void
+    ) {
+        NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
+        NSApp.activate(ignoringOtherApps: true)
+        completionHandler()
+    }
+
+    func userNotificationCenter(
+        _ center: UNUserNotificationCenter,
+        willPresent notification: UNNotification,
+        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+    ) {
+        completionHandler([.banner, .sound])
+    }
+}
+

+ 89 - 0
meetings_app/Reminders/MeetingReminderManager.swift

@@ -0,0 +1,89 @@
+import Foundation
+import UserNotifications
+
+final class MeetingReminderManager: NSObject {
+
+    static let shared = MeetingReminderManager()
+
+    private let center = UNUserNotificationCenter.current()
+
+    private struct Interval {
+        let offset: TimeInterval
+        let label: String
+        let suffix: String
+        let isEnabled: () -> Bool
+    }
+
+    private var intervals: [Interval] {
+        [
+            Interval(offset: 86400, label: "Starts in 1 day", suffix: "1d", isEnabled: { ReminderPreferences.remind1Day }),
+            Interval(offset: 43200, label: "Starts in 12 hours", suffix: "12h", isEnabled: { ReminderPreferences.remind12Hours }),
+            Interval(offset: 3600, label: "Starts in 1 hour", suffix: "1h", isEnabled: { ReminderPreferences.remind1Hour })
+        ]
+    }
+
+    private override init() { super.init() }
+
+    func requestPermissionIfNeeded(completion: ((Bool) -> Void)? = nil) {
+        center.getNotificationSettings { [weak self] settings in
+            guard let self else { return }
+            switch settings.authorizationStatus {
+            case .notDetermined:
+                self.center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
+                    DispatchQueue.main.async { completion?(granted) }
+                }
+            case .authorized, .provisional, .ephemeral:
+                DispatchQueue.main.async { completion?(true) }
+            default:
+                DispatchQueue.main.async { completion?(false) }
+            }
+        }
+    }
+
+    func scheduleReminders(for meetings: [ScheduledMeeting]) {
+        guard ReminderPreferences.remindersEnabled else {
+            cancelAllReminders()
+            return
+        }
+
+        let now = Date()
+        var requests: [UNNotificationRequest] = []
+
+        for meeting in meetings {
+            let title = meeting.title.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !title.isEmpty else { continue }
+
+            for interval in intervals where interval.isEnabled() {
+                let triggerDate = meeting.startDate.addingTimeInterval(-interval.offset)
+                guard triggerDate > now else { continue }
+
+                let content = UNMutableNotificationContent()
+                content.title = "📅 \(title)"
+                content.subtitle = meeting.subtitle ?? "Google Meet"
+                content.body = interval.label
+                content.sound = .default
+
+                let components = Calendar.current.dateComponents(
+                    [.year, .month, .day, .hour, .minute, .second],
+                    from: triggerDate
+                )
+                let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
+                let id = notificationID(meetingID: meeting.id, suffix: interval.suffix)
+                requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
+            }
+        }
+
+        center.removeAllPendingNotificationRequests()
+        for request in requests {
+            center.add(request, withCompletionHandler: nil)
+        }
+    }
+
+    func cancelAllReminders() {
+        center.removeAllPendingNotificationRequests()
+    }
+
+    private func notificationID(meetingID: String, suffix: String) -> String {
+        "meeting.reminder.\(meetingID).\(suffix)"
+    }
+}

+ 41 - 0
meetings_app/Reminders/ReminderPreferences.swift

@@ -0,0 +1,41 @@
+import Foundation
+
+struct ReminderPreferences {
+
+    private static let enabledKey = "reminders.enabled"
+    private static let day1Key = "reminders.1day"
+    private static let hours12Key = "reminders.12hours"
+    private static let hour1Key = "reminders.1hour"
+
+    static var remindersEnabled: Bool {
+        get {
+            let stored = UserDefaults.standard.object(forKey: enabledKey)
+            return stored != nil ? UserDefaults.standard.bool(forKey: enabledKey) : true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
+    }
+
+    static var remind1Day: Bool {
+        get {
+            let stored = UserDefaults.standard.object(forKey: day1Key)
+            return stored != nil ? UserDefaults.standard.bool(forKey: day1Key) : true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: day1Key) }
+    }
+
+    static var remind12Hours: Bool {
+        get {
+            let stored = UserDefaults.standard.object(forKey: hours12Key)
+            return stored != nil ? UserDefaults.standard.bool(forKey: hours12Key) : true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: hours12Key) }
+    }
+
+    static var remind1Hour: Bool {
+        get {
+            let stored = UserDefaults.standard.object(forKey: hour1Key)
+            return stored != nil ? UserDefaults.standard.bool(forKey: hour1Key) : true
+        }
+        set { UserDefaults.standard.set(newValue, forKey: hour1Key) }
+    }
+}

+ 148 - 0
meetings_app/ViewController.swift

@@ -334,6 +334,10 @@ final class ViewController: NSViewController {
     private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint?
     private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
     private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
+    private weak var settingsReminderMasterSwitch: NSSwitch?
+    private weak var settingsReminder1DaySwitch: NSSwitch?
+    private weak var settingsReminder12HoursSwitch: NSSwitch?
+    private weak var settingsReminder1HourSwitch: NSSwitch?
     /// Circular avatar size when signed in (top-right, Google-style).
     private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
     private var scheduleGoogleAuthHovering = false
@@ -1465,8 +1469,13 @@ private extension ViewController {
             if hasCompletedInitialStoreKitSync {
                 scheduleRatingPromptAfterPremiumUpgrade()
             }
+            MeetingReminderManager.shared.requestPermissionIfNeeded { [weak self] _ in
+                guard let self else { return }
+                MeetingReminderManager.shared.scheduleReminders(for: self.scheduleCachedMeetings)
+            }
         }
         if hadPremiumAccess && !hasPremiumAccess {
+            MeetingReminderManager.shared.cancelAllReminders()
             showPaywall()
         }
     }
@@ -1833,6 +1842,15 @@ private extension ViewController {
             stack.setCustomSpacing(24, after: shareButton)
         }
 
+        if storeKitCoordinator.hasPremiumAccess {
+            let notificationsTitle = textLabel("Notifications", font: typography.joinWithURLTitle, color: palette.textPrimary)
+            stack.addArrangedSubview(notificationsTitle)
+            let remindersSection = makeSettingsRemindersSection()
+            stack.addArrangedSubview(remindersSection)
+            remindersSection.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+            stack.setCustomSpacing(24, after: remindersSection)
+        }
+
         let legalTitle = textLabel("Help & Legal", font: typography.joinWithURLTitle, color: palette.textPrimary)
         stack.addArrangedSubview(legalTitle)
         let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
@@ -1901,6 +1919,130 @@ private extension ViewController {
         return row
     }
 
+    private func makeSettingsRemindersSection() -> NSView {
+        let container = NSStackView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        container.orientation = .vertical
+        container.spacing = 1
+        container.alignment = .leading
+
+        let masterRow = makeReminderToggleRow(
+            icon: "🔔",
+            title: "Reminders",
+            state: ReminderPreferences.remindersEnabled,
+            action: #selector(settingsReminderMasterToggled(_:)),
+            isSubRow: false
+        )
+        container.addArrangedSubview(masterRow)
+        masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        let subAlpha: CGFloat = ReminderPreferences.remindersEnabled ? 1.0 : 0.4
+
+        let day1Row = makeReminderToggleRow(
+            icon: "📅",
+            title: "1 Day Before",
+            state: ReminderPreferences.remind1Day,
+            action: #selector(settingsReminder1DayToggled(_:)),
+            isSubRow: true
+        )
+        day1Row.alphaValue = subAlpha
+        container.addArrangedSubview(day1Row)
+        day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        let hours12Row = makeReminderToggleRow(
+            icon: "🕛",
+            title: "12 Hours Before",
+            state: ReminderPreferences.remind12Hours,
+            action: #selector(settingsReminder12HoursToggled(_:)),
+            isSubRow: true
+        )
+        hours12Row.alphaValue = subAlpha
+        container.addArrangedSubview(hours12Row)
+        hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        let hour1Row = makeReminderToggleRow(
+            icon: "⏰",
+            title: "1 Hour Before",
+            state: ReminderPreferences.remind1Hour,
+            action: #selector(settingsReminder1HourToggled(_:)),
+            isSubRow: true
+        )
+        hour1Row.alphaValue = subAlpha
+        container.addArrangedSubview(hour1Row)
+        hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+
+        settingsReminderMasterSwitch = masterRow.subviews.compactMap { $0 as? NSSwitch }.first
+        settingsReminder1DaySwitch = day1Row.subviews.compactMap { $0 as? NSSwitch }.first
+        settingsReminder12HoursSwitch = hours12Row.subviews.compactMap { $0 as? NSSwitch }.first
+        settingsReminder1HourSwitch = hour1Row.subviews.compactMap { $0 as? NSSwitch }.first
+
+        return container
+    }
+
+    private func makeReminderToggleRow(icon: String, title: String, state: Bool, action: Selector, isSubRow: Bool) -> NSView {
+        let row = roundedContainer(cornerRadius: 10, color: isSubRow ? palette.inputBackground.withAlphaComponent(0.6) : palette.inputBackground)
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.heightAnchor.constraint(equalToConstant: 48).isActive = true
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: isSubRow ? 15 : 17, weight: .medium), color: palette.textPrimary)
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: isSubRow ? 14 : 15, weight: isSubRow ? .regular : .semibold), color: palette.textPrimary)
+        if isSubRow {
+            titleLabel.textColor = palette.textSecondary
+        }
+
+        let toggle = NSSwitch()
+        toggle.translatesAutoresizingMaskIntoConstraints = false
+        toggle.state = state ? .on : .off
+        toggle.target = self
+        toggle.action = action
+
+        row.addSubview(iconLabel)
+        row.addSubview(titleLabel)
+        row.addSubview(toggle)
+        NSLayoutConstraint.activate([
+            iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: isSubRow ? 30 : 14),
+            iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+            titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
+            titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+            toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+            toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
+        ])
+        return row
+    }
+
+    @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
+        let enabled = sender.state == .on
+        ReminderPreferences.remindersEnabled = enabled
+        let subAlpha: CGFloat = enabled ? 1.0 : 0.4
+        settingsReminder1DaySwitch?.superview?.alphaValue = subAlpha
+        settingsReminder12HoursSwitch?.superview?.alphaValue = subAlpha
+        settingsReminder1HourSwitch?.superview?.alphaValue = subAlpha
+        if enabled {
+            MeetingReminderManager.shared.requestPermissionIfNeeded { [weak self] _ in
+                guard let self else { return }
+                MeetingReminderManager.shared.scheduleReminders(for: self.scheduleCachedMeetings)
+            }
+        } else {
+            MeetingReminderManager.shared.cancelAllReminders()
+        }
+    }
+
+    @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
+        ReminderPreferences.remind1Day = sender.state == .on
+        MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
+    }
+
+    @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
+        ReminderPreferences.remind12Hours = sender.state == .on
+        MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
+    }
+
+    @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
+        ReminderPreferences.remind1Hour = sender.state == .on
+        MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
+    }
+
     private func makeSettingsGoogleAccountRow() -> NSView {
         let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -6503,6 +6645,7 @@ private extension ViewController {
                         renderScheduleCards(into: stack, meetings: [])
                     }
                     scheduleCachedMeetings = []
+                    MeetingReminderManager.shared.cancelAllReminders()
                     applySchedulePageFiltersAndRender()
                     if calendarPageGridStack != nil {
                         calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6526,6 +6669,9 @@ private extension ViewController {
                     renderScheduleCards(into: stack, meetings: filtered)
                 }
                 scheduleCachedMeetings = meetings
+                if storeKitCoordinator.hasPremiumAccess {
+                    MeetingReminderManager.shared.scheduleReminders(for: meetings)
+                }
                 applySchedulePageFiltersAndRender()
                 if calendarPageGridStack != nil {
                     calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6545,6 +6691,7 @@ private extension ViewController {
                     renderScheduleCards(into: stack, meetings: [])
                 }
                 scheduleCachedMeetings = []
+                MeetingReminderManager.shared.cancelAllReminders()
                 applySchedulePageFiltersAndRender()
                 if calendarPageGridStack != nil {
                     calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6648,6 +6795,7 @@ private extension ViewController {
 
     private func performGoogleSignOut() {
         do {
+            MeetingReminderManager.shared.cancelAllReminders()
             try googleOAuth.signOut()
             applyGoogleProfile(nil)
             updateGoogleAuthButtonTitle()