Kaynağa Gözat

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 1 ay önce
ebeveyn
işleme
3c183cd85c

+ 24 - 0
meetings_app/AppDelegate.swift

@@ -6,6 +6,7 @@
6 6
 //
7 7
 
8 8
 import Cocoa
9
+import UserNotifications
9 10
 
10 11
 @main
11 12
 class AppDelegate: NSObject, NSApplicationDelegate {
@@ -17,6 +18,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
17 18
         let darkEnabled = systemPrefersDarkMode()
18 19
         UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
19 20
         NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
21
+        UNUserNotificationCenter.current().delegate = self
22
+        MeetingReminderManager.shared.requestPermissionIfNeeded()
20 23
     }
21 24
 
22 25
     func applicationWillTerminate(_ aNotification: Notification) {
@@ -37,3 +40,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
37 40
 
38 41
 }
39 42
 
43
+extension AppDelegate: UNUserNotificationCenterDelegate {
44
+
45
+    func userNotificationCenter(
46
+        _ center: UNUserNotificationCenter,
47
+        didReceive response: UNNotificationResponse,
48
+        withCompletionHandler completionHandler: @escaping () -> Void
49
+    ) {
50
+        NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
51
+        NSApp.activate(ignoringOtherApps: true)
52
+        completionHandler()
53
+    }
54
+
55
+    func userNotificationCenter(
56
+        _ center: UNUserNotificationCenter,
57
+        willPresent notification: UNNotification,
58
+        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
59
+    ) {
60
+        completionHandler([.banner, .sound])
61
+    }
62
+}
63
+

+ 89 - 0
meetings_app/Reminders/MeetingReminderManager.swift

@@ -0,0 +1,89 @@
1
+import Foundation
2
+import UserNotifications
3
+
4
+final class MeetingReminderManager: NSObject {
5
+
6
+    static let shared = MeetingReminderManager()
7
+
8
+    private let center = UNUserNotificationCenter.current()
9
+
10
+    private struct Interval {
11
+        let offset: TimeInterval
12
+        let label: String
13
+        let suffix: String
14
+        let isEnabled: () -> Bool
15
+    }
16
+
17
+    private var intervals: [Interval] {
18
+        [
19
+            Interval(offset: 86400, label: "Starts in 1 day", suffix: "1d", isEnabled: { ReminderPreferences.remind1Day }),
20
+            Interval(offset: 43200, label: "Starts in 12 hours", suffix: "12h", isEnabled: { ReminderPreferences.remind12Hours }),
21
+            Interval(offset: 3600, label: "Starts in 1 hour", suffix: "1h", isEnabled: { ReminderPreferences.remind1Hour })
22
+        ]
23
+    }
24
+
25
+    private override init() { super.init() }
26
+
27
+    func requestPermissionIfNeeded(completion: ((Bool) -> Void)? = nil) {
28
+        center.getNotificationSettings { [weak self] settings in
29
+            guard let self else { return }
30
+            switch settings.authorizationStatus {
31
+            case .notDetermined:
32
+                self.center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
33
+                    DispatchQueue.main.async { completion?(granted) }
34
+                }
35
+            case .authorized, .provisional, .ephemeral:
36
+                DispatchQueue.main.async { completion?(true) }
37
+            default:
38
+                DispatchQueue.main.async { completion?(false) }
39
+            }
40
+        }
41
+    }
42
+
43
+    func scheduleReminders(for meetings: [ScheduledMeeting]) {
44
+        guard ReminderPreferences.remindersEnabled else {
45
+            cancelAllReminders()
46
+            return
47
+        }
48
+
49
+        let now = Date()
50
+        var requests: [UNNotificationRequest] = []
51
+
52
+        for meeting in meetings {
53
+            let title = meeting.title.trimmingCharacters(in: .whitespacesAndNewlines)
54
+            guard !title.isEmpty else { continue }
55
+
56
+            for interval in intervals where interval.isEnabled() {
57
+                let triggerDate = meeting.startDate.addingTimeInterval(-interval.offset)
58
+                guard triggerDate > now else { continue }
59
+
60
+                let content = UNMutableNotificationContent()
61
+                content.title = "📅 \(title)"
62
+                content.subtitle = meeting.subtitle ?? "Google Meet"
63
+                content.body = interval.label
64
+                content.sound = .default
65
+
66
+                let components = Calendar.current.dateComponents(
67
+                    [.year, .month, .day, .hour, .minute, .second],
68
+                    from: triggerDate
69
+                )
70
+                let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
71
+                let id = notificationID(meetingID: meeting.id, suffix: interval.suffix)
72
+                requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
73
+            }
74
+        }
75
+
76
+        center.removeAllPendingNotificationRequests()
77
+        for request in requests {
78
+            center.add(request, withCompletionHandler: nil)
79
+        }
80
+    }
81
+
82
+    func cancelAllReminders() {
83
+        center.removeAllPendingNotificationRequests()
84
+    }
85
+
86
+    private func notificationID(meetingID: String, suffix: String) -> String {
87
+        "meeting.reminder.\(meetingID).\(suffix)"
88
+    }
89
+}

+ 41 - 0
meetings_app/Reminders/ReminderPreferences.swift

@@ -0,0 +1,41 @@
1
+import Foundation
2
+
3
+struct ReminderPreferences {
4
+
5
+    private static let enabledKey = "reminders.enabled"
6
+    private static let day1Key = "reminders.1day"
7
+    private static let hours12Key = "reminders.12hours"
8
+    private static let hour1Key = "reminders.1hour"
9
+
10
+    static var remindersEnabled: Bool {
11
+        get {
12
+            let stored = UserDefaults.standard.object(forKey: enabledKey)
13
+            return stored != nil ? UserDefaults.standard.bool(forKey: enabledKey) : true
14
+        }
15
+        set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
16
+    }
17
+
18
+    static var remind1Day: Bool {
19
+        get {
20
+            let stored = UserDefaults.standard.object(forKey: day1Key)
21
+            return stored != nil ? UserDefaults.standard.bool(forKey: day1Key) : true
22
+        }
23
+        set { UserDefaults.standard.set(newValue, forKey: day1Key) }
24
+    }
25
+
26
+    static var remind12Hours: Bool {
27
+        get {
28
+            let stored = UserDefaults.standard.object(forKey: hours12Key)
29
+            return stored != nil ? UserDefaults.standard.bool(forKey: hours12Key) : true
30
+        }
31
+        set { UserDefaults.standard.set(newValue, forKey: hours12Key) }
32
+    }
33
+
34
+    static var remind1Hour: Bool {
35
+        get {
36
+            let stored = UserDefaults.standard.object(forKey: hour1Key)
37
+            return stored != nil ? UserDefaults.standard.bool(forKey: hour1Key) : true
38
+        }
39
+        set { UserDefaults.standard.set(newValue, forKey: hour1Key) }
40
+    }
41
+}

+ 148 - 0
meetings_app/ViewController.swift

@@ -334,6 +334,10 @@ final class ViewController: NSViewController {
334 334
     private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint?
335 335
     private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
336 336
     private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
337
+    private weak var settingsReminderMasterSwitch: NSSwitch?
338
+    private weak var settingsReminder1DaySwitch: NSSwitch?
339
+    private weak var settingsReminder12HoursSwitch: NSSwitch?
340
+    private weak var settingsReminder1HourSwitch: NSSwitch?
337 341
     /// Circular avatar size when signed in (top-right, Google-style).
338 342
     private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
339 343
     private var scheduleGoogleAuthHovering = false
@@ -1465,8 +1469,13 @@ private extension ViewController {
1465 1469
             if hasCompletedInitialStoreKitSync {
1466 1470
                 scheduleRatingPromptAfterPremiumUpgrade()
1467 1471
             }
1472
+            MeetingReminderManager.shared.requestPermissionIfNeeded { [weak self] _ in
1473
+                guard let self else { return }
1474
+                MeetingReminderManager.shared.scheduleReminders(for: self.scheduleCachedMeetings)
1475
+            }
1468 1476
         }
1469 1477
         if hadPremiumAccess && !hasPremiumAccess {
1478
+            MeetingReminderManager.shared.cancelAllReminders()
1470 1479
             showPaywall()
1471 1480
         }
1472 1481
     }
@@ -1833,6 +1842,15 @@ private extension ViewController {
1833 1842
             stack.setCustomSpacing(24, after: shareButton)
1834 1843
         }
1835 1844
 
1845
+        if storeKitCoordinator.hasPremiumAccess {
1846
+            let notificationsTitle = textLabel("Notifications", font: typography.joinWithURLTitle, color: palette.textPrimary)
1847
+            stack.addArrangedSubview(notificationsTitle)
1848
+            let remindersSection = makeSettingsRemindersSection()
1849
+            stack.addArrangedSubview(remindersSection)
1850
+            remindersSection.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
1851
+            stack.setCustomSpacing(24, after: remindersSection)
1852
+        }
1853
+
1836 1854
         let legalTitle = textLabel("Help & Legal", font: typography.joinWithURLTitle, color: palette.textPrimary)
1837 1855
         stack.addArrangedSubview(legalTitle)
1838 1856
         let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
@@ -1901,6 +1919,130 @@ private extension ViewController {
1901 1919
         return row
1902 1920
     }
1903 1921
 
1922
+    private func makeSettingsRemindersSection() -> NSView {
1923
+        let container = NSStackView()
1924
+        container.translatesAutoresizingMaskIntoConstraints = false
1925
+        container.orientation = .vertical
1926
+        container.spacing = 1
1927
+        container.alignment = .leading
1928
+
1929
+        let masterRow = makeReminderToggleRow(
1930
+            icon: "🔔",
1931
+            title: "Reminders",
1932
+            state: ReminderPreferences.remindersEnabled,
1933
+            action: #selector(settingsReminderMasterToggled(_:)),
1934
+            isSubRow: false
1935
+        )
1936
+        container.addArrangedSubview(masterRow)
1937
+        masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
1938
+
1939
+        let subAlpha: CGFloat = ReminderPreferences.remindersEnabled ? 1.0 : 0.4
1940
+
1941
+        let day1Row = makeReminderToggleRow(
1942
+            icon: "📅",
1943
+            title: "1 Day Before",
1944
+            state: ReminderPreferences.remind1Day,
1945
+            action: #selector(settingsReminder1DayToggled(_:)),
1946
+            isSubRow: true
1947
+        )
1948
+        day1Row.alphaValue = subAlpha
1949
+        container.addArrangedSubview(day1Row)
1950
+        day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
1951
+
1952
+        let hours12Row = makeReminderToggleRow(
1953
+            icon: "🕛",
1954
+            title: "12 Hours Before",
1955
+            state: ReminderPreferences.remind12Hours,
1956
+            action: #selector(settingsReminder12HoursToggled(_:)),
1957
+            isSubRow: true
1958
+        )
1959
+        hours12Row.alphaValue = subAlpha
1960
+        container.addArrangedSubview(hours12Row)
1961
+        hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
1962
+
1963
+        let hour1Row = makeReminderToggleRow(
1964
+            icon: "⏰",
1965
+            title: "1 Hour Before",
1966
+            state: ReminderPreferences.remind1Hour,
1967
+            action: #selector(settingsReminder1HourToggled(_:)),
1968
+            isSubRow: true
1969
+        )
1970
+        hour1Row.alphaValue = subAlpha
1971
+        container.addArrangedSubview(hour1Row)
1972
+        hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
1973
+
1974
+        settingsReminderMasterSwitch = masterRow.subviews.compactMap { $0 as? NSSwitch }.first
1975
+        settingsReminder1DaySwitch = day1Row.subviews.compactMap { $0 as? NSSwitch }.first
1976
+        settingsReminder12HoursSwitch = hours12Row.subviews.compactMap { $0 as? NSSwitch }.first
1977
+        settingsReminder1HourSwitch = hour1Row.subviews.compactMap { $0 as? NSSwitch }.first
1978
+
1979
+        return container
1980
+    }
1981
+
1982
+    private func makeReminderToggleRow(icon: String, title: String, state: Bool, action: Selector, isSubRow: Bool) -> NSView {
1983
+        let row = roundedContainer(cornerRadius: 10, color: isSubRow ? palette.inputBackground.withAlphaComponent(0.6) : palette.inputBackground)
1984
+        row.translatesAutoresizingMaskIntoConstraints = false
1985
+        row.heightAnchor.constraint(equalToConstant: 48).isActive = true
1986
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1987
+
1988
+        let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: isSubRow ? 15 : 17, weight: .medium), color: palette.textPrimary)
1989
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: isSubRow ? 14 : 15, weight: isSubRow ? .regular : .semibold), color: palette.textPrimary)
1990
+        if isSubRow {
1991
+            titleLabel.textColor = palette.textSecondary
1992
+        }
1993
+
1994
+        let toggle = NSSwitch()
1995
+        toggle.translatesAutoresizingMaskIntoConstraints = false
1996
+        toggle.state = state ? .on : .off
1997
+        toggle.target = self
1998
+        toggle.action = action
1999
+
2000
+        row.addSubview(iconLabel)
2001
+        row.addSubview(titleLabel)
2002
+        row.addSubview(toggle)
2003
+        NSLayoutConstraint.activate([
2004
+            iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: isSubRow ? 30 : 14),
2005
+            iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
2006
+            titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
2007
+            titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
2008
+            toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
2009
+            toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
2010
+        ])
2011
+        return row
2012
+    }
2013
+
2014
+    @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
2015
+        let enabled = sender.state == .on
2016
+        ReminderPreferences.remindersEnabled = enabled
2017
+        let subAlpha: CGFloat = enabled ? 1.0 : 0.4
2018
+        settingsReminder1DaySwitch?.superview?.alphaValue = subAlpha
2019
+        settingsReminder12HoursSwitch?.superview?.alphaValue = subAlpha
2020
+        settingsReminder1HourSwitch?.superview?.alphaValue = subAlpha
2021
+        if enabled {
2022
+            MeetingReminderManager.shared.requestPermissionIfNeeded { [weak self] _ in
2023
+                guard let self else { return }
2024
+                MeetingReminderManager.shared.scheduleReminders(for: self.scheduleCachedMeetings)
2025
+            }
2026
+        } else {
2027
+            MeetingReminderManager.shared.cancelAllReminders()
2028
+        }
2029
+    }
2030
+
2031
+    @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
2032
+        ReminderPreferences.remind1Day = sender.state == .on
2033
+        MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
2034
+    }
2035
+
2036
+    @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
2037
+        ReminderPreferences.remind12Hours = sender.state == .on
2038
+        MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
2039
+    }
2040
+
2041
+    @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
2042
+        ReminderPreferences.remind1Hour = sender.state == .on
2043
+        MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
2044
+    }
2045
+
1904 2046
     private func makeSettingsGoogleAccountRow() -> NSView {
1905 2047
         let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
1906 2048
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -6503,6 +6645,7 @@ private extension ViewController {
6503 6645
                         renderScheduleCards(into: stack, meetings: [])
6504 6646
                     }
6505 6647
                     scheduleCachedMeetings = []
6648
+                    MeetingReminderManager.shared.cancelAllReminders()
6506 6649
                     applySchedulePageFiltersAndRender()
6507 6650
                     if calendarPageGridStack != nil {
6508 6651
                         calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6526,6 +6669,9 @@ private extension ViewController {
6526 6669
                     renderScheduleCards(into: stack, meetings: filtered)
6527 6670
                 }
6528 6671
                 scheduleCachedMeetings = meetings
6672
+                if storeKitCoordinator.hasPremiumAccess {
6673
+                    MeetingReminderManager.shared.scheduleReminders(for: meetings)
6674
+                }
6529 6675
                 applySchedulePageFiltersAndRender()
6530 6676
                 if calendarPageGridStack != nil {
6531 6677
                     calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6545,6 +6691,7 @@ private extension ViewController {
6545 6691
                     renderScheduleCards(into: stack, meetings: [])
6546 6692
                 }
6547 6693
                 scheduleCachedMeetings = []
6694
+                MeetingReminderManager.shared.cancelAllReminders()
6548 6695
                 applySchedulePageFiltersAndRender()
6549 6696
                 if calendarPageGridStack != nil {
6550 6697
                     calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6648,6 +6795,7 @@ private extension ViewController {
6648 6795
 
6649 6796
     private func performGoogleSignOut() {
6650 6797
         do {
6798
+            MeetingReminderManager.shared.cancelAllReminders()
6651 6799
             try googleOAuth.signOut()
6652 6800
             applyGoogleProfile(nil)
6653 6801
             updateGoogleAuthButtonTitle()