Jelajahi Sumber

Add menu bar status icon with top meetings

Creates a macOS status item menu for opening the app, signing in/out, and launching the top 3 upcoming meeting links. Keeps the menu in sync with auth state and the widget meetings snapshot.

Made-with: Cursor
huzaifahayat12 3 bulan lalu
induk
melakukan
bb9a2bf4a5

+ 2 - 0
meetings_app/AppDelegate.swift

@@ -11,6 +11,7 @@ import UserNotifications
 @main
 class AppDelegate: NSObject, NSApplicationDelegate {
     private let darkModeDefaultsKey = "settings.darkModeEnabled"
+    private var statusBarController: StatusBarController?
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
         // Always sync to current macOS appearance on launch.
@@ -21,6 +22,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         UNUserNotificationCenter.current().delegate = self
         MeetingReminderManager.shared.requestPermissionIfNeeded()
         DesktopWidgetWindowManager.shared.restore()
+        statusBarController = StatusBarController()
     }
 
     func applicationWillTerminate(_ aNotification: Notification) {

+ 129 - 0
meetings_app/StatusBar/StatusBarController.swift

@@ -0,0 +1,129 @@
+import AppKit
+
+@MainActor
+final class StatusBarController: NSObject {
+    private let authService = GoogleOAuthService.shared
+    private let statusItem: NSStatusItem
+    private var observers: [NSObjectProtocol] = []
+
+    override init() {
+        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+        super.init()
+
+        if let icon = NSImage(systemSymbolName: "calendar.badge.clock", accessibilityDescription: "Meetings") {
+            icon.isTemplate = true
+            statusItem.button?.image = icon
+        } else {
+            statusItem.button?.title = "Meetings"
+        }
+        statusItem.button?.setAccessibilityLabel("Meetings status menu")
+        rebuildMenu()
+
+        let center = NotificationCenter.default
+        observers.append(center.addObserver(forName: .meetingsSnapshotUpdated, object: nil, queue: .main) { [weak self] _ in
+            self?.rebuildMenu()
+        })
+        observers.append(center.addObserver(forName: .statusBarSignOutRequested, object: nil, queue: .main) { [weak self] _ in
+            self?.rebuildMenu()
+        })
+        observers.append(center.addObserver(forName: .widgetSignInRequested, object: nil, queue: .main) { [weak self] _ in
+            self?.rebuildMenu()
+        })
+    }
+
+    deinit {
+        observers.forEach { NotificationCenter.default.removeObserver($0) }
+    }
+
+    private func rebuildMenu() {
+        let menu = NSMenu()
+
+        let openAppItem = NSMenuItem(title: "Open App", action: #selector(openAppClicked), keyEquivalent: "")
+        openAppItem.target = self
+        menu.addItem(openAppItem)
+
+        let signedIn = authService.loadTokens() != nil
+        if signedIn {
+            let topMeetings = WidgetMeetingStore.load().prefix(3).map { $0 }
+            if topMeetings.isEmpty {
+                let empty = NSMenuItem(title: "No upcoming meetings", action: nil, keyEquivalent: "")
+                empty.isEnabled = false
+                menu.addItem(.separator())
+                menu.addItem(empty)
+            } else {
+                menu.addItem(.separator())
+                let header = NSMenuItem(title: "Top meetings", action: nil, keyEquivalent: "")
+                header.isEnabled = false
+                menu.addItem(header)
+
+                for meeting in topMeetings {
+                    let title = "\(meeting.timeText)  \(meeting.title)"
+                    let item = NSMenuItem(title: title, action: #selector(meetingClicked(_:)), keyEquivalent: "")
+                    item.target = self
+                    item.representedObject = meeting.joinLink
+                    menu.addItem(item)
+                }
+            }
+
+            menu.addItem(.separator())
+            let signOutItem = NSMenuItem(title: "Sign Out", action: #selector(signOutClicked), keyEquivalent: "")
+            signOutItem.target = self
+            menu.addItem(signOutItem)
+        } else {
+            menu.addItem(.separator())
+            let signInItem = NSMenuItem(title: "Sign In", action: #selector(signInClicked), keyEquivalent: "")
+            signInItem.target = self
+            menu.addItem(signInItem)
+        }
+
+        menu.addItem(.separator())
+        let quitItem = NSMenuItem(title: "Quit", action: #selector(quitClicked), keyEquivalent: "q")
+        quitItem.target = self
+        menu.addItem(quitItem)
+
+        statusItem.menu = menu
+    }
+
+    @objc private func openAppClicked() {
+        bringAppToFront()
+    }
+
+    @objc private func signInClicked() {
+        bringAppToFront()
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+            NotificationCenter.default.post(name: .widgetSignInRequested, object: nil)
+        }
+    }
+
+    @objc private func signOutClicked() {
+        bringAppToFront()
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+            NotificationCenter.default.post(name: .statusBarSignOutRequested, object: nil)
+        }
+    }
+
+    @objc private func meetingClicked(_ sender: NSMenuItem) {
+        guard let link = sender.representedObject as? String,
+              let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    @objc private func quitClicked() {
+        NSApp.terminate(nil)
+    }
+
+    private func bringAppToFront() {
+        NSApp.unhide(nil)
+        NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
+        NSApp.activate(ignoringOtherApps: true)
+        NSApp.arrangeInFront(nil)
+        for window in NSApp.windows where window.contentViewController is ViewController {
+            if window.isMiniaturized {
+                window.deminiaturize(nil)
+            }
+            window.orderFrontRegardless()
+            window.makeKeyAndOrderFront(nil)
+        }
+    }
+}
+

+ 4 - 0
meetings_app/ViewController.swift

@@ -696,6 +696,7 @@ private extension ViewController {
         NotificationCenter.default.addObserver(self, selector: #selector(widgetRefreshRequested), name: .widgetRefreshRequested, object: nil)
         NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenMeetWebRequested), name: .widgetOpenMeetWebRequested, object: nil)
         NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenMeetingLinkRequested(_:)), name: .widgetOpenMeetingLinkRequested, object: nil)
+        NotificationCenter.default.addObserver(self, selector: #selector(statusBarSignOutRequested), name: .statusBarSignOutRequested, object: nil)
     }
 
     @objc private func widgetOpenJoinMeetingsPageRequested() { showSidebarPage(.joinMeetings) }
@@ -716,6 +717,8 @@ private extension ViewController {
         openInDefaultBrowser(url: url)
     }
 
+    @objc private func statusBarSignOutRequested() { performGoogleSignOut() }
+
     @objc private func joinMeetClicked(_ sender: Any?) {
         let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
 
@@ -6870,6 +6873,7 @@ private extension ViewController {
         } else {
             UserDefaults.standard.removeObject(forKey: WidgetMeetingStore.key)
         }
+        NotificationCenter.default.post(name: .meetingsSnapshotUpdated, object: nil)
     }
 
     func showScheduleHelp() {

+ 2 - 0
meetings_app/Widgets/DesktopWidgetView.swift

@@ -358,4 +358,6 @@ extension Notification.Name {
     static let widgetOpenMeetWebRequested = Notification.Name("widgetOpenMeetWebRequested")
     static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
     static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
+    static let statusBarSignOutRequested = Notification.Name("statusBarSignOutRequested")
+    static let meetingsSnapshotUpdated = Notification.Name("meetingsSnapshotUpdated")
 }