// // ViewController.swift // classroom_app // // Created by Dev Mac 1 on 06/04/2026. // import Cocoa import QuartzCore import WebKit import AuthenticationServices import StoreKit private enum SidebarPage: Int { case joinMeetings = 0 case photo = 1 case enrolled = 2 case teaching = 3 case video = 4 case settings = 5 } private enum ZoomJoinMode: Int { case id = 0 case url = 1 } private enum SettingsAction: Int { case restore = 0 case rateUs = 1 case support = 2 case moreApps = 3 case shareApp = 4 case upgrade = 5 case privacyPolicy = 6 case termsOfServices = 7 } private enum PremiumPlan: Int { case weekly = 0 case monthly = 1 case yearly = 2 case lifetime = 3 } private enum PremiumStoreProduct { static let weekly = "com.mqldev.classroomapp.premium.weekly" static let monthly = "com.mqldev.classroomapp.premium.monthly" static let yearly = "com.mqldev.classroomapp.premium.yearly" static let lifetime = "com.mqldev.classroomapp.premium.lifetime" static let allIDs = [weekly, monthly, yearly, lifetime] static func productID(for plan: PremiumPlan) -> String { switch plan { case .weekly: return weekly case .monthly: return monthly case .yearly: return yearly case .lifetime: return lifetime } } static func plan(for productID: String) -> PremiumPlan? { switch productID { case weekly: return .weekly case monthly: return .monthly case yearly: return .yearly case lifetime: return .lifetime default: return nil } } } @MainActor private final class StoreKitCoordinator { enum PurchaseOutcome { case success case cancelled case pending case unavailable case alreadyOwned case failed(String) } private(set) var productsByID: [String: Product] = [:] private(set) var activeEntitlementProductIDs: Set = [] private(set) var lastProductLoadError: String? var onEntitlementsChanged: ((Bool) -> Void)? var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty } var hasLifetimeAccess: Bool { activeEntitlementProductIDs.contains(PremiumStoreProduct.lifetime) } var activeNonLifetimePlan: PremiumPlan? { activeEntitlementProductIDs .compactMap { PremiumStoreProduct.plan(for: $0) } .filter { $0 != .lifetime } .max(by: { $0.rawValue < $1.rawValue }) } private var transactionUpdatesTask: Task? deinit { transactionUpdatesTask?.cancel() } func start() async { if transactionUpdatesTask == nil { transactionUpdatesTask = Task { [weak self] in await self?.observeTransactionUpdates() } } await refreshProducts() await refreshEntitlements() } func refreshProducts() async { do { let products = try await Product.products(for: PremiumStoreProduct.allIDs) productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) }) lastProductLoadError = nil } catch { productsByID = [:] lastProductLoadError = error.localizedDescription } } func refreshEntitlements() async { let previousHasPremiumAccess = hasPremiumAccess var active = Set() for await entitlement in Transaction.currentEntitlements { guard case .verified(let transaction) = entitlement else { continue } guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue } if Self.isTransactionActive(transaction) { active.insert(transaction.productID) } } // Some StoreKit test timelines can briefly report empty current entitlements // even though a latest verified transaction exists for a non-consumable. // Merge in latest transactions to keep launch access state accurate. for productID in PremiumStoreProduct.allIDs { guard let latest = await Transaction.latest(for: productID), case .verified(let transaction) = latest, Self.isTransactionActive(transaction) else { continue } active.insert(productID) } activeEntitlementProductIDs = active let newHasPremiumAccess = hasPremiumAccess if newHasPremiumAccess != previousHasPremiumAccess { onEntitlementsChanged?(newHasPremiumAccess) } } func purchase(plan: PremiumPlan) async -> PurchaseOutcome { let productID = PremiumStoreProduct.productID(for: plan) if activeEntitlementProductIDs.contains(productID) { return .alreadyOwned } guard let product = productsByID[productID] else { await refreshProducts() guard let refreshed = productsByID[productID] else { if let lastProductLoadError, !lastProductLoadError.isEmpty { return .failed("Unable to load products: \(lastProductLoadError)") } let loadedIDs = productsByID.keys.sorted().joined(separator: ", ") let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)") } return await purchase(product: refreshed) } return await purchase(product: product) } func restorePurchases() async -> String { do { try await AppStore.sync() await refreshEntitlements() if hasPremiumAccess { return "Purchases restored successfully." } return "No previous premium purchase was found for this Apple ID." } catch { return "Restore failed: \(error.localizedDescription)" } } private func purchase(product: Product) async -> PurchaseOutcome { do { let result = try await product.purchase() switch result { case .success(let verificationResult): guard case .verified(let transaction) = verificationResult else { return .failed("Purchase verification failed.") } await transaction.finish() await refreshEntitlements() return .success case .pending: return .pending case .userCancelled: return .cancelled @unknown default: return .failed("Unknown purchase state.") } } catch { return .failed(error.localizedDescription) } } private func observeTransactionUpdates() async { for await update in Transaction.updates { guard case .verified(let transaction) = update else { continue } if PremiumStoreProduct.allIDs.contains(transaction.productID) { await refreshEntitlements() } await transaction.finish() } } private static func isTransactionActive(_ transaction: Transaction) -> Bool { if transaction.revocationDate != nil { return false } if let expirationDate = transaction.expirationDate { return expirationDate > Date() } return true } } final class ViewController: NSViewController { private struct GoogleProfileDisplay { let name: String let email: String let pictureURL: URL? } private var palette = Palette(isDarkMode: true) private let typography = Typography() private let launchContentSize = NSSize(width: 920, height: 690) private let launchMinContentSize = NSSize(width: 760, height: 600) private var mainContentHost: NSView? /// Pin constraints for the current page inside `mainContentHost`; deactivated before each swap so relayout never stacks duplicates. private var mainContentHostPinConstraints: [NSLayoutConstraint] = [] private var sidebarRowViews: [SidebarPage: NSView] = [:] private var selectedSidebarPage: SidebarPage = .joinMeetings private var selectedZoomJoinMode: ZoomJoinMode = .id private var pageCache: [SidebarPage: NSView] = [:] private var sidebarPageByView = [ObjectIdentifier: SidebarPage]() private var zoomJoinModeByView = [ObjectIdentifier: ZoomJoinMode]() private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:] private var settingsActionByView = [ObjectIdentifier: SettingsAction]() private weak var centeredTitleLabel: NSTextField? private var paywallWindow: NSWindow? private let paywallContentWidth: CGFloat = 520 private let launchWindowLeftOffset: CGFloat = 80 private var selectedPremiumPlan: PremiumPlan = .monthly private var paywallPlanViews: [PremiumPlan: NSView] = [:] private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]() private weak var paywallOfferLabel: NSTextField? private weak var paywallContinueLabel: NSTextField? private weak var paywallContinueButton: NSView? private weak var sidebarPremiumTitleLabel: NSTextField? private weak var sidebarPremiumIconView: NSImageView? private weak var sidebarPremiumButtonView: HoverTrackingView? private weak var instantMeetCardView: HoverSurfaceView? private weak var instantMeetTitleLabel: NSTextField? private weak var instantMeetSubtitleLabel: NSTextField? private weak var joinWithLinkCardView: HoverSurfaceView? private weak var joinWithLinkTitleLabel: NSTextField? private weak var joinMeetPrimaryButton: NSButton? private weak var meetLinkField: NSTextField? private weak var browseAddressField: NSTextField? private var inAppBrowserWindowController: InAppBrowserWindowController? private let googleOAuth = GoogleOAuthService.shared private let calendarClient = GoogleCalendarClient() private let classroomClient = GoogleClassroomClient() private let storeKitCoordinator = StoreKitCoordinator() private var storeKitStartupTask: Task? private var paywallPurchaseTask: Task? private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:] private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:] private var paywallContinueEnabled = true private var paywallUpgradeFlowEnabled = false private let launchPaywallDelay: TimeInterval = 3 private var hasCompletedInitialStoreKitSync = false private var hasPresentedLaunchPaywall = false private var launchPaywallWorkItem: DispatchWorkItem? private var hasViewAppearedOnce = false private var lastKnownPremiumAccess = false private var displayedScheduleTodos: [ClassroomTodoItem] = [] private var enrolledCachedCourses: [ClassroomCourse] = [] private var teachingCachedCourses: [ClassroomCourse] = [] private var appUsageSessionStartDate: Date? private var hasObservedAppLifecycleForUsage = false private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem? private enum ScheduleFilter: Int { case all = 0 case today = 1 case week = 2 } private enum SchedulePageFilter: Int { case all = 0 case today = 1 case week = 2 case month = 3 case customRange = 4 } private var scheduleFilter: ScheduleFilter = .all private weak var scheduleDateHeadingLabel: NSTextField? private weak var scheduleCardsStack: NSStackView? private weak var scheduleCardsScrollView: NSScrollView? private weak var scheduleScrollLeftButton: NSView? private weak var scheduleScrollRightButton: NSView? private weak var scheduleFilterDropdown: NSPopUpButton? private weak var scheduleGoogleAuthButton: NSButton? private weak var scheduleGoogleAuthHostView: GoogleProfileAuthHostView? private var scheduleGoogleAuthHostPadWidthConstraint: NSLayoutConstraint? private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint? private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint? private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint? /// Circular avatar size when signed in (top-right, Google-style). private let scheduleGoogleSignedInAvatarSize: CGFloat = 36 private var scheduleGoogleAuthHovering = false private var scheduleCurrentProfile: GoogleProfileDisplay? /// Larger copy of the header avatar for the account popover (optional). private var scheduleProfileMenuAvatar: NSImage? private var scheduleProfileImageTask: Task? private var googleAccountPopover: NSPopover? private var scheduleCachedMeetings: [ScheduledMeeting] = [] private var scheduleCachedTodos: [ClassroomTodoItem] = [] private var schedulePageFilter: SchedulePageFilter = .all private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date()) private var schedulePageToDate: Date = Calendar.current.startOfDay(for: Date()) private var schedulePageFilteredTodos: [ClassroomTodoItem] = [] private var schedulePageVisibleCount: Int = 0 private let schedulePageBatchSize: Int = 6 private let schedulePageCardsPerRow: Int = 3 private let schedulePageCardSpacing: CGFloat = 20 private let schedulePageCardHeight: CGFloat = 182 /// Match `makeJoinMeetingsContent` vertical rhythm between sections. private let schedulePageStackSpacing: CGFloat = 14 /// Tighter gap from header block (title + filters) to the date line below. private let schedulePageHeaderToDateSpacing: CGFloat = 10 /// Join Meetings: gap from “Schedule” row to date heading, and date heading to card strip (keeps cards aligned with the rest of the column). private let joinPageScheduleHeaderToDateSpacing: CGFloat = 8 private let joinPageDateToMeetingCardsSpacing: CGFloat = 8 /// Match Join Meetings main content insets so the top auth/profile bar lines up with page edges. private let schedulePageLeadingInset: CGFloat = 28 private let schedulePageTrailingInset: CGFloat = 28 private var schedulePageScrollObservation: NSObjectProtocol? private weak var schedulePageDateHeadingLabel: NSTextField? private weak var schedulePageFilterDropdown: NSPopUpButton? private weak var schedulePageFromDatePicker: NSDatePicker? private weak var schedulePageToDatePicker: NSDatePicker? private weak var schedulePageRangeErrorLabel: NSTextField? private weak var schedulePageCardsStack: NSStackView? private weak var schedulePageCardsScrollView: NSScrollView? private weak var enrolledPageHeadingLabel: NSTextField? private weak var enrolledPageCardsStack: NSStackView? private weak var teachingPageHeadingLabel: NSTextField? private weak var teachingPageCardsStack: NSStackView? private var enrolledClassDetailsPopover: NSPopover? private var enrolledCourseByCardID: [String: ClassroomCourse] = [:] private var teachingCourseByCardID: [String: ClassroomCourse] = [:] // MARK: - Calendar page (custom month UI) private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date()) private var calendarPageSelectedDate: Date = Calendar.current.startOfDay(for: Date()) private weak var calendarPageMonthLabel: NSTextField? private weak var calendarPageGridStack: NSStackView? private var calendarPageGridHeightConstraint: NSLayoutConstraint? private weak var calendarPageDaySummaryLabel: NSTextField? private var calendarPageActionPopover: NSPopover? private var calendarPageCreatePopover: NSPopover? private weak var topToastView: NSVisualEffectView? private var topToastHideWorkItem: DispatchWorkItem? /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`). private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll private let darkModeDefaultsKey = "settings.darkModeEnabled" private let appUsageAccumulatedSecondsDefaultsKey = "rating.appUsageAccumulatedSeconds" private let userHasRatedDefaultsKey = "rating.userHasRated" private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done" private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60 private var darkModeEnabled: Bool { get { let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode() } set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) } } private func makeSettingsPopover() -> NSPopover { let popover = NSPopover() popover.behavior = .transient popover.animates = true let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess let showRateUsInSettings = shouldShowRateUsInSettings popover.contentViewController = SettingsMenuViewController( palette: palette, typography: typography, darkModeEnabled: darkModeEnabled, showRateUsInSettings: showRateUsInSettings, showUpgradeInSettings: showUpgradeInSettings, onToggleDarkMode: { [weak self] enabled in self?.setDarkMode(enabled) }, onAction: { [weak self] action, sourceView, clickPoint in self?.handleSettingsAction(action, sourceView: sourceView, clickLocationInSourceView: clickPoint) } ) return popover } private var settingsPopover: NSPopover? override func viewDidLoad() { super.viewDidLoad() // Sync toggle + palette with current macOS appearance on launch. darkModeEnabled = systemPrefersDarkMode() palette = Palette(isDarkMode: darkModeEnabled) storeKitCoordinator.onEntitlementsChanged = { [weak self] hasPremiumAccess in guard let self else { return } self.handlePremiumAccessChanged(hasPremiumAccess) } migrateLegacyRatingStateIfNeeded() beginUsageTrackingSessionIfNeeded() observeAppLifecycleForUsageTrackingIfNeeded() setupRootView() buildMainLayout() startStoreKit() } override func viewDidAppear() { super.viewDidAppear() hasViewAppearedOnce = true presentLaunchPaywallIfNeeded() applyWindowTitle(for: selectedSidebarPage) guard let window = view.window else { return } // Ensure launch size is applied even when macOS tries to restore prior window state. window.isRestorable = false window.setFrameAutosaveName("") DispatchQueue.main.async { [weak self, weak window] in guard let self, let window else { return } let frameSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchContentSize)).size var newFrame = window.frame newFrame.size = frameSize window.setFrame(newFrame, display: true) window.center() if let screen = window.screen ?? NSScreen.main { var adjustedFrame = window.frame adjustedFrame.origin.x -= self.launchWindowLeftOffset let minX = screen.visibleFrame.minX adjustedFrame.origin.x = max(minX, adjustedFrame.origin.x) window.setFrame(adjustedFrame, display: true) } window.minSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchMinContentSize)).size self.installCenteredTitleIfNeeded(on: window) } } override var representedObject: Any? { didSet {} } deinit { premiumUpgradeRatingPromptWorkItem?.cancel() endUsageTrackingSession() if hasObservedAppLifecycleForUsage { NotificationCenter.default.removeObserver(self) } if let observer = schedulePageScrollObservation { NotificationCenter.default.removeObserver(observer) } storeKitStartupTask?.cancel() paywallPurchaseTask?.cancel() launchPaywallWorkItem?.cancel() } } private extension ViewController { func setupRootView() { view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua) view.wantsLayer = true view.layer?.backgroundColor = palette.pageBackground.cgColor } func systemPrefersDarkMode() -> Bool { // Use the system-wide appearance setting (not app/window effective appearance). // When the key is missing, macOS is in Light mode. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) let style = global?["AppleInterfaceStyle"] as? String return style?.lowercased() == "dark" } func buildMainLayout() { let splitContainer = NSStackView() splitContainer.translatesAutoresizingMaskIntoConstraints = false splitContainer.orientation = .horizontal splitContainer.spacing = 14 splitContainer.distribution = .fill splitContainer.alignment = .top view.addSubview(splitContainer) NSLayoutConstraint.activate([ splitContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), splitContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), splitContainer.topAnchor.constraint(equalTo: view.topAnchor), splitContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) let sidebar = makeSidebar() let mainPanel = makeMainPanel() sidebar.setContentHuggingPriority(.required, for: .horizontal) sidebar.setContentCompressionResistancePriority(.required, for: .horizontal) mainPanel.setContentHuggingPriority(.defaultLow, for: .horizontal) mainPanel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) splitContainer.addArrangedSubview(sidebar) splitContainer.addArrangedSubview(mainPanel) } @objc private func sidebarItemClicked(_ sender: NSClickGestureRecognizer) { guard let view = sender.view else { return } activateSidebarItem(view) } private func activateSidebarItem(_ view: NSView) { guard let page = sidebarPageByView[ObjectIdentifier(view)], page != selectedSidebarPage || page == .settings else { return } showSidebarPage(page) } @objc private func zoomJoinModeClicked(_ sender: NSClickGestureRecognizer) { guard let view = sender.view, let mode = zoomJoinModeByView[ObjectIdentifier(view)], mode != selectedZoomJoinMode else { return } selectedZoomJoinMode = mode updateZoomJoinModeAppearance() if selectedSidebarPage == .joinMeetings { pageCache[.joinMeetings] = nil showSidebarPage(.joinMeetings) } } @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) { if storeKitCoordinator.hasPremiumAccess { openManageSubscriptions() } else { showPaywall() } } @objc private func sidebarButtonClicked(_ sender: NSButton) { guard let page = SidebarPage(rawValue: sender.tag), page != selectedSidebarPage || page == .settings else { return } showSidebarPage(page) } @objc private func joinMeetClicked(_ sender: Any?) { let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let url = normalizedClassroomURL(from: rawInput) else { showSimpleAlert( title: "Invalid Classroom link", message: "Enter a valid Google Classroom link or class code (for example classroom.google.com, classroom.google.com/c/your-class-id, or paste a class code)." ) return } openURL(url) } @objc private func joinWithLinkCardClicked(_ sender: NSClickGestureRecognizer) { meetLinkField?.window?.makeFirstResponder(meetLinkField) } @objc private func cancelMeetJoinClicked(_ sender: Any?) { meetLinkField?.stringValue = "" } @objc private func browseOpenAddressClicked(_ sender: Any?) { let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard raw.isEmpty == false else { showSimpleAlert(title: "Browse", message: "Enter a web address (for example classroom.google.com).") return } let normalized = normalizedURLString(from: raw) guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else { showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.") return } openURL(url) } @objc private func browseQuickLinkMeetClicked(_ sender: Any?) { guard let url = URL(string: "https://classroom.google.com/") else { return } openURL(url) } @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) { guard let url = URL(string: "https://support.google.com/classroom") else { return } openURL(url) } @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) { guard let url = URL(string: "https://support.zoom.us") else { return } openURL(url) } @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) { guard let url = URL(string: "https://classroom.google.com/") else { return } openURL(url) } private func normalizedURLString(from value: String) -> String { if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") { return value } return "https://\(value)" } /// Bare class IDs used in `/c/...` paths: alphanumeric, typical Classroom length range. private func isValidClassroomClassCode(_ code: String) -> Bool { let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty == false else { return false } guard (6...64).contains(trimmed.count) else { return false } let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) return trimmed.unicodeScalars.allSatisfy { allowed.contains($0) } } /// Accepts `https://classroom.google.com/...`, host-only, or a bare class code for `/c/...`. private func normalizedClassroomURL(from rawInput: String) -> URL? { let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty == false else { return nil } let lower = trimmed.lowercased() func classroomHost(_ host: String) -> Bool { host == "classroom.google.com" || host.hasSuffix(".classroom.google.com") } if lower.hasPrefix("http://") || lower.hasPrefix("https://") { guard let url = URL(string: trimmed), let host = url.host?.lowercased(), classroomHost(host) else { return nil } var components = URLComponents(url: url, resolvingAgainstBaseURL: false) components?.scheme = "https" components?.host = host return components?.url } if lower == "classroom.google.com" || lower == "www.classroom.google.com" { return URL(string: "https://classroom.google.com/") } if lower.hasPrefix("classroom.google.com/") || lower.hasPrefix("www.classroom.google.com/") { let hostLen = lower.hasPrefix("www.") ? "www.classroom.google.com".count : "classroom.google.com".count let rest = String(trimmed.dropFirst(hostLen)) let path = rest.hasPrefix("/") ? rest : "/" + rest return URL(string: "https://classroom.google.com\(path)") } let code = trimmed.replacingOccurrences(of: " ", with: "") if isValidClassroomClassCode(code) { return URL(string: "https://classroom.google.com/c/\(code)") } return nil } private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) { let browserController: InAppBrowserWindowController if let existing = inAppBrowserWindowController { browserController = existing } else { browserController = InAppBrowserWindowController() inAppBrowserWindowController = browserController } browserController.load(url: url, policy: policy) browserController.applyDefaultFrameCenteredOnVisibleScreen() browserController.showWindow(nil) browserController.window?.makeKeyAndOrderFront(nil) browserController.window?.orderFrontRegardless() NSApp.activate(ignoringOtherApps: true) } private func openInDefaultBrowser(url: URL) { NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in if let error { DispatchQueue.main.async { self?.showSimpleAlert(title: "Unable to open browser", message: error.localizedDescription) } } } } private func openURLWithRouting(_ url: URL, policy: InAppBrowserURLPolicy = .allowAll) { let scheme = (url.scheme ?? "").lowercased() if scheme == "http" || scheme == "https" { openInAppBrowser(with: url, policy: policy) return } openInDefaultBrowser(url: url) } private func openRateUsDestination() { let configured = (Bundle.main.object(forInfoDictionaryKey: "RateUsURL") as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) let placeholder = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) let hardcodedRateUsURL = "https://apps.apple.com/mac/search?term=Google%20Classroom" var candidateStrings = [String]() if let configured, !configured.isEmpty { candidateStrings.append(configured) if let appID = extractAppleAppID(from: configured) { candidateStrings.append("macappstore://apps.apple.com/app/id\(appID)") candidateStrings.append("macappstore://itunes.apple.com/app/id\(appID)") } } candidateStrings.append(hardcodedRateUsURL) if let placeholder, !placeholder.isEmpty { candidateStrings.append(placeholder) } for candidate in candidateStrings { guard let url = URL(string: candidate) else { continue } if NSWorkspace.shared.open(url) { return } } showSimpleAlert( title: "Unable to Open Rate Page", message: "Could not open the App Store rating page. Please try again." ) requestAppRatingIfEligible(markAsRated: true) } private func extractAppleAppID(from urlString: String) -> String? { guard let match = urlString.range(of: "id[0-9]+", options: .regularExpression) else { return nil } let token = String(urlString[match]) return String(token.dropFirst()) } private func openInSafari(url: URL) { guard let safariAppURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") else { NSWorkspace.shared.open(url) return } let configuration = NSWorkspace.OpenConfiguration() NSWorkspace.shared.open([url], withApplicationAt: safariAppURL, configuration: configuration) { _, error in if let error { self.showSimpleAlert(title: "Unable to Open Safari", message: error.localizedDescription) } } } private func openManageSubscriptions() { guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { showSimpleAlert(title: "Unable to Open Subscriptions", message: "The subscriptions URL is invalid.") return } openURL(url) } private func shareAppURL() -> URL? { if let configured = Bundle.main.object(forInfoDictionaryKey: "AppShareURL") as? String { let trimmed = configured.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty == false, let url = URL(string: trimmed) { return url } } return nil } private func shareAppFromSettingsMenu(sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) { let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Google Classroom App" let message = "Check out \(appName) for Google Classroom and your classwork." let appURL = shareAppURL() let shareItems: [Any] = appURL.map { [message, $0] } ?? [message] let picker = NSSharingServicePicker(items: shareItems) let anchorView = sourceView ?? sidebarRowViews[.settings] ?? view let anchorPoint = clickLocationInSourceView ?? NSPoint(x: anchorView.bounds.midX, y: anchorView.bounds.midY) let anchorRect = NSRect(x: anchorPoint.x, y: anchorPoint.y, width: 1, height: 1) picker.show(relativeTo: anchorRect, of: anchorView, preferredEdge: .minY) let clipboardText = ([message, appURL?.absoluteString].compactMap { $0 }).joined(separator: "\n") NSPasteboard.general.clearContents() NSPasteboard.general.setString(clipboardText, forType: .string) } private func showSidebarPage(_ page: SidebarPage) { selectedSidebarPage = page updateSidebarAppearance() applyWindowTitle(for: page) guard let host = mainContentHost else { return } NSLayoutConstraint.deactivate(mainContentHostPinConstraints) mainContentHostPinConstraints.removeAll() host.subviews.forEach { $0.removeFromSuperview() } let child = viewForPage(page) child.translatesAutoresizingMaskIntoConstraints = false host.addSubview(child) mainContentHostPinConstraints = [ child.leadingAnchor.constraint(equalTo: host.leadingAnchor), child.trailingAnchor.constraint(equalTo: host.trailingAnchor), child.topAnchor.constraint(equalTo: host.topAnchor), child.bottomAnchor.constraint(equalTo: host.bottomAnchor) ] NSLayoutConstraint.activate(mainContentHostPinConstraints) } private func showSettingsPopover() { guard let anchor = sidebarRowViews[.settings] else { return } if settingsPopover?.isShown == true { settingsPopover?.performClose(nil) return } settingsPopover = makeSettingsPopover() if let menu = settingsPopover?.contentViewController as? SettingsMenuViewController { menu.setDarkModeEnabled(darkModeEnabled) } settingsPopover?.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX) } private func setDarkMode(_ enabled: Bool) { darkModeEnabled = enabled NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua) view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua) palette = Palette(isDarkMode: enabled) reloadTheme() } private func reloadTheme() { pageCache.removeAll() if let observer = schedulePageScrollObservation { NotificationCenter.default.removeObserver(observer) } schedulePageScrollObservation = nil sidebarRowViews.removeAll() sidebarPageByView.removeAll() zoomJoinModeByView.removeAll() zoomJoinModeViews.removeAll() settingsActionByView.removeAll() paywallPlanViews.removeAll() premiumPlanByView.removeAll() paywallPriceLabels.removeAll() paywallSubtitleLabels.removeAll() paywallContinueLabel = nil paywallContinueButton = nil paywallContinueEnabled = true googleAccountPopover?.performClose(nil) googleAccountPopover = nil NSLayoutConstraint.deactivate(mainContentHostPinConstraints) mainContentHostPinConstraints.removeAll() mainContentHost = nil view.subviews.forEach { $0.removeFromSuperview() } setupRootView() buildMainLayout() showSidebarPage(selectedSidebarPage) } private func handleSettingsAction(_ action: SettingsAction, sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) { switch action { case .restore: Task { [weak self] in guard let self else { return } let message = await self.storeKitCoordinator.restorePurchases() self.refreshPaywallStoreUI() self.showSimpleAlert(title: "Restore Purchases", message: message) } case .rateUs: openRateUsDestination() case .support: openSettingsLink(infoKey: "SupportURL") case .privacyPolicy: openSettingsLink(infoKey: "PrivacyPolicyURL") case .termsOfServices: openSettingsLink(infoKey: "TermsOfServiceURL") case .moreApps: if let moreAppsURL = Bundle.main.object(forInfoDictionaryKey: "MoreAppsURL") as? String, let url = URL(string: moreAppsURL) { openURL(url) } case .shareApp: shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView) case .upgrade: showPaywall(upgradeFlow: true, preferredPlan: .lifetime) } } private func openSettingsLink(infoKey: String) { let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon" let urlString = (Bundle.main.object(forInfoDictionaryKey: infoKey) as? String) ?? defaultURL guard let url = URL(string: urlString) else { return } openURL(url) } private func showSimpleAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.addButton(withTitle: "OK") alert.runModal() } private func showTopToast(message: String, isError: Bool) { topToastHideWorkItem?.cancel() topToastHideWorkItem = nil topToastView?.removeFromSuperview() topToastView = nil let toast = NSVisualEffectView() toast.translatesAutoresizingMaskIntoConstraints = false toast.material = darkModeEnabled ? .hudWindow : .popover toast.blendingMode = .withinWindow toast.state = .active toast.wantsLayer = true toast.layer?.cornerRadius = 10 toast.layer?.masksToBounds = true toast.layer?.borderWidth = 1 toast.layer?.borderColor = NSColor.white.withAlphaComponent(darkModeEnabled ? 0.10 : 0.18).cgColor toast.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.68 : 0.78).cgColor toast.alphaValue = 0 let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.alignment = .centerY row.spacing = 8 row.distribution = .fill let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.imageScaling = .scaleProportionallyDown icon.image = NSImage( systemSymbolName: isError ? "xmark.circle.fill" : "checkmark.circle.fill", accessibilityDescription: isError ? "Error" : "Success" ) icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold) icon.contentTintColor = isError ? NSColor.systemRed : NSColor.systemGreen let label = textLabel( message, font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: NSColor.white ) label.alignment = .left label.maximumNumberOfLines = 2 label.lineBreakMode = .byWordWrapping row.addArrangedSubview(icon) row.addArrangedSubview(label) toast.addSubview(row) view.addSubview(toast) NSLayoutConstraint.activate([ toast.topAnchor.constraint(equalTo: view.topAnchor, constant: 14), toast.centerXAnchor.constraint(equalTo: view.centerXAnchor), toast.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -40), toast.heightAnchor.constraint(greaterThanOrEqualToConstant: 40), icon.widthAnchor.constraint(equalToConstant: 16), icon.heightAnchor.constraint(equalToConstant: 16), row.leadingAnchor.constraint(equalTo: toast.leadingAnchor, constant: 14), row.trailingAnchor.constraint(equalTo: toast.trailingAnchor, constant: -14), row.topAnchor.constraint(equalTo: toast.topAnchor, constant: 10), row.bottomAnchor.constraint(equalTo: toast.bottomAnchor, constant: -10) ]) topToastView = toast NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) toast.animator().alphaValue = 1 } let hideWorkItem = DispatchWorkItem { [weak self, weak toast] in guard let self, let toast else { return } NSAnimationContext.runAnimationGroup({ context in context.duration = 0.2 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) toast.animator().alphaValue = 0 }, completionHandler: { toast.removeFromSuperview() if self.topToastView === toast { self.topToastView = nil } }) } topToastHideWorkItem = hideWorkItem DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: hideWorkItem) } private func confirmPremiumUpgrade() -> Bool { let alert = NSAlert() alert.messageText = "Already Premium" alert.informativeText = "You are already premium. Do you want to continue with this purchase?" alert.addButton(withTitle: "Continue") alert.addButton(withTitle: "Cancel") return alert.runModal() == .alertFirstButtonReturn } private func showPaywall(upgradeFlow: Bool = false, preferredPlan: PremiumPlan? = nil) { paywallUpgradeFlowEnabled = upgradeFlow if let preferredPlan { selectedPremiumPlan = preferredPlan } if let existing = paywallWindow { refreshPaywallStoreUI() animatePaywallPresentation(existing) existing.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return } let content = makePaywallContent() let controller = NSViewController() controller.view = content let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 640, height: 820), styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false ) panel.title = "Get Premium" panel.titleVisibility = .hidden panel.titlebarAppearsTransparent = true panel.isFloatingPanel = false panel.level = .normal panel.hidesOnDeactivate = true panel.isReleasedWhenClosed = false panel.delegate = self panel.standardWindowButton(.closeButton)?.isHidden = true panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true panel.center() panel.contentViewController = controller panel.alphaValue = 0 panel.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) paywallWindow = panel animatePaywallPresentation(panel) Task { [weak self] in guard let self else { return } await self.storeKitCoordinator.refreshProducts() self.refreshPaywallStoreUI() } } private func animatePaywallPresentation(_ window: NSWindow) { let finalFrame = window.frame let targetScreen = window.screen ?? NSScreen.main let startY: CGFloat if let screen = targetScreen { startY = screen.visibleFrame.maxY + 12 } else { startY = finalFrame.origin.y + 120 } let startFrame = NSRect(x: finalFrame.origin.x, y: startY, width: finalFrame.width, height: finalFrame.height) window.setFrame(startFrame, display: false) window.alphaValue = 0 NSAnimationContext.runAnimationGroup { context in context.duration = 0.28 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) window.animator().alphaValue = 1 window.animator().setFrame(finalFrame, display: true) } } @objc private func closePaywallClicked(_ sender: Any?) { if let win = paywallWindow { win.performClose(nil) return } if let gesture = sender as? NSGestureRecognizer, let win = gesture.view?.window { win.performClose(nil) return } if let view = sender as? NSView, let win = view.window { win.performClose(nil) return } } @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) { guard let view = sender.view else { return } let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link" let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon" let map: [String: String] = [ "Privacy Policy": (Bundle.main.object(forInfoDictionaryKey: "PrivacyPolicyURL") as? String) ?? defaultURL, "Support": (Bundle.main.object(forInfoDictionaryKey: "SupportURL") as? String) ?? defaultURL, "Terms of Services": (Bundle.main.object(forInfoDictionaryKey: "TermsOfServiceURL") as? String) ?? defaultURL ] if let urlString = map[text], let url = URL(string: urlString) { openURL(url) return } showSimpleAlert(title: text, message: "\(text) tapped.") } @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) { guard let view = sender.view, let plan = premiumPlanByView[ObjectIdentifier(view)] else { return } selectedPremiumPlan = plan updatePaywallPlanSelection() } @objc private func paywallPlanButtonClicked(_ sender: NSButton) { guard let plan = PremiumPlan(rawValue: sender.tag) else { return } selectedPremiumPlan = plan updatePaywallPlanSelection() } private func updatePaywallPlanSelection() { for (plan, view) in paywallPlanViews { applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan) } paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan) } private func paywallOfferText(for plan: PremiumPlan) -> String { if storeKitCoordinator.hasPremiumAccess { if storeKitCoordinator.hasLifetimeAccess { return "Lifetime premium is active on this Apple ID." } if paywallUpgradeFlowEnabled { let currentPlanName = storeKitCoordinator.activeNonLifetimePlan?.displayName ?? "Premium" if plan == .lifetime { return "Current plan: \(currentPlanName). Tap Continue to upgrade to Lifetime." } return "Current plan: \(currentPlanName). Select Lifetime to upgrade." } return "Premium is active on this Apple ID." } let productID = PremiumStoreProduct.productID(for: plan) if let product = storeKitCoordinator.productsByID[productID] { let pkrPrice = pkrDisplayPrice(product.displayPrice) if product.type == .nonConsumable { return "\(pkrPrice) one-time purchase" } if let period = product.subscription?.subscriptionPeriod { return "\(pkrPrice)/\(subscriptionUnitText(period.unit))" } return pkrPrice } switch plan { case .weekly: return "PKR 1,100.00/week" case .monthly: return "Free for 3 Days then PKR 2,500.00/month" case .yearly: return "PKR 9,900.00/year (about 190.38/week)" case .lifetime: return "PKR 14,900.00 one-time purchase" } } private func pkrDisplayPrice(_ value: String) -> String { if value.hasPrefix("PKR ") { return value } if value.hasPrefix("Rs ") { return "PKR " + value.dropFirst(3) } if value.contains("PKR") { return value } return "PKR \(value)" } private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String { switch unit { case .day: return "day" case .week: return "week" case .month: return "month" case .year: return "year" @unknown default: return "period" } } private func startStoreKit() { storeKitStartupTask?.cancel() storeKitStartupTask = Task { [weak self] in guard let self else { return } await self.storeKitCoordinator.start() self.hasCompletedInitialStoreKitSync = true self.refreshPaywallStoreUI() self.presentLaunchPaywallIfNeeded() } } private func refreshPaywallStoreUI() { for (plan, label) in paywallPriceLabels { let productID = PremiumStoreProduct.productID(for: plan) if let product = storeKitCoordinator.productsByID[productID] { label.stringValue = pkrDisplayPrice(product.displayPrice) } } for (plan, label) in paywallSubtitleLabels { let productID = PremiumStoreProduct.productID(for: plan) guard let product = storeKitCoordinator.productsByID[productID], let period = product.subscription?.subscriptionPeriod else { continue } label.stringValue = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))" } refreshSidebarPremiumButton() refreshInstantMeetPremiumState() updatePaywallPlanSelection() updatePaywallContinueState(isLoading: false) } private func refreshInstantMeetPremiumState() { instantMeetCardView?.alphaValue = 1.0 instantMeetTitleLabel?.alphaValue = 1.0 instantMeetSubtitleLabel?.alphaValue = 1.0 instantMeetCardView?.toolTip = nil instantMeetCardView?.onHoverChanged?(false) joinWithLinkCardView?.alphaValue = 1.0 joinWithLinkTitleLabel?.alphaValue = 1.0 meetLinkField?.isEditable = true meetLinkField?.isSelectable = true meetLinkField?.alphaValue = 1.0 joinMeetPrimaryButton?.isEnabled = true joinMeetPrimaryButton?.alphaValue = 1.0 joinWithLinkCardView?.toolTip = nil } private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) { let hadPremiumAccess = lastKnownPremiumAccess lastKnownPremiumAccess = hasPremiumAccess premiumUpgradeRatingPromptWorkItem?.cancel() refreshPaywallStoreUI() refreshScheduleCardsForPremiumStateChange() refreshPagesAfterPremiumStateUpdate() Task { [weak self] in await self?.loadSchedule() } if !hadPremiumAccess && hasPremiumAccess { if selectedSidebarPage != .joinMeetings { Task { [weak self] in await self?.loadSchedule() } } // Skip delayed review prompt during initial launch entitlement sync. // We only want this after a real in-session upgrade. if hasCompletedInitialStoreKitSync { scheduleRatingPromptAfterPremiumUpgrade() } } if hadPremiumAccess && !hasPremiumAccess { showPaywall() } } private func refreshPagesAfterPremiumStateUpdate() { pageCache[.joinMeetings] = nil pageCache[.photo] = nil pageCache[.enrolled] = nil pageCache[.teaching] = nil pageCache[.video] = nil pageCache[.settings] = nil showSidebarPage(selectedSidebarPage) } private var userHasRated: Bool { UserDefaults.standard.bool(forKey: userHasRatedDefaultsKey) } private var accumulatedAppUsageSeconds: TimeInterval { get { UserDefaults.standard.double(forKey: appUsageAccumulatedSecondsDefaultsKey) } set { UserDefaults.standard.set(newValue, forKey: appUsageAccumulatedSecondsDefaultsKey) } } private var totalTrackedUsageSeconds: TimeInterval { let liveSessionSeconds: TimeInterval if let start = appUsageSessionStartDate { liveSessionSeconds = max(0, Date().timeIntervalSince(start)) } else { liveSessionSeconds = 0 } return accumulatedAppUsageSeconds + liveSessionSeconds } private var hasReachedRatingUsageThreshold: Bool { totalTrackedUsageSeconds >= ratingEligibleUsageSeconds } private var shouldShowRateUsInSettings: Bool { true } private func migrateLegacyRatingStateIfNeeded() { let defaults = UserDefaults.standard guard !defaults.bool(forKey: ratingStateMigrationV2DoneDefaultsKey) else { return } // Legacy behavior marked "rated" immediately after requesting review. // Clear once so testing and new logic can run correctly. defaults.set(false, forKey: userHasRatedDefaultsKey) defaults.set(true, forKey: ratingStateMigrationV2DoneDefaultsKey) } private func beginUsageTrackingSessionIfNeeded() { guard appUsageSessionStartDate == nil else { return } appUsageSessionStartDate = Date() } private func endUsageTrackingSession() { guard let start = appUsageSessionStartDate else { return } let sessionElapsedSeconds = max(0, Date().timeIntervalSince(start)) accumulatedAppUsageSeconds += sessionElapsedSeconds appUsageSessionStartDate = nil } private func observeAppLifecycleForUsageTrackingIfNeeded() { guard !hasObservedAppLifecycleForUsage else { return } hasObservedAppLifecycleForUsage = true NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActiveForUsageTracking), name: NSApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationWillResignActiveForUsageTracking), name: NSApplication.willResignActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationWillTerminateForUsageTracking), name: NSApplication.willTerminateNotification, object: nil ) } @objc private func applicationDidBecomeActiveForUsageTracking() { beginUsageTrackingSessionIfNeeded() } @objc private func applicationWillResignActiveForUsageTracking() { endUsageTrackingSession() } @objc private func applicationWillTerminateForUsageTracking() { endUsageTrackingSession() } private func scheduleRatingPromptAfterPremiumUpgrade() { guard !userHasRated else { return } let workItem = DispatchWorkItem { [weak self] in self?.requestAppRatingIfEligible(markAsRated: false) } premiumUpgradeRatingPromptWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: workItem) } private func requestAppRatingIfEligible(markAsRated: Bool) { guard storeKitCoordinator.hasPremiumAccess, !userHasRated else { return } SKStoreReviewController.requestReview() if markAsRated { UserDefaults.standard.set(true, forKey: userHasRatedDefaultsKey) } } private func refreshScheduleCardsForPremiumStateChange() { if let stack = scheduleCardsStack { renderScheduleCards(into: stack, todos: displayedScheduleTodos) } applySchedulePageFiltersAndRender() } private func refreshSidebarPremiumButton() { let isPremium = storeKitCoordinator.hasPremiumAccess if isPremium { sidebarPremiumTitleLabel?.stringValue = "Manage Subscription" sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "crown.fill") } else { sidebarPremiumTitleLabel?.stringValue = "Get Premium" sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "star.fill") } sidebarPremiumIconView?.contentTintColor = .white sidebarPremiumButtonView?.onHoverChanged?(false) } private func premiumButtonSymbolImage(named symbolName: String) -> NSImage? { let configuration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? .withSymbolConfiguration(configuration) } private func presentLaunchPaywallIfNeeded() { guard hasCompletedInitialStoreKitSync, hasViewAppearedOnce, !hasPresentedLaunchPaywall else { return } hasPresentedLaunchPaywall = true if !storeKitCoordinator.hasPremiumAccess { launchPaywallWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.launchPaywallWorkItem = nil guard !self.storeKitCoordinator.hasPremiumAccess else { return } self.showPaywall() } launchPaywallWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem) } } @objc private func paywallContinueClicked(_ sender: Any?) { startSelectedPlanPurchase() } private func startSelectedPlanPurchase() { guard paywallContinueEnabled else { if storeKitCoordinator.hasPremiumAccess { showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.") } else { showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.") } return } if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess && !confirmPremiumUpgrade() { return } paywallPurchaseTask?.cancel() updatePaywallContinueState(isLoading: true) let selectedPlan = selectedPremiumPlan paywallPurchaseTask = Task { [weak self] in guard let self else { return } let result = await self.storeKitCoordinator.purchase(plan: selectedPlan) self.updatePaywallContinueState(isLoading: false) self.refreshPaywallStoreUI() switch result { case .success: self.refreshPagesAfterPremiumStateUpdate() Task { [weak self] in await self?.loadSchedule() } self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.") self.paywallWindow?.performClose(nil) self.scheduleRatingPromptAfterPremiumUpgrade() case .cancelled: break case .pending: self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.") case .unavailable: self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.") case .alreadyOwned: self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.") case .failed(let message): self.showSimpleAlert(title: "Purchase Failed", message: message) } } } private func updatePaywallContinueState(isLoading: Bool) { if isLoading { paywallContinueEnabled = false paywallContinueLabel?.stringValue = "Processing..." paywallContinueButton?.alphaValue = 0.75 return } if storeKitCoordinator.hasLifetimeAccess { paywallContinueEnabled = false paywallContinueLabel?.stringValue = "Premium Active" paywallContinueButton?.alphaValue = 0.75 } else if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess { paywallContinueEnabled = true paywallContinueLabel?.stringValue = "Continue" paywallContinueButton?.alphaValue = 1.0 } else { paywallContinueEnabled = true paywallContinueLabel?.stringValue = "Continue" paywallContinueButton?.alphaValue = 1.0 } } private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) { let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1) let idleBorder = palette.inputBorder let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverIdleBackground = palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard let selectedBackground = darkModeEnabled ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1) : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1) card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor card.layer?.borderWidth = isSelected ? 2 : 1 card.layer?.shadowColor = NSColor.black.cgColor card.layer?.shadowOpacity = isSelected ? (darkModeEnabled ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12) card.layer?.shadowOffset = CGSize(width: 0, height: -1) card.layer?.shadowRadius = isSelected ? (darkModeEnabled ? 10 : 6) : (hovering ? 7 : 5) } private func viewForPage(_ page: SidebarPage) -> NSView { if let cached = pageCache[page] { return cached } let built: NSView switch page { case .joinMeetings: built = makeJoinMeetingsContent() case .photo: built = makeSchedulePageContent() case .enrolled: built = makeEnrolledPageContent() case .teaching: built = makeTeachingPageContent() case .video: built = makeCalendarPageContent() case .settings: built = makeSettingsPageContent() } pageCache[page] = built return built } private func makePlaceholderPage(title: String, subtitle: String) -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false let titleLabel = textLabel(title, font: typography.pageTitle, color: palette.textPrimary) titleLabel.translatesAutoresizingMaskIntoConstraints = false let sub = textLabel(subtitle, font: typography.fieldLabel, color: palette.textSecondary) sub.translatesAutoresizingMaskIntoConstraints = false panel.addSubview(titleLabel) panel.addSubview(sub) NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28), titleLabel.topAnchor.constraint(equalTo: panel.topAnchor), sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8) ]) return panel } private func makeSettingsPageContent() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false let scroll = NSScrollView() scroll.translatesAutoresizingMaskIntoConstraints = false scroll.drawsBackground = false scroll.hasHorizontalScroller = false scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.borderType = .noBorder scroll.scrollerStyle = .overlay scroll.automaticallyAdjustsContentInsets = false let clip = TopAlignedClipView() clip.drawsBackground = false scroll.contentView = clip panel.addSubview(scroll) let content = NSView() content.translatesAutoresizingMaskIntoConstraints = false scroll.documentView = content let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard) card.translatesAutoresizingMaskIntoConstraints = false styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true) content.addSubview(card) let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .vertical stack.spacing = 18 stack.alignment = .leading card.addSubview(stack) let pageTitle = textLabel("Settings", font: typography.pageTitle, color: palette.textPrimary) let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: typography.fieldLabel, color: palette.textSecondary) stack.addArrangedSubview(pageTitle) stack.addArrangedSubview(pageSubtitle) stack.setCustomSpacing(24, after: pageSubtitle) let appearanceTitle = textLabel("Appearance", font: typography.joinWithURLTitle, color: palette.textPrimary) stack.addArrangedSubview(appearanceTitle) let darkModeRow = makeSettingsDarkModeRow() stack.addArrangedSubview(darkModeRow) darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true stack.setCustomSpacing(24, after: darkModeRow) let accountTitle = textLabel("Account", font: typography.joinWithURLTitle, color: palette.textPrimary) stack.addArrangedSubview(accountTitle) let googleAccountRow = makeSettingsGoogleAccountRow() stack.addArrangedSubview(googleAccountRow) googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true stack.setCustomSpacing(24, after: googleAccountRow) let appTitle = textLabel("App", font: typography.joinWithURLTitle, color: palette.textPrimary) stack.addArrangedSubview(appTitle) if shouldShowRateUsInSettings { let rateButton = makeSettingsActionButton(icon: "★", title: "Rate Us", action: .rateUs) stack.addArrangedSubview(rateButton) rateButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp) stack.addArrangedSubview(shareButton) shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true if storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess { let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade) stack.addArrangedSubview(upgradeButton) upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true stack.setCustomSpacing(24, after: upgradeButton) } else { stack.setCustomSpacing(24, after: shareButton) } let legalTitle = textLabel("Help & Legal", font: typography.joinWithURLTitle, color: palette.textPrimary) stack.addArrangedSubview(legalTitle) let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy) stack.addArrangedSubview(privacyButton) privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support) stack.addArrangedSubview(supportButton) supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices) stack.addArrangedSubview(termsButton) termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true NSLayoutConstraint.activate([ scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor), scroll.topAnchor.constraint(equalTo: panel.topAnchor), scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor), content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor), content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor), content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor), card.centerXAnchor.constraint(equalTo: content.centerXAnchor), card.topAnchor.constraint(equalTo: content.topAnchor, constant: 36), content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36), card.widthAnchor.constraint(lessThanOrEqualToConstant: 620), card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460), card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30), card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30), stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28), stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28), stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24), stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24) ]) return panel } private func makeSettingsDarkModeRow() -> NSView { let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground) row.translatesAutoresizingMaskIntoConstraints = false row.heightAnchor.constraint(equalToConstant: 52).isActive = true styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: palette.textPrimary) let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary) let toggle = NSSwitch() toggle.translatesAutoresizingMaskIntoConstraints = false toggle.state = darkModeEnabled ? .on : .off toggle.target = self toggle.action = #selector(settingsPageDarkModeToggled(_:)) row.addSubview(icon) row.addSubview(title) row.addSubview(toggle) NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14), icon.centerYAnchor.constraint(equalTo: row.centerYAnchor), title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10), title.centerYAnchor.constraint(equalTo: row.centerYAnchor), toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14), toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor) ]) return row } private func makeSettingsGoogleAccountRow() -> NSView { let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground) row.translatesAutoresizingMaskIntoConstraints = false styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let signedIn = googleOAuth.loadTokens() != nil let titleText = signedIn ? (scheduleCurrentProfile?.name ?? "Google account connected") : "Google account not connected" let subtitleText = signedIn ? (scheduleCurrentProfile?.email ?? "Signed in") : "Sign in to sync your Google Calendar with classwork reminders." let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary) let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: palette.textSecondary) subtitle.maximumNumberOfLines = 2 subtitle.lineBreakMode = .byTruncatingTail let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:))) actionButton.translatesAutoresizingMaskIntoConstraints = false actionButton.bezelStyle = .rounded actionButton.controlSize = .regular row.addSubview(title) row.addSubview(subtitle) row.addSubview(actionButton) NSLayoutConstraint.activate([ row.heightAnchor.constraint(equalToConstant: 78), title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14), title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12), subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor), subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4), subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14), actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14), actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor) ]) return row } private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton { let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:))) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.wantsLayer = true button.layer?.cornerRadius = 10 button.layer?.backgroundColor = palette.inputBackground.cgColor styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) button.heightAnchor.constraint(equalToConstant: 46).isActive = true button.tag = action.rawValue let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: palette.textPrimary) let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary) button.addSubview(iconLabel) button.addSubview(titleLabel) NSLayoutConstraint.activate([ iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14), iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor), titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10), titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor) ]) return button } @objc private func settingsPageDarkModeToggled(_ sender: NSSwitch) { setDarkMode(sender.state == .on) } @objc private func settingsPageActionButtonClicked(_ sender: NSButton) { guard let action = SettingsAction(rawValue: sender.tag) else { return } let clickPoint: NSPoint? if let event = NSApp.currentEvent { let pointInWindow = event.locationInWindow clickPoint = sender.convert(pointInWindow, from: nil) } else { clickPoint = nil } handleSettingsAction(action, sourceView: sender, clickLocationInSourceView: clickPoint) } @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) { if googleOAuth.loadTokens() == nil { scheduleConnectClicked() } else { performGoogleSignOut() } } func makeBrowseWebContent() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary) titleLabel.translatesAutoresizingMaskIntoConstraints = false let sub = textLabel( "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.", font: typography.fieldLabel, color: palette.textSecondary ) sub.translatesAutoresizingMaskIntoConstraints = false sub.maximumNumberOfLines = 0 sub.lineBreakMode = .byWordWrapping let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground) fieldShell.translatesAutoresizingMaskIntoConstraints = false fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let field = NSTextField(string: "") field.translatesAutoresizingMaskIntoConstraints = false field.isEditable = true field.isBordered = false field.drawsBackground = false field.focusRingType = .none field.font = NSFont.systemFont(ofSize: 14, weight: .regular) field.textColor = palette.textPrimary field.placeholderString = "https://example.com or example.com" field.delegate = self browseAddressField = field fieldShell.addSubview(field) let openBtn = meetActionButton( title: "Open in app browser", color: palette.primaryBlue, textColor: .white, width: 220, action: #selector(browseOpenAddressClicked(_:)) ) let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary) quickTitle.translatesAutoresizingMaskIntoConstraints = false let quickRow = NSStackView() quickRow.translatesAutoresizingMaskIntoConstraints = false quickRow.orientation = .horizontal quickRow.spacing = 10 quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Classroom", action: #selector(browseQuickLinkMeetClicked(_:)))) quickRow.addArrangedSubview(browseQuickLinkButton(title: "Classroom help", action: #selector(browseQuickLinkMeetHelpClicked(_:)))) quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:)))) panel.addSubview(titleLabel) panel.addSubview(sub) panel.addSubview(fieldShell) panel.addSubview(openBtn) panel.addSubview(quickTitle) panel.addSubview(quickRow) NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28), titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26), titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28), sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28), sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10), fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28), fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18), field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12), field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12), field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor), openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12), openBtn.heightAnchor.constraint(equalToConstant: 36), quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28), quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10) ]) return panel } private func browseQuickLinkButton(title: String, action: Selector) -> NSButton { let b = NSButton(title: title, target: self, action: action) b.translatesAutoresizingMaskIntoConstraints = false b.bezelStyle = .rounded b.font = NSFont.systemFont(ofSize: 13, weight: .medium) return b } private func applyWindowTitle(for page: SidebarPage) { let title: String switch page { case .joinMeetings: title = "App for Google Classroom" case .photo: title = "Schedule" case .enrolled: title = "Enrolled" case .teaching: title = "Teaching" case .video: title = "Calendar" case .settings: title = "Settings" } view.window?.title = title centeredTitleLabel?.stringValue = title } private func installCenteredTitleIfNeeded(on window: NSWindow) { guard centeredTitleLabel == nil else { return } guard let titlebarView = window.standardWindowButton(.closeButton)?.superview else { return } let label = NSTextField(labelWithString: window.title) label.translatesAutoresizingMaskIntoConstraints = false label.alignment = .center label.font = NSFont.titleBarFont(ofSize: 0) label.textColor = .labelColor label.lineBreakMode = .byTruncatingTail label.maximumNumberOfLines = 1 titlebarView.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor), label.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor), label.leadingAnchor.constraint(greaterThanOrEqualTo: titlebarView.leadingAnchor, constant: 90), label.trailingAnchor.constraint(lessThanOrEqualTo: titlebarView.trailingAnchor, constant: -90) ]) window.titleVisibility = .hidden centeredTitleLabel = label } private func updateSidebarAppearance() { for (page, row) in sidebarRowViews { applySidebarRowStyle(row, page: page, logoTemplate: logoTemplateForSidebarPage(page)) } } private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool { switch page { case .photo: return false case .joinMeetings, .enrolled, .teaching, .video, .settings: return true } } func makeSidebar() -> NSView { let sidebar = NSView() sidebar.translatesAutoresizingMaskIntoConstraints = false sidebar.wantsLayer = true sidebar.layer?.backgroundColor = palette.sidebarBackground.cgColor sidebar.layer?.borderColor = palette.separator.cgColor sidebar.layer?.borderWidth = 1 sidebar.layer?.shadowColor = NSColor.black.cgColor sidebar.layer?.shadowOpacity = 0.18 sidebar.layer?.shadowOffset = CGSize(width: 2, height: 0) sidebar.layer?.shadowRadius = 10 sidebar.widthAnchor.constraint(equalToConstant: 224).isActive = true let appIconView = NSImageView() if let classroomLogo = NSImage(named: "icon_google_classroom") { classroomLogo.isTemplate = false appIconView.image = classroomLogo } else if let headerLogo = NSImage(named: "HeaderLogo") { headerLogo.isTemplate = false appIconView.image = headerLogo } else if let appIconImage = NSApplication.shared.applicationIconImage { appIconImage.isTemplate = false appIconView.image = appIconImage } appIconView.translatesAutoresizingMaskIntoConstraints = false appIconView.imageScaling = NSImageScaling.scaleProportionallyDown appIconView.imageAlignment = NSImageAlignment.alignCenter appIconView.contentTintColor = nil appIconView.widthAnchor.constraint(equalToConstant: 40).isActive = true appIconView.heightAnchor.constraint(equalToConstant: 40).isActive = true let sidebarBrandLabel = textLabel("Classroom", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: palette.textPrimary) sidebarBrandLabel.maximumNumberOfLines = 1 sidebarBrandLabel.lineBreakMode = .byTruncatingTail sidebarBrandLabel.setContentCompressionResistancePriority(.required, for: .horizontal) sidebarBrandLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) let titleRow = NSStackView(views: [ appIconView, sidebarBrandLabel ]) titleRow.translatesAutoresizingMaskIntoConstraints = false titleRow.orientation = NSUserInterfaceLayoutOrientation.horizontal titleRow.alignment = NSLayoutConstraint.Attribute.centerY titleRow.spacing = 12 let menuStack = NSStackView() menuStack.translatesAutoresizingMaskIntoConstraints = false menuStack.orientation = .vertical menuStack.alignment = .leading menuStack.spacing = 10 menuStack.addArrangedSubview(sidebarSectionTitle("Classes")) let joinRow = sidebarItem("Open Classroom", icon: "􀉣", page: .joinMeetings, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0) menuStack.addArrangedSubview(joinRow) sidebarRowViews[.joinMeetings] = joinRow menuStack.addArrangedSubview(sidebarSectionTitle("Planning")) let photoRow = sidebarItem("To-Do", icon: "􀏂", page: .photo, systemSymbolName: "clock.badge.checkmark") menuStack.addArrangedSubview(photoRow) sidebarRowViews[.photo] = photoRow let enrolledRow = sidebarItem("Enrolled", icon: "􀆄", page: .enrolled, systemSymbolName: "person.3.sequence.fill") menuStack.addArrangedSubview(enrolledRow) sidebarRowViews[.enrolled] = enrolledRow let teachingRow = sidebarItem("Teaching", icon: "􀅼", page: .teaching, systemSymbolName: "person.2.badge.gearshape.fill") menuStack.addArrangedSubview(teachingRow) sidebarRowViews[.teaching] = teachingRow let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar") menuStack.addArrangedSubview(videoRow) sidebarRowViews[.video] = videoRow menuStack.addArrangedSubview(sidebarSectionTitle("Additional")) let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true) menuStack.addArrangedSubview(settingsRow) sidebarRowViews[.settings] = settingsRow let premiumButton = sidebarPremiumButton() sidebar.addSubview(titleRow) sidebar.addSubview(menuStack) sidebar.addSubview(premiumButton) NSLayoutConstraint.activate([ titleRow.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 16), titleRow.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 24), titleRow.trailingAnchor.constraint(lessThanOrEqualTo: sidebar.trailingAnchor, constant: -16), menuStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12), menuStack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12), menuStack.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 20), menuStack.bottomAnchor.constraint(lessThanOrEqualTo: premiumButton.topAnchor, constant: -16), premiumButton.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12), premiumButton.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12), premiumButton.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -14) ]) for subview in menuStack.arrangedSubviews { subview.widthAnchor.constraint(equalTo: menuStack.widthAnchor).isActive = true } return sidebar } func sidebarPremiumButton() -> NSView { let button = HoverTrackingView() button.translatesAutoresizingMaskIntoConstraints = false button.wantsLayer = true button.layer?.cornerRadius = 17 button.layer?.backgroundColor = palette.primaryBlue.cgColor button.heightAnchor.constraint(equalToConstant: 34).isActive = true styleSurface(button, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: false) let icon = NSImageView() icon.translatesAutoresizingMaskIntoConstraints = false icon.imageScaling = .scaleProportionallyUpOrDown icon.contentTintColor = .white icon.image = premiumButtonSymbolImage(named: "star.fill") let title = textLabel("Get Premium", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: .white) button.addSubview(icon) button.addSubview(title) NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12), icon.centerYAnchor.constraint(equalTo: button.centerYAnchor), icon.widthAnchor.constraint(equalToConstant: 14), icon.heightAnchor.constraint(equalToConstant: 14), title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8), title.centerYAnchor.constraint(equalTo: button.centerYAnchor), title.trailingAnchor.constraint(lessThanOrEqualTo: button.trailingAnchor, constant: -12) ]) button.onHoverChanged = { [weak self, weak button] hovering in guard let self, let button else { return } let isPremium = self.storeKitCoordinator.hasPremiumAccess let baseColor = isPremium ? NSColor(calibratedRed: 0.93, green: 0.73, blue: 0.16, alpha: 1.0) : self.palette.primaryBlue let borderColor = isPremium ? NSColor(calibratedRed: 0.78, green: 0.56, blue: 0.10, alpha: 1.0) : self.palette.primaryBlueBorder let hoverColor: NSColor let hoverBorderColor: NSColor if isPremium { // Darker rich-gold hover for stronger premium feedback. hoverColor = NSColor(calibratedRed: 0.84, green: 0.62, blue: 0.11, alpha: 1.0) hoverBorderColor = NSColor(calibratedRed: 0.67, green: 0.46, blue: 0.07, alpha: 1.0) } else { let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor hoverBorderColor = borderColor } button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor button.layer?.shadowColor = NSColor.black.cgColor button.layer?.shadowOpacity = hovering ? (isPremium ? 0.30 : 0.20) : 0.14 button.layer?.shadowOffset = CGSize(width: 0, height: -1) button.layer?.shadowRadius = hovering ? (isPremium ? 8 : 6) : 4 self.styleSurface(button, borderColor: (hovering ? hoverBorderColor : borderColor), borderWidth: hovering ? 1.5 : 1, shadow: false) } button.onHoverChanged?(false) sidebarPremiumTitleLabel = title sidebarPremiumIconView = icon sidebarPremiumButtonView = button refreshSidebarPremiumButton() let click = NSClickGestureRecognizer(target: self, action: #selector(premiumButtonClicked(_:))) button.addGestureRecognizer(click) return button } func makeMainPanel() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false panel.wantsLayer = true panel.layer?.backgroundColor = palette.pageBackground.cgColor let authBar = scheduleTopAuthRow() authBar.translatesAutoresizingMaskIntoConstraints = false panel.addSubview(authBar) let host = NSView() host.translatesAutoresizingMaskIntoConstraints = false panel.addSubview(host) NSLayoutConstraint.activate([ authBar.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28), authBar.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28), authBar.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26), host.leadingAnchor.constraint(equalTo: panel.leadingAnchor), host.trailingAnchor.constraint(equalTo: panel.trailingAnchor), host.topAnchor.constraint(equalTo: authBar.bottomAnchor, constant: 20), host.bottomAnchor.constraint(equalTo: panel.bottomAnchor) ]) mainContentHost = host if googleOAuth.loadTokens() != nil, let profile = scheduleCurrentProfile { applyGoogleProfile(profile) } // Preserve the currently selected page during rebuilds (e.g. dark mode toggle). showSidebarPage(selectedSidebarPage) return panel } func makeJoinMeetingsContent() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false let contentStack = NSStackView() contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.orientation = .vertical contentStack.spacing = 14 contentStack.alignment = .leading let joinActions = meetJoinActionsRow() contentStack.addArrangedSubview(textLabel("Google Classroom", font: typography.pageTitle, color: palette.textPrimary)) contentStack.addArrangedSubview(meetJoinSectionRow()) contentStack.addArrangedSubview(joinActions) contentStack.setCustomSpacing(26, after: joinActions) let scheduleHeaderView = scheduleHeader() contentStack.addArrangedSubview(scheduleHeaderView) contentStack.setCustomSpacing(joinPageScheduleHeaderToDateSpacing, after: scheduleHeaderView) let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary) scheduleDateHeadingLabel = dateHeading contentStack.addArrangedSubview(dateHeading) contentStack.setCustomSpacing(joinPageDateToMeetingCardsSpacing, after: dateHeading) let cardsRow = scheduleCardsRow(todos: []) contentStack.addArrangedSubview(cardsRow) panel.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28), contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28), contentStack.topAnchor.constraint(equalTo: panel.topAnchor) ]) Task { [weak self] in await self?.loadSchedule() } return panel } func makeSchedulePageContent() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false panel.userInterfaceLayoutDirection = .leftToRight let contentStack = NSStackView() contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.userInterfaceLayoutDirection = .leftToRight contentStack.orientation = .vertical contentStack.spacing = schedulePageStackSpacing contentStack.alignment = .width contentStack.distribution = .fill let header = schedulePageHeader() header.setContentHuggingPriority(.required, for: .vertical) header.setContentCompressionResistancePriority(.required, for: .vertical) contentStack.addArrangedSubview(header) contentStack.setCustomSpacing(schedulePageHeaderToDateSpacing, after: header) let heading = textLabel(schedulePageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary) heading.alignment = .left heading.setContentHuggingPriority(.required, for: .vertical) heading.setContentCompressionResistancePriority(.required, for: .vertical) schedulePageDateHeadingLabel = heading contentStack.addArrangedSubview(heading) let rangeError = textLabel("", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .systemRed) rangeError.alignment = .left rangeError.isHidden = true rangeError.setContentHuggingPriority(.required, for: .vertical) rangeError.setContentCompressionResistancePriority(.required, for: .vertical) schedulePageRangeErrorLabel = rangeError contentStack.addArrangedSubview(rangeError) let cardsContainer = makeSchedulePageCardsContainer() cardsContainer.setContentHuggingPriority(.defaultLow, for: .vertical) contentStack.addArrangedSubview(cardsContainer) panel.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: schedulePageLeadingInset), contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -schedulePageTrailingInset), contentStack.topAnchor.constraint(equalTo: panel.topAnchor), contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16), header.widthAnchor.constraint(equalTo: contentStack.widthAnchor), heading.widthAnchor.constraint(equalTo: contentStack.widthAnchor), rangeError.widthAnchor.constraint(equalTo: contentStack.widthAnchor), cardsContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor) ]) Task { [weak self] in await self?.loadSchedule() } return panel } func makeEnrolledPageContent() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false panel.userInterfaceLayoutDirection = .leftToRight let contentStack = NSStackView() contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.userInterfaceLayoutDirection = .leftToRight contentStack.orientation = .vertical contentStack.spacing = 14 contentStack.alignment = .width contentStack.distribution = .fill let titleRow = NSStackView() titleRow.translatesAutoresizingMaskIntoConstraints = false titleRow.userInterfaceLayoutDirection = .leftToRight titleRow.orientation = .horizontal titleRow.alignment = .centerY titleRow.distribution = .fill titleRow.spacing = 10 let titleLabel = textLabel("Enrolled Classes", font: typography.pageTitle, color: palette.textPrimary) titleLabel.alignment = .left titleLabel.maximumNumberOfLines = 1 titleLabel.lineBreakMode = .byTruncatingTail titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let refreshButton = makeScheduleRefreshButton() refreshButton.target = self refreshButton.action = #selector(enrolledPageRefreshPressed(_:)) titleRow.addArrangedSubview(titleLabel) titleRow.addArrangedSubview(spacer) titleRow.addArrangedSubview(refreshButton) let heading = textLabel(enrolledPageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary) heading.alignment = .left heading.maximumNumberOfLines = 2 heading.lineBreakMode = .byWordWrapping enrolledPageHeadingLabel = heading let scroll = NSScrollView() scroll.translatesAutoresizingMaskIntoConstraints = false scroll.userInterfaceLayoutDirection = .leftToRight scroll.drawsBackground = false scroll.hasHorizontalScroller = false scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.borderType = .noBorder scroll.scrollerStyle = .overlay scroll.automaticallyAdjustsContentInsets = false let clip = TopAlignedClipView() clip.drawsBackground = false scroll.contentView = clip let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.userInterfaceLayoutDirection = .leftToRight stack.orientation = .vertical stack.spacing = 14 stack.alignment = .leading enrolledPageCardsStack = stack scroll.documentView = stack NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor), stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor) ]) contentStack.addArrangedSubview(titleRow) contentStack.setCustomSpacing(10, after: titleRow) contentStack.addArrangedSubview(heading) contentStack.setCustomSpacing(12, after: heading) contentStack.addArrangedSubview(scroll) panel.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28), contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28), contentStack.topAnchor.constraint(equalTo: panel.topAnchor), contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16), titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), heading.widthAnchor.constraint(equalTo: contentStack.widthAnchor), scroll.widthAnchor.constraint(equalTo: contentStack.widthAnchor), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420) ]) renderEnrolledClassCards([]) Task { [weak self] in await self?.loadEnrolledClasses() } return panel } func makeTeachingPageContent() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false panel.userInterfaceLayoutDirection = .leftToRight let contentStack = NSStackView() contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.userInterfaceLayoutDirection = .leftToRight contentStack.orientation = .vertical contentStack.spacing = 14 contentStack.alignment = .width contentStack.distribution = .fill let titleRow = NSStackView() titleRow.translatesAutoresizingMaskIntoConstraints = false titleRow.userInterfaceLayoutDirection = .leftToRight titleRow.orientation = .horizontal titleRow.alignment = .centerY titleRow.distribution = .fill titleRow.spacing = 10 let titleLabel = textLabel("Teaching Classes", font: typography.pageTitle, color: palette.textPrimary) titleLabel.alignment = .left titleLabel.maximumNumberOfLines = 1 titleLabel.lineBreakMode = .byTruncatingTail titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let refreshButton = makeScheduleRefreshButton() refreshButton.target = self refreshButton.action = #selector(teachingPageRefreshPressed(_:)) titleRow.addArrangedSubview(titleLabel) titleRow.addArrangedSubview(spacer) titleRow.addArrangedSubview(refreshButton) let heading = textLabel(teachingPageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary) heading.alignment = .left heading.maximumNumberOfLines = 2 heading.lineBreakMode = .byWordWrapping teachingPageHeadingLabel = heading let scroll = NSScrollView() scroll.translatesAutoresizingMaskIntoConstraints = false scroll.userInterfaceLayoutDirection = .leftToRight scroll.drawsBackground = false scroll.hasHorizontalScroller = false scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.borderType = .noBorder scroll.scrollerStyle = .overlay scroll.automaticallyAdjustsContentInsets = false let clip = TopAlignedClipView() clip.drawsBackground = false scroll.contentView = clip let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.userInterfaceLayoutDirection = .leftToRight stack.orientation = .vertical stack.spacing = 14 stack.alignment = .leading teachingPageCardsStack = stack scroll.documentView = stack NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor), stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor) ]) contentStack.addArrangedSubview(titleRow) contentStack.setCustomSpacing(10, after: titleRow) contentStack.addArrangedSubview(heading) contentStack.setCustomSpacing(12, after: heading) contentStack.addArrangedSubview(scroll) panel.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28), contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28), contentStack.topAnchor.constraint(equalTo: panel.topAnchor), contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16), titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), heading.widthAnchor.constraint(equalTo: contentStack.widthAnchor), scroll.widthAnchor.constraint(equalTo: contentStack.widthAnchor), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420) ]) renderTeachingClassCards([]) Task { [weak self] in await self?.loadTeachingClasses() } return panel } func makeCalendarPageContent() -> NSView { let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false panel.userInterfaceLayoutDirection = .leftToRight let contentStack = NSStackView() contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.userInterfaceLayoutDirection = .leftToRight contentStack.orientation = .vertical contentStack.spacing = 14 contentStack.alignment = .width contentStack.distribution = .fill let titleRow = NSStackView() titleRow.translatesAutoresizingMaskIntoConstraints = false titleRow.userInterfaceLayoutDirection = .leftToRight titleRow.orientation = .horizontal titleRow.alignment = .centerY titleRow.distribution = .fill titleRow.spacing = 10 let titleLabel = textLabel("Calendar", font: typography.pageTitle, color: palette.textPrimary) titleLabel.alignment = .left titleLabel.maximumNumberOfLines = 1 titleLabel.lineBreakMode = .byTruncatingTail titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let prevButton = makeCalendarHeaderPillButton(title: "‹", action: #selector(calendarPrevMonthPressed(_:))) prevButton.widthAnchor.constraint(equalToConstant: 46).isActive = true let nextButton = makeCalendarHeaderPillButton(title: "›", action: #selector(calendarNextMonthPressed(_:))) nextButton.widthAnchor.constraint(equalToConstant: 46).isActive = true let monthLabel = textLabel("", font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: palette.textSecondary) monthLabel.alignment = .right monthLabel.maximumNumberOfLines = 1 monthLabel.lineBreakMode = .byTruncatingTail monthLabel.setContentHuggingPriority(.required, for: .horizontal) monthLabel.setContentCompressionResistancePriority(.required, for: .horizontal) calendarPageMonthLabel = monthLabel let refreshButton = HoverButton(title: "", target: self, action: #selector(calendarRefreshPressed(_:))) refreshButton.translatesAutoresizingMaskIntoConstraints = false refreshButton.isBordered = false refreshButton.bezelStyle = .regularSquare refreshButton.wantsLayer = true refreshButton.layer?.cornerRadius = 15 refreshButton.layer?.masksToBounds = true refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor refreshButton.layer?.borderColor = palette.inputBorder.cgColor refreshButton.layer?.borderWidth = 1 refreshButton.setButtonType(.momentaryChange) refreshButton.contentTintColor = palette.textSecondary refreshButton.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Sync calendar") refreshButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold) refreshButton.imagePosition = .imageOnly refreshButton.imageScaling = .scaleProportionallyDown refreshButton.focusRingType = .none refreshButton.heightAnchor.constraint(equalToConstant: 32).isActive = true refreshButton.widthAnchor.constraint(equalToConstant: 32).isActive = true titleRow.addArrangedSubview(titleLabel) titleRow.addArrangedSubview(spacer) titleRow.addArrangedSubview(prevButton) titleRow.addArrangedSubview(nextButton) titleRow.addArrangedSubview(monthLabel) titleRow.addArrangedSubview(refreshButton) let weekdayRow = NSStackView() weekdayRow.translatesAutoresizingMaskIntoConstraints = false weekdayRow.userInterfaceLayoutDirection = .leftToRight weekdayRow.orientation = .horizontal weekdayRow.alignment = .centerY weekdayRow.distribution = .fillEqually weekdayRow.spacing = 12 for symbol in calendarWeekdaySymbolsStartingAtFirstWeekday() { let label = textLabel(symbol, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textMuted) label.alignment = .center label.maximumNumberOfLines = 1 label.lineBreakMode = .byClipping weekdayRow.addArrangedSubview(label) } let gridStack = NSStackView() gridStack.translatesAutoresizingMaskIntoConstraints = false gridStack.userInterfaceLayoutDirection = .leftToRight gridStack.orientation = .vertical gridStack.alignment = .width gridStack.distribution = .fillEqually gridStack.spacing = 10 calendarPageGridStack = gridStack let gridCard = roundedContainer(cornerRadius: 14, color: palette.sectionCard) gridCard.translatesAutoresizingMaskIntoConstraints = false styleSurface(gridCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let gridHeightConstraint = gridCard.heightAnchor.constraint(equalToConstant: 360) gridHeightConstraint.isActive = true calendarPageGridHeightConstraint = gridHeightConstraint gridCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 360).isActive = true gridCard.addSubview(gridStack) NSLayoutConstraint.activate([ gridStack.leadingAnchor.constraint(equalTo: gridCard.leadingAnchor, constant: 16), gridStack.trailingAnchor.constraint(equalTo: gridCard.trailingAnchor, constant: -16), gridStack.topAnchor.constraint(equalTo: gridCard.topAnchor, constant: 16), gridStack.bottomAnchor.constraint(equalTo: gridCard.bottomAnchor, constant: -16) ]) let daySummary = textLabel("", font: typography.dateHeading, color: palette.textSecondary) daySummary.alignment = .left daySummary.maximumNumberOfLines = 2 daySummary.lineBreakMode = .byWordWrapping calendarPageDaySummaryLabel = daySummary contentStack.addArrangedSubview(titleRow) contentStack.setCustomSpacing(16, after: titleRow) contentStack.addArrangedSubview(weekdayRow) contentStack.setCustomSpacing(10, after: weekdayRow) contentStack.addArrangedSubview(gridCard) contentStack.setCustomSpacing(16, after: gridCard) contentStack.addArrangedSubview(daySummary) panel.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28), contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28), contentStack.topAnchor.constraint(equalTo: panel.topAnchor), contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16), titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), weekdayRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), gridCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor), daySummary.widthAnchor.constraint(equalTo: contentStack.widthAnchor) ]) if !storeKitCoordinator.hasPremiumAccess { let lockOverlay = NSVisualEffectView() lockOverlay.translatesAutoresizingMaskIntoConstraints = false lockOverlay.material = darkModeEnabled ? .hudWindow : .popover lockOverlay.blendingMode = .withinWindow lockOverlay.state = .active lockOverlay.wantsLayer = true lockOverlay.layer?.cornerRadius = 14 lockOverlay.layer?.masksToBounds = true lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.30 : 0.12).cgColor panel.addSubview(lockOverlay) let message = textLabel("Premium required. Get Premium now to unlock the calendar view.", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: darkModeEnabled ? .white : .black) message.alignment = .center message.maximumNumberOfLines = 2 message.lineBreakMode = .byWordWrapping lockOverlay.addSubview(message) let hit = HoverTrackingView() hit.translatesAutoresizingMaskIntoConstraints = false hit.wantsLayer = true hit.layer?.backgroundColor = NSColor.clear.cgColor hit.onClick = { [weak self] in self?.showPaywall() } hit.toolTip = "Premium required. Click to open paywall." lockOverlay.addSubview(hit) NSLayoutConstraint.activate([ lockOverlay.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), lockOverlay.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), lockOverlay.topAnchor.constraint(equalTo: contentStack.topAnchor), lockOverlay.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor), message.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor), message.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor), message.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 22), message.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -22), hit.leadingAnchor.constraint(equalTo: lockOverlay.leadingAnchor), hit.trailingAnchor.constraint(equalTo: lockOverlay.trailingAnchor), hit.topAnchor.constraint(equalTo: lockOverlay.topAnchor), hit.bottomAnchor.constraint(equalTo: lockOverlay.bottomAnchor) ]) } let calendar = Calendar.current calendarPageMonthAnchor = calendarStartOfMonth(for: Date()) calendarPageSelectedDate = calendar.startOfDay(for: Date()) calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor) renderCalendarMonthGrid() renderCalendarSelectedDay() Task { [weak self] in await self?.loadSchedule() } return panel } func meetJoinSectionRow() -> NSView { let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.spacing = 12 row.alignment = .top row.distribution = .fillEqually row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true row.heightAnchor.constraint(equalToConstant: 140).isActive = true let instant = HoverSurfaceView() instant.translatesAutoresizingMaskIntoConstraints = false instant.wantsLayer = true instant.layer?.cornerRadius = 14 instant.layer?.backgroundColor = palette.sectionCard.cgColor styleSurface(instant, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let iconWrap = roundedContainer(cornerRadius: 12, color: NSColor.clear) iconWrap.translatesAutoresizingMaskIntoConstraints = false iconWrap.widthAnchor.constraint(equalToConstant: 58).isActive = true iconWrap.heightAnchor.constraint(equalToConstant: 58).isActive = true iconWrap.layer?.borderWidth = 0 let usesClassroomAsset = NSImage(named: "icon_google_classroom") != nil let classLogoImage = NSImage(named: "icon_google_classroom") ?? NSImage(systemSymbolName: "graduationcap.circle.fill", accessibilityDescription: "Google Classroom") ?? NSImage() classLogoImage.isTemplate = false let classLogo = NSImageView(image: classLogoImage) classLogo.translatesAutoresizingMaskIntoConstraints = false classLogo.imageScaling = .scaleProportionallyDown classLogo.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 28, weight: .medium) if !usesClassroomAsset { classLogo.contentTintColor = palette.primaryBlue } iconWrap.addSubview(classLogo) let instantTitle = textLabel("Open Classroom", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary) let instantSub = textLabel("Go to your classes, assignments,\nand stream in Google Classroom.", font: NSFont.systemFont(ofSize: 16 / 2, weight: .medium), color: palette.textSecondary) instantSub.maximumNumberOfLines = 2 instant.addSubview(iconWrap) instant.addSubview(instantTitle) instant.addSubview(instantSub) let codeCard = HoverSurfaceView() codeCard.translatesAutoresizingMaskIntoConstraints = false codeCard.wantsLayer = true codeCard.layer?.cornerRadius = 14 codeCard.layer?.backgroundColor = palette.sectionCard.cgColor styleSurface(codeCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let codeTitle = textLabel("Open class link", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary) let codeInputShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground) codeInputShell.translatesAutoresizingMaskIntoConstraints = false codeInputShell.heightAnchor.constraint(equalToConstant: 52).isActive = true styleSurface(codeInputShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let codeField = NSTextField(string: "") codeField.translatesAutoresizingMaskIntoConstraints = false codeField.isEditable = true codeField.isBordered = false codeField.drawsBackground = false codeField.focusRingType = .none codeField.font = NSFont.systemFont(ofSize: 36 / 2, weight: .regular) codeField.textColor = palette.textPrimary codeField.placeholderString = "Class code or classroom.google.com/…" codeInputShell.addSubview(codeField) meetLinkField = codeField codeCard.addSubview(codeTitle) codeCard.addSubview(codeInputShell) NSLayoutConstraint.activate([ classLogo.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor), classLogo.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor), classLogo.widthAnchor.constraint(equalToConstant: 46), classLogo.heightAnchor.constraint(equalToConstant: 46), iconWrap.leadingAnchor.constraint(equalTo: instant.leadingAnchor, constant: 18), iconWrap.topAnchor.constraint(equalTo: instant.topAnchor, constant: 22), instantTitle.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 14), instantTitle.topAnchor.constraint(equalTo: instant.topAnchor, constant: 24), instantSub.leadingAnchor.constraint(equalTo: instantTitle.leadingAnchor), instantSub.topAnchor.constraint(equalTo: instantTitle.bottomAnchor, constant: 6), instantSub.trailingAnchor.constraint(lessThanOrEqualTo: instant.trailingAnchor, constant: -16), codeTitle.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18), codeTitle.topAnchor.constraint(equalTo: codeCard.topAnchor, constant: 22), codeInputShell.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18), codeInputShell.trailingAnchor.constraint(equalTo: codeCard.trailingAnchor, constant: -18), codeInputShell.topAnchor.constraint(equalTo: codeTitle.bottomAnchor, constant: 12), codeField.leadingAnchor.constraint(equalTo: codeInputShell.leadingAnchor, constant: 14), codeField.trailingAnchor.constraint(equalTo: codeInputShell.trailingAnchor, constant: -14), codeField.centerYAnchor.constraint(equalTo: codeInputShell.centerYAnchor) ]) let baseColor = palette.sectionCard let baseBorderColor = palette.inputBorder let classroomHoverTint = NSColor(calibratedRed: 0.46, green: 0.80, blue: 0.42, alpha: 1.0) let hoverColor = baseColor.blended(withFraction: darkModeEnabled ? 0.24 : 0.16, of: classroomHoverTint) ?? baseColor let hoverBorderColor = baseBorderColor.blended(withFraction: darkModeEnabled ? 0.45 : 0.32, of: classroomHoverTint) ?? baseBorderColor instant.onHoverChanged = { [weak self] hovering in guard self != nil else { return } instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor instant.layer?.borderColor = (hovering ? hoverBorderColor : baseBorderColor).cgColor } codeCard.onHoverChanged = { [weak self] hovering in guard self != nil else { return } codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor codeCard.layer?.borderColor = (hovering ? hoverBorderColor : baseBorderColor).cgColor } instant.onHoverChanged?(false) codeCard.onHoverChanged?(false) let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:))) instant.addGestureRecognizer(instantClick) let joinWithLinkClick = NSClickGestureRecognizer(target: self, action: #selector(joinWithLinkCardClicked(_:))) codeCard.addGestureRecognizer(joinWithLinkClick) instantMeetCardView = instant instantMeetTitleLabel = instantTitle instantMeetSubtitleLabel = instantSub joinWithLinkCardView = codeCard joinWithLinkTitleLabel = codeTitle refreshInstantMeetPremiumState() row.addArrangedSubview(instant) row.addArrangedSubview(codeCard) return row } func meetJoinActionsRow() -> NSView { let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.spacing = 12 row.alignment = .centerY row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false row.addArrangedSubview(spacer) row.addArrangedSubview(meetActionButton( title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110, action: #selector(cancelMeetJoinClicked(_:)) )) let joinButton = meetActionButton( title: "Open", color: palette.primaryBlue, textColor: .white, width: 116, action: #selector(joinMeetClicked(_:)) ) joinMeetPrimaryButton = joinButton row.addArrangedSubview(joinButton) refreshInstantMeetPremiumState() return row } func meetActionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat, action: Selector) -> NSButton { let button = HoverButton(title: title, target: self, action: action) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = 9 let baseBackground = color let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverBackground = baseBackground.blended(withFraction: 0.10, of: hoverBlend) ?? baseBackground let baseBorder = (title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder) let hoverBorder = baseBorder.blended(withFraction: 0.18, of: hoverBlend) ?? baseBorder button.layer?.backgroundColor = baseBackground.cgColor button.layer?.borderColor = baseBorder.cgColor button.layer?.borderWidth = 1 button.font = typography.buttonText button.contentTintColor = textColor button.widthAnchor.constraint(equalToConstant: width).isActive = true button.heightAnchor.constraint(equalToConstant: 36).isActive = true button.onHoverChanged = { [weak self, weak button] hovering in guard let self, let button else { return } button.layer?.backgroundColor = (hovering ? hoverBackground : baseBackground).cgColor button.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor if title == "Cancel" { button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : textColor } } button.onHoverChanged?(false) return button } func makePaywallContent() -> NSView { paywallPlanViews.removeAll() premiumPlanByView.removeAll() let panel = NSView() panel.translatesAutoresizingMaskIntoConstraints = false panel.wantsLayer = true panel.layer?.backgroundColor = palette.pageBackground.cgColor let contentStack = NSStackView() contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.orientation = .vertical contentStack.spacing = 12 contentStack.alignment = .leading panel.addSubview(contentStack) let topRow = NSStackView() topRow.translatesAutoresizingMaskIntoConstraints = false topRow.orientation = .horizontal topRow.alignment = .centerY topRow.distribution = .fill topRow.spacing = 10 topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: palette.textPrimary)) let topSpacer = NSView() topSpacer.translatesAutoresizingMaskIntoConstraints = false topRow.addArrangedSubview(topSpacer) let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:))) closeButton.translatesAutoresizingMaskIntoConstraints = false closeButton.isBordered = false closeButton.bezelStyle = .regularSquare closeButton.wantsLayer = true closeButton.layer?.cornerRadius = 14 closeButton.layer?.backgroundColor = palette.inputBackground.cgColor closeButton.layer?.borderColor = palette.inputBorder.cgColor closeButton.layer?.borderWidth = 1 closeButton.font = typography.iconButton closeButton.contentTintColor = palette.textSecondary closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true closeButton.onHoverChanged = { [weak closeButton, weak self] hovering in guard let closeButton, let self else { return } let base = self.palette.inputBackground let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base closeButton.layer?.backgroundColor = (hovering ? hover : base).cgColor closeButton.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary } topRow.addArrangedSubview(closeButton) topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true contentStack.addArrangedSubview(topRow) contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textSecondary)) let benefits = paywallBenefitsSection() contentStack.addArrangedSubview(benefits) contentStack.setCustomSpacing(18, after: benefits) let weeklyCard = paywallPlanCard( title: "Weekly", price: "PKR 1,100.00", badge: "Basic Deal", badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1), subtitle: nil, plan: .weekly, strikePrice: nil ) contentStack.addArrangedSubview(weeklyCard) let monthlyCard = paywallPlanCard( title: "Monthly", price: "PKR 2,500.00", badge: "Free Trial", badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1), subtitle: "625.00/week", plan: .monthly, strikePrice: nil ) contentStack.addArrangedSubview(monthlyCard) let yearlyCard = paywallPlanCard( title: "Yearly", price: "PKR 9,900.00", badge: "Best Deal", badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1), subtitle: "190.38/week", plan: .yearly, strikePrice: nil ) contentStack.addArrangedSubview(yearlyCard) let lifetimeCard = paywallPlanCard( title: "Lifetime", price: "PKR 14,900.00", badge: "Save 50%", badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1), subtitle: nil, plan: .lifetime, strikePrice: "PKR 29,800.00" ) contentStack.addArrangedSubview(lifetimeCard) updatePaywallPlanSelection() contentStack.setCustomSpacing(20, after: lifetimeCard) let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary) offer.alignment = .center paywallOfferLabel = offer let offerWrap = NSView() offerWrap.translatesAutoresizingMaskIntoConstraints = false offerWrap.addSubview(offer) NSLayoutConstraint.activate([ offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth), offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor), offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6), offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2) ]) contentStack.addArrangedSubview(offerWrap) contentStack.setCustomSpacing(18, after: offerWrap) let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:))) continueButton.translatesAutoresizingMaskIntoConstraints = false continueButton.isBordered = false continueButton.bezelStyle = .regularSquare continueButton.wantsLayer = true continueButton.layer?.cornerRadius = 14 continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true) let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white) continueButton.addSubview(continueLabel) NSLayoutConstraint.activate([ continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor), continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor) ]) let baseBlue = palette.primaryBlue let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverBlue = baseBlue.blended(withFraction: 0.10, of: hoverBlend) ?? baseBlue continueButton.onHoverChanged = { hovering in continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor } continueButton.onHoverChanged?(false) paywallContinueButton = continueButton paywallContinueLabel = continueLabel contentStack.addArrangedSubview(continueButton) contentStack.setCustomSpacing(16, after: continueButton) let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary) secure.alignment = .center let secureWrap = NSView() secureWrap.translatesAutoresizingMaskIntoConstraints = false secureWrap.addSubview(secure) NSLayoutConstraint.activate([ secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth), secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor), secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4), secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8) ]) contentStack.addArrangedSubview(secureWrap) contentStack.setCustomSpacing(16, after: secureWrap) let footer = paywallFooterLinks() contentStack.addArrangedSubview(footer) NSLayoutConstraint.activate([ contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18), contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18), contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16), contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12) ]) refreshPaywallStoreUI() return panel } func paywallPlanCard( title: String, price: String, badge: String, badgeColor: NSColor, subtitle: String?, plan: PremiumPlan, strikePrice: String? ) -> NSView { let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:))) wrapper.translatesAutoresizingMaskIntoConstraints = false wrapper.isBordered = false wrapper.bezelStyle = .regularSquare wrapper.wantsLayer = true wrapper.layer?.backgroundColor = NSColor.clear.cgColor wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true wrapper.tag = plan.rawValue let card = HoverTrackingView() card.translatesAutoresizingMaskIntoConstraints = false card.wantsLayer = true card.layer?.cornerRadius = 16 card.layer?.backgroundColor = palette.sectionCard.cgColor card.heightAnchor.constraint(equalToConstant: 82).isActive = true wrapper.addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12), card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor) ]) styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white) let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor) badgeWrap.translatesAutoresizingMaskIntoConstraints = false badgeWrap.wantsLayer = true badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor badgeWrap.layer?.borderWidth = 1 badgeWrap.layer?.shadowColor = NSColor.black.cgColor badgeWrap.layer?.shadowOpacity = 0.20 badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1) badgeWrap.layer?.shadowRadius = 3 badgeWrap.addSubview(badgeLabel) NSLayoutConstraint.activate([ badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8), badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8), badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2), badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2) ]) wrapper.addSubview(badgeWrap) let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: palette.primaryBlue) card.addSubview(titleLabel) let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary) card.addSubview(priceLabel) paywallPriceLabels[plan] = priceLabel NSLayoutConstraint.activate([ badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor), badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor), titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34), priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32) ]) if let subtitle { let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary) card.addSubview(sub) paywallSubtitleLabels[plan] = sub NSLayoutConstraint.activate([ sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor), sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0) ]) } if let strikePrice { let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed) card.addSubview(strike) NSLayoutConstraint.activate([ strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor), strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4) ]) } paywallPlanViews[plan] = card wrapper.onHoverChanged = { [weak self, weak card] hovering in guard let self, let card else { return } self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering) } wrapper.onHoverChanged?(false) return wrapper } func paywallFooterLinks() -> NSView { let wrap = NSView() wrap.translatesAutoresizingMaskIntoConstraints = false wrap.heightAnchor.constraint(equalToConstant: 34).isActive = true wrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.distribution = .fillEqually row.alignment = .centerY row.spacing = 0 wrap.addSubview(row) row.addArrangedSubview(footerLink("Privacy Policy")) row.addArrangedSubview(footerLink("Support")) row.addArrangedSubview(footerLink("Terms of Services")) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor), row.trailingAnchor.constraint(equalTo: wrap.trailingAnchor), row.topAnchor.constraint(equalTo: wrap.topAnchor), row.bottomAnchor.constraint(equalTo: wrap.bottomAnchor) ]) return wrap } func footerLink(_ title: String) -> NSView { let container = HoverTrackingView() container.translatesAutoresizingMaskIntoConstraints = false let label = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary) label.alignment = .center container.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: container.centerXAnchor), label.centerYAnchor.constraint(equalTo: container.centerYAnchor) ]) let click = NSClickGestureRecognizer(target: self, action: #selector(paywallFooterLinkClicked(_:))) container.addGestureRecognizer(click) container.onHoverChanged = { hovering in label.textColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary } container.onHoverChanged?(false) return container } func paywallBenefitsSection() -> NSView { let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .vertical stack.spacing = 8 stack.alignment = .leading stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true let rowOne = NSStackView() rowOne.translatesAutoresizingMaskIntoConstraints = false rowOne.orientation = .horizontal rowOne.spacing = 10 rowOne.distribution = .fillEqually rowOne.alignment = .centerY rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Classes & calendar")) rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds")) let rowTwo = NSStackView() rowTwo.translatesAutoresizingMaskIntoConstraints = false rowTwo.orientation = .horizontal rowTwo.spacing = 10 rowTwo.distribution = .fillEqually rowTwo.alignment = .centerY rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity")) rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support")) stack.addArrangedSubview(rowOne) stack.addArrangedSubview(rowTwo) return stack } func paywallBenefitItem(icon: String, text: String) -> NSView { let card = HoverTrackingView() card.translatesAutoresizingMaskIntoConstraints = false card.wantsLayer = true card.layer?.cornerRadius = 10 card.layer?.backgroundColor = palette.inputBackground.cgColor card.heightAnchor.constraint(equalToConstant: 36).isActive = true styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground) iconWrap.translatesAutoresizingMaskIntoConstraints = false iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.primaryBlue) iconWrap.addSubview(iconLabel) NSLayoutConstraint.activate([ iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor), iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor) ]) let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textPrimary) card.addSubview(iconWrap) card.addSubview(title) NSLayoutConstraint.activate([ iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8), iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor), title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10), title.centerYAnchor.constraint(equalTo: card.centerYAnchor), title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8) ]) let base = palette.inputBackground let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base let hoverBorder = palette.primaryBlueBorder.withAlphaComponent(0.55) card.onHoverChanged = { [weak card, weak iconWrap] hovering in guard let card else { return } card.layer?.backgroundColor = (hovering ? hover : base).cgColor card.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor iconWrap?.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor } card.onHoverChanged?(false) return card } func zoomJoinModeTabs() -> NSView { let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.alignment = .centerY row.spacing = 28 row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true let idTab = joinModeTab("Join with ID", mode: .id) let urlTab = joinModeTab("Join with URL", mode: .url) row.addArrangedSubview(idTab) row.addArrangedSubview(urlTab) let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false row.addArrangedSubview(spacer) zoomJoinModeViews[.id] = idTab zoomJoinModeViews[.url] = urlTab updateZoomJoinModeAppearance() return row } func joinModeTab(_ title: String, mode: ZoomJoinMode) -> NSView { let tab = HoverTrackingView() tab.translatesAutoresizingMaskIntoConstraints = false tab.wantsLayer = true tab.layer?.cornerRadius = 6 tab.layer?.backgroundColor = NSColor.clear.cgColor tab.heightAnchor.constraint(equalToConstant: 30).isActive = true zoomJoinModeByView[ObjectIdentifier(tab)] = mode let label = textLabel(title, font: NSFont.systemFont(ofSize: 33 / 2, weight: .medium), color: palette.textPrimary) tab.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: tab.leadingAnchor, constant: 4), label.trailingAnchor.constraint(equalTo: tab.trailingAnchor, constant: -4), label.topAnchor.constraint(equalTo: tab.topAnchor, constant: 4), label.bottomAnchor.constraint(equalTo: tab.bottomAnchor, constant: -6) ]) let click = NSClickGestureRecognizer(target: self, action: #selector(zoomJoinModeClicked(_:))) tab.addGestureRecognizer(click) return tab } func updateZoomJoinModeAppearance() { for (mode, tab) in zoomJoinModeViews { let selected = (mode == selectedZoomJoinMode) let textColor = selected ? palette.textPrimary : palette.textSecondary let label = tab.subviews.first { $0 is NSTextField } as? NSTextField label?.textColor = textColor // Keep the active tab visually underlined like the reference. if selected { if tab.subviews.contains(where: { $0.identifier?.rawValue == "modeUnderline" }) == false { let underline = NSView() underline.identifier = NSUserInterfaceItemIdentifier("modeUnderline") underline.translatesAutoresizingMaskIntoConstraints = false underline.wantsLayer = true underline.layer?.backgroundColor = palette.primaryBlue.cgColor tab.addSubview(underline) NSLayoutConstraint.activate([ underline.leadingAnchor.constraint(equalTo: tab.leadingAnchor), underline.trailingAnchor.constraint(equalTo: tab.trailingAnchor), underline.bottomAnchor.constraint(equalTo: tab.bottomAnchor), underline.heightAnchor.constraint(equalToConstant: 2) ]) } } else { tab.subviews .filter { $0.identifier?.rawValue == "modeUnderline" } .forEach { $0.removeFromSuperview() } } } } func joinWithIDHeading() -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false let title = textLabel("Join with ID", font: typography.joinWithURLTitle, color: palette.textPrimary) title.alignment = .left title.setContentHuggingPriority(.defaultHigh, for: .horizontal) title.setContentCompressionResistancePriority(.required, for: .horizontal) let bar = NSView() bar.translatesAutoresizingMaskIntoConstraints = false bar.wantsLayer = true bar.layer?.backgroundColor = palette.primaryBlue.cgColor bar.heightAnchor.constraint(equalToConstant: 3).isActive = true container.addSubview(title) container.addSubview(bar) NSLayoutConstraint.activate([ title.leadingAnchor.constraint(equalTo: container.leadingAnchor), title.topAnchor.constraint(equalTo: container.topAnchor), bar.leadingAnchor.constraint(equalTo: title.leadingAnchor), bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6), bar.widthAnchor.constraint(equalTo: title.widthAnchor), bar.bottomAnchor.constraint(equalTo: container.bottomAnchor), container.trailingAnchor.constraint(equalTo: title.trailingAnchor) ]) return container } func zoomMeetingIDSection() -> NSView { let wrapper = NSView() wrapper.translatesAutoresizingMaskIntoConstraints = false let fieldsRow = NSStackView() fieldsRow.translatesAutoresizingMaskIntoConstraints = false fieldsRow.orientation = .horizontal fieldsRow.alignment = .top fieldsRow.distribution = .fillEqually fieldsRow.spacing = 12 fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting ID", placeholder: "Enter meeting ID...")) fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting Passcode", placeholder: "Enter meeting passcode...")) let actions = NSStackView() actions.orientation = .horizontal actions.spacing = 10 actions.translatesAutoresizingMaskIntoConstraints = false actions.alignment = .centerY actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110)) actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116)) wrapper.addSubview(fieldsRow) wrapper.addSubview(actions) NSLayoutConstraint.activate([ wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780), fieldsRow.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), fieldsRow.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), fieldsRow.topAnchor.constraint(equalTo: wrapper.topAnchor), actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), actions.topAnchor.constraint(equalTo: fieldsRow.bottomAnchor, constant: 14), actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor) ]) return wrapper } func zoomInputField(title: String, placeholder: String) -> NSView { let wrapper = NSView() wrapper.translatesAutoresizingMaskIntoConstraints = false let heading = textLabel(title, font: typography.fieldLabel, color: palette.textPrimary) let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground) textFieldContainer.translatesAutoresizingMaskIntoConstraints = false textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let field = NSTextField(string: "") field.translatesAutoresizingMaskIntoConstraints = false field.isEditable = true field.isSelectable = true field.isBordered = false field.drawsBackground = false field.placeholderString = placeholder field.font = typography.inputPlaceholder field.textColor = palette.textPrimary field.focusRingType = .none textFieldContainer.addSubview(field) wrapper.addSubview(heading) wrapper.addSubview(textFieldContainer) NSLayoutConstraint.activate([ heading.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), heading.topAnchor.constraint(equalTo: wrapper.topAnchor), textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), textFieldContainer.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 10), textFieldContainer.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor), field.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12), field.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12), field.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor) ]) return wrapper } func joinWithURLHeading() -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false let title = textLabel("Join with URL", font: typography.joinWithURLTitle, color: palette.textPrimary) title.alignment = .left title.setContentHuggingPriority(.defaultHigh, for: .horizontal) title.setContentCompressionResistancePriority(.required, for: .horizontal) let bar = NSView() bar.translatesAutoresizingMaskIntoConstraints = false bar.wantsLayer = true bar.layer?.backgroundColor = palette.primaryBlue.cgColor bar.heightAnchor.constraint(equalToConstant: 3).isActive = true container.addSubview(title) container.addSubview(bar) NSLayoutConstraint.activate([ title.leadingAnchor.constraint(equalTo: container.leadingAnchor), title.topAnchor.constraint(equalTo: container.topAnchor), bar.leadingAnchor.constraint(equalTo: title.leadingAnchor), bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6), bar.widthAnchor.constraint(equalTo: title.widthAnchor), bar.bottomAnchor.constraint(equalTo: container.bottomAnchor), container.trailingAnchor.constraint(equalTo: title.trailingAnchor) ]) return container } func meetingUrlSection() -> NSView { let wrapper = NSView() wrapper.translatesAutoresizingMaskIntoConstraints = false let title = textLabel("Meeting URL", font: typography.fieldLabel, color: palette.textSecondary) let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground) textFieldContainer.translatesAutoresizingMaskIntoConstraints = false textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let urlField = NSTextField(string: "") urlField.translatesAutoresizingMaskIntoConstraints = false urlField.isEditable = true urlField.isSelectable = true urlField.isBordered = false urlField.drawsBackground = false urlField.placeholderString = "Enter meeting URL..." urlField.font = typography.inputPlaceholder urlField.textColor = palette.textPrimary urlField.focusRingType = .none textFieldContainer.addSubview(urlField) let actions = NSStackView() actions.orientation = .horizontal actions.spacing = 10 actions.translatesAutoresizingMaskIntoConstraints = false actions.alignment = .centerY actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110)) actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116)) wrapper.addSubview(title) wrapper.addSubview(textFieldContainer) wrapper.addSubview(actions) NSLayoutConstraint.activate([ wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780), title.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), title.topAnchor.constraint(equalTo: wrapper.topAnchor), textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), textFieldContainer.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10), urlField.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12), urlField.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12), urlField.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor), actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), actions.topAnchor.constraint(equalTo: textFieldContainer.bottomAnchor, constant: 14), actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor) ]) return wrapper } func scheduleHeader() -> NSView { let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.alignment = .centerY row.distribution = .fill row.spacing = 12 row.addArrangedSubview(textLabel("To-do", font: typography.sectionTitleBold, color: palette.textPrimary)) let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false row.addArrangedSubview(spacer) spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) row.addArrangedSubview(makeScheduleRefreshButton()) row.addArrangedSubview(makeScheduleFilterDropdown()) row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true return row } private func schedulePageHeader() -> NSView { let container = NSStackView() container.translatesAutoresizingMaskIntoConstraints = false container.userInterfaceLayoutDirection = .leftToRight container.orientation = .vertical container.spacing = 8 container.alignment = .width let titleRow = NSStackView() titleRow.translatesAutoresizingMaskIntoConstraints = false titleRow.userInterfaceLayoutDirection = .leftToRight titleRow.orientation = .horizontal titleRow.alignment = .centerY titleRow.distribution = .fill titleRow.spacing = 0 let titleLabel = textLabel("To-do", font: typography.pageTitle, color: palette.textPrimary) titleLabel.alignment = .left titleLabel.userInterfaceLayoutDirection = .leftToRight titleLabel.maximumNumberOfLines = 1 titleLabel.lineBreakMode = .byTruncatingTail titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let titleRowSpacer = NSView() titleRowSpacer.translatesAutoresizingMaskIntoConstraints = false titleRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) titleRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleRow.addArrangedSubview(titleLabel) // To-do items are read-only from Classroom. titleRow.addArrangedSubview(titleRowSpacer) container.addArrangedSubview(titleRow) let filterRow = NSStackView() filterRow.translatesAutoresizingMaskIntoConstraints = false filterRow.userInterfaceLayoutDirection = .leftToRight filterRow.orientation = .horizontal filterRow.alignment = .centerY filterRow.spacing = 18 filterRow.distribution = .fill let filterDropdown = makeSchedulePageFilterDropdown() schedulePageFilterDropdown = filterDropdown filterRow.addArrangedSubview(filterDropdown) filterRow.setCustomSpacing(20, after: filterDropdown) let (fromShell, fromPicker) = makeScheduleDatePicker(date: schedulePageFromDate) schedulePageFromDatePicker = fromPicker filterRow.addArrangedSubview(fromShell) filterRow.setCustomSpacing(16, after: fromShell) let (toShell, toPicker) = makeScheduleDatePicker(date: schedulePageToDate) schedulePageToDatePicker = toPicker filterRow.addArrangedSubview(toShell) NSLayoutConstraint.activate([ fromShell.widthAnchor.constraint(equalTo: toShell.widthAnchor), fromShell.widthAnchor.constraint(greaterThanOrEqualToConstant: 152) ]) let filterRowSpacer = NSView() filterRowSpacer.translatesAutoresizingMaskIntoConstraints = false filterRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) filterRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) filterRow.addArrangedSubview(filterRowSpacer) let applyButton = makeSchedulePagePillButton(title: "Apply", action: #selector(schedulePageApplyDateRangePressed(_:))) filterRow.addArrangedSubview(applyButton) filterRow.setCustomSpacing(22, after: applyButton) let resetButton = makeSchedulePagePillButton(title: "Reset", action: #selector(schedulePageResetFiltersPressed(_:))) filterRow.addArrangedSubview(resetButton) filterRow.setCustomSpacing(22, after: resetButton) filterRow.addArrangedSubview(makeScheduleRefreshButton()) container.addArrangedSubview(filterRow) NSLayoutConstraint.activate([ titleRow.widthAnchor.constraint(equalTo: container.widthAnchor), filterRow.widthAnchor.constraint(equalTo: container.widthAnchor) ]) refreshSchedulePageDateFilterUI() return container } private func makeSchedulePageFilterDropdown() -> NSPopUpButton { let button = HoverPopUpButton(frame: .zero, pullsDown: false) button.translatesAutoresizingMaskIntoConstraints = false button.autoenablesItems = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = 8 button.layer?.masksToBounds = true button.layer?.backgroundColor = palette.inputBackground.cgColor button.layer?.borderColor = palette.inputBorder.cgColor button.layer?.borderWidth = 1 button.font = typography.filterText button.contentTintColor = palette.textSecondary button.target = self button.action = #selector(schedulePageFilterDropdownChanged(_:)) button.heightAnchor.constraint(equalToConstant: 34).isActive = true button.widthAnchor.constraint(equalToConstant: 228).isActive = true button.removeAllItems() button.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"]) button.selectItem(at: schedulePageFilter.rawValue) if let menu = button.menu { for (index, item) in menu.items.enumerated() { item.tag = index } } let baseColor = palette.inputBackground let baseBorder = palette.inputBorder let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder button.onHoverChanged = { [weak button] hovering in button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor } button.onHoverChanged?(false) return button } /// Rounded shell matching `makeSchedulePageFilterDropdown` (34pt, 8pt corner, border + hover). Both pickers use the same field+stepper style inside the shell. private func makeScheduleDatePicker(date: Date) -> (NSView, NSDatePicker) { let shell = HoverSurfaceView() shell.translatesAutoresizingMaskIntoConstraints = false shell.wantsLayer = true shell.layer?.cornerRadius = 8 shell.layer?.masksToBounds = true shell.layer?.borderWidth = 1 let baseColor = palette.inputBackground let baseBorder = palette.inputBorder let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverBackground = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder func applyShellIdleAppearance() { shell.layer?.backgroundColor = baseColor.cgColor shell.layer?.borderColor = baseBorder.cgColor } applyShellIdleAppearance() shell.onHoverChanged = { [weak shell] hovering in guard let shell else { return } shell.layer?.backgroundColor = (hovering ? hoverBackground : baseColor).cgColor shell.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor } let picker = NSDatePicker() picker.translatesAutoresizingMaskIntoConstraints = false picker.isBordered = false picker.drawsBackground = false picker.focusRingType = .none picker.datePickerStyle = .textFieldAndStepper picker.datePickerElements = [.yearMonthDay] picker.dateValue = date picker.font = typography.filterText picker.textColor = palette.textSecondary picker.setContentHuggingPriority(.defaultLow, for: .horizontal) picker.target = self picker.action = #selector(schedulePageDatePickerChanged(_:)) shell.addSubview(picker) NSLayoutConstraint.activate([ shell.heightAnchor.constraint(equalToConstant: 34), picker.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: 8), picker.trailingAnchor.constraint(equalTo: shell.trailingAnchor, constant: -8), picker.centerYAnchor.constraint(equalTo: shell.centerYAnchor) ]) return (shell, picker) } private func makeSchedulePagePillButton(title: String, action: Selector) -> NSButton { let button = makeSchedulePillButton(title: title) button.target = self button.action = action button.widthAnchor.constraint(equalToConstant: 100).isActive = true return button } private func makeSchedulePageCardsContainer() -> NSView { if let observer = schedulePageScrollObservation { NotificationCenter.default.removeObserver(observer) } schedulePageScrollObservation = nil let wrapper = NSView() wrapper.translatesAutoresizingMaskIntoConstraints = false wrapper.userInterfaceLayoutDirection = .leftToRight let scroll = NSScrollView() scroll.translatesAutoresizingMaskIntoConstraints = false scroll.userInterfaceLayoutDirection = .leftToRight scroll.drawsBackground = false scroll.hasHorizontalScroller = false scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.borderType = .noBorder scroll.scrollerStyle = .overlay scroll.automaticallyAdjustsContentInsets = false let clip = TopAlignedClipView() clip.drawsBackground = false clip.postsBoundsChangedNotifications = true scroll.contentView = clip schedulePageCardsScrollView = scroll wrapper.addSubview(scroll) let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.userInterfaceLayoutDirection = .leftToRight stack.orientation = .vertical stack.spacing = schedulePageCardSpacing stack.alignment = .width schedulePageCardsStack = stack scroll.documentView = stack NSLayoutConstraint.activate([ scroll.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), scroll.topAnchor.constraint(equalTo: wrapper.topAnchor), scroll.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420), stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor), stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor) ]) scroll.contentView.postsBoundsChangedNotifications = true schedulePageScrollObservation = NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, object: scroll.contentView, queue: .main ) { [weak self] _ in self?.schedulePageScrolled() } renderSchedulePageCards() return wrapper } private func scheduleTopAuthRow() -> NSView { let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.alignment = .centerY row.spacing = 10 let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false row.addArrangedSubview(spacer) spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) let host = GoogleProfileAuthHostView() host.translatesAutoresizingMaskIntoConstraints = false let authButton = makeGoogleAuthButton() host.authButton = authButton scheduleGoogleAuthHostView = host scheduleGoogleAuthButton = authButton host.addSubview(authButton) NSLayoutConstraint.activate([ authButton.centerXAnchor.constraint(equalTo: host.centerXAnchor), authButton.centerYAnchor.constraint(equalTo: host.centerYAnchor) ]) let hostPadW = host.widthAnchor.constraint(equalTo: authButton.widthAnchor, constant: 0) let hostPadH = host.heightAnchor.constraint(equalTo: authButton.heightAnchor, constant: 0) hostPadW.isActive = true hostPadH.isActive = true scheduleGoogleAuthHostPadWidthConstraint = hostPadW scheduleGoogleAuthHostPadHeightConstraint = hostPadH updateGoogleAuthButtonTitle() row.addArrangedSubview(host) row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true return row } private func makeScheduleFilterDropdown() -> NSPopUpButton { let button = HoverPopUpButton(frame: .zero, pullsDown: false) button.translatesAutoresizingMaskIntoConstraints = false button.autoenablesItems = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = 8 button.layer?.masksToBounds = true button.layer?.backgroundColor = palette.inputBackground.cgColor button.layer?.borderColor = palette.inputBorder.cgColor button.layer?.borderWidth = 1 button.font = typography.filterText button.contentTintColor = palette.textSecondary button.target = self button.action = #selector(scheduleFilterDropdownChanged(_:)) button.heightAnchor.constraint(equalToConstant: 34).isActive = true button.widthAnchor.constraint(equalToConstant: 156).isActive = true button.removeAllItems() button.addItems(withTitles: ["All", "Today", "This week"]) button.selectItem(at: scheduleFilter.rawValue) if let menu = button.menu { for (index, item) in menu.items.enumerated() { item.tag = index } } let baseColor = palette.inputBackground let baseBorder = palette.inputBorder let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder button.onHoverChanged = { [weak button] hovering in button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor } button.onHoverChanged?(false) scheduleFilterDropdown = button return button } private func makeSchedulePillButton(title: String) -> NSButton { let button = NSButton(title: title, target: nil, action: nil) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = 8 button.layer?.backgroundColor = palette.inputBackground.cgColor button.layer?.borderColor = palette.inputBorder.cgColor button.layer?.borderWidth = 1 button.font = typography.filterText button.contentTintColor = palette.textSecondary button.setButtonType(.momentaryChange) button.heightAnchor.constraint(equalToConstant: 34).isActive = true button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true return button } private func makeGoogleAuthButton() -> NSButton { let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:))) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = 16 button.layer?.borderWidth = 1 button.font = NSFont.systemFont(ofSize: 14, weight: .semibold) button.imagePosition = .imageLeading button.alignment = .center button.imageHugsTitle = true button.lineBreakMode = .byTruncatingTail button.contentTintColor = palette.textPrimary button.imageScaling = .scaleNone button.layer?.masksToBounds = true let heightConstraint = button.heightAnchor.constraint(equalToConstant: 42) heightConstraint.isActive = true scheduleGoogleAuthButtonHeightConstraint = heightConstraint let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248) widthConstraint.isActive = true scheduleGoogleAuthButtonWidthConstraint = widthConstraint button.onHoverChanged = { [weak self] hovering in self?.scheduleGoogleAuthHovering = hovering self?.scheduleGoogleAuthHostView?.setProfileHoverActive(hovering) self?.applyGoogleAuthButtonSurface() } button.onHoverChanged?(false) return button } private func makeSchedulePageAddButton() -> NSButton { let diameter: CGFloat = 30 let button = HoverButton(title: "", target: self, action: #selector(schedulePageAddMeetingPressed(_:))) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = diameter / 2 button.layer?.masksToBounds = true button.layer?.backgroundColor = palette.inputBackground.cgColor button.layer?.borderColor = palette.inputBorder.cgColor button.layer?.borderWidth = 1 button.setButtonType(.momentaryChange) button.contentTintColor = palette.textSecondary button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Add calendar event") button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold) button.imagePosition = .imageOnly button.imageScaling = .scaleProportionallyDown button.focusRingType = .none button.heightAnchor.constraint(equalToConstant: diameter).isActive = true button.widthAnchor.constraint(equalToConstant: diameter).isActive = true let baseColor = palette.inputBackground let baseBorder = palette.inputBorder let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder button.onHoverChanged = { [weak button] hovering in button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor } button.onHoverChanged?(false) return button } private func makeScheduleRefreshButton() -> NSButton { let diameter: CGFloat = 30 let button = HoverButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:))) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = diameter / 2 button.layer?.masksToBounds = true button.layer?.backgroundColor = palette.inputBackground.cgColor button.layer?.borderColor = palette.inputBorder.cgColor button.layer?.borderWidth = 1 button.setButtonType(.momentaryChange) button.contentTintColor = palette.textSecondary button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh calendar") button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold) button.imagePosition = .imageOnly button.imageScaling = .scaleProportionallyDown button.focusRingType = .none button.heightAnchor.constraint(equalToConstant: diameter).isActive = true button.widthAnchor.constraint(equalToConstant: diameter).isActive = true let baseColor = palette.inputBackground let baseBorder = palette.inputBorder let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder button.onHoverChanged = { [weak button] hovering in button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor } button.onHoverChanged?(false) return button } func scheduleCardsRow(todos: [ClassroomTodoItem]) -> NSView { let cardWidth: CGFloat = 240 let cardsPerViewport: CGFloat = 3 let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1)) let wrapper = NSStackView() wrapper.translatesAutoresizingMaskIntoConstraints = false wrapper.orientation = .horizontal wrapper.alignment = .centerY wrapper.spacing = 10 let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:))) scheduleScrollLeftButton = leftButton wrapper.addArrangedSubview(leftButton) let scroll = NSScrollView() scheduleCardsScrollView = scroll scroll.translatesAutoresizingMaskIntoConstraints = false scroll.drawsBackground = false scroll.hasHorizontalScroller = false scroll.hasVerticalScroller = false scroll.horizontalScrollElasticity = .allowed scroll.verticalScrollElasticity = .none scroll.autohidesScrollers = false scroll.borderType = .noBorder scroll.automaticallyAdjustsContentInsets = false scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.spacing = 12 row.alignment = .top row.distribution = .gravityAreas row.setContentHuggingPriority(.defaultHigh, for: .horizontal) row.heightAnchor.constraint(equalToConstant: 150).isActive = true scheduleCardsStack = row scroll.documentView = row scroll.contentView.postsBoundsChangedNotifications = true // Pin top/leading/trailing only; avoid bottom == clip so the horizontal stack is not stretched vertically inside the clip (same as Schedule cards scroll). NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor), row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), row.heightAnchor.constraint(equalToConstant: 150) ]) renderScheduleCards(into: row, todos: todos) wrapper.addArrangedSubview(scroll) let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:))) scheduleScrollRightButton = rightButton wrapper.addArrangedSubview(rightButton) scroll.setContentHuggingPriority(.defaultLow, for: .horizontal) scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true return wrapper } func scheduleCard(todo: ClassroomTodoItem, useFlexibleWidth: Bool = false, contentHeight: CGFloat = 150) -> NSView { let cardWidth: CGFloat = 240 let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard) styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true) card.translatesAutoresizingMaskIntoConstraints = false card.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true if useFlexibleWidth { card.setContentHuggingPriority(.defaultLow, for: .horizontal) card.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } else { card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true card.setContentHuggingPriority(.required, for: .horizontal) card.setContentCompressionResistancePriority(.required, for: .horizontal) } let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge) icon.translatesAutoresizingMaskIntoConstraints = false icon.widthAnchor.constraint(equalToConstant: 28).isActive = true icon.heightAnchor.constraint(equalToConstant: 28).isActive = true let iconView = NSImageView() iconView.translatesAutoresizingMaskIntoConstraints = false let iconSymbolName: String = { switch todo.workType { case .assignment: return "doc.text.fill" case .shortAnswerQuestion, .multipleChoiceQuestion: return "checkmark.seal.fill" case .unspecified: return "book.closed.fill" } }() iconView.image = NSImage(systemSymbolName: iconSymbolName, accessibilityDescription: "Classroom to-do") iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold) iconView.contentTintColor = .white icon.addSubview(iconView) NSLayoutConstraint.activate([ iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor), iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor) ]) let title = textLabel(todo.title, font: typography.cardTitle, color: palette.textPrimary) title.lineBreakMode = .byTruncatingTail title.maximumNumberOfLines = 1 title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let subtitle = textLabel(todo.courseName, font: typography.cardSubtitle, color: palette.textPrimary) let time = textLabel(todoDueText(for: todo), font: typography.cardTime, color: palette.textSecondary) let duration = textLabel(todo.workType.displayName, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted) let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground) dayChip.translatesAutoresizingMaskIntoConstraints = false dayChip.layer?.borderWidth = 1 dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor let dayText = textLabel(todoDayText(for: todo), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary) dayText.translatesAutoresizingMaskIntoConstraints = false dayChip.addSubview(dayText) NSLayoutConstraint.activate([ dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8), dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8), dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4), dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4) ]) card.addSubview(icon) card.addSubview(dayChip) card.addSubview(title) card.addSubview(subtitle) card.addSubview(time) card.addSubview(duration) var titleConstraints: [NSLayoutConstraint] = [ icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10), icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10), dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10), dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor), title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), title.centerYAnchor.constraint(equalTo: icon.centerYAnchor), title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8) ] if !useFlexibleWidth { titleConstraints.append(title.widthAnchor.constraint(lessThanOrEqualToConstant: 130)) } NSLayoutConstraint.activate(titleConstraints + [ subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10), subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10), subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10), time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10), time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5), time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10), duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10), duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4), duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10) ]) let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:))) hit.translatesAutoresizingMaskIntoConstraints = false hit.isBordered = false hit.bezelStyle = .regularSquare if let url = todo.alternateLink { hit.identifier = NSUserInterfaceItemIdentifier(url.absoluteString) } hit.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true if useFlexibleWidth { hit.setContentHuggingPriority(.defaultLow, for: .horizontal) hit.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } else { hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true hit.setContentHuggingPriority(.required, for: .horizontal) hit.setContentCompressionResistancePriority(.required, for: .horizontal) } hit.addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: hit.leadingAnchor), card.trailingAnchor.constraint(equalTo: hit.trailingAnchor), card.topAnchor.constraint(equalTo: hit.topAnchor), card.bottomAnchor.constraint(equalTo: hit.bottomAnchor) ]) hit.onHoverChanged = { [weak self] hovering in guard let self else { return } let base = self.palette.sectionCard let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base if self.storeKitCoordinator.hasPremiumAccess { card.layer?.backgroundColor = (hovering ? hover : base).cgColor } else { card.layer?.backgroundColor = base.cgColor } } hit.onHoverChanged?(false) if !storeKitCoordinator.hasPremiumAccess { let lockOverlay = NSVisualEffectView() lockOverlay.translatesAutoresizingMaskIntoConstraints = false lockOverlay.material = darkModeEnabled ? .hudWindow : .popover lockOverlay.blendingMode = .withinWindow lockOverlay.state = .active lockOverlay.wantsLayer = true lockOverlay.layer?.cornerRadius = 12 lockOverlay.layer?.masksToBounds = true lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.28 : 0.12).cgColor let lockLabel = textLabel("Get Premium to see events", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: darkModeEnabled ? .white : .black) lockLabel.alignment = .center card.addSubview(lockOverlay) lockOverlay.addSubview(lockLabel) NSLayoutConstraint.activate([ lockOverlay.leadingAnchor.constraint(equalTo: card.leadingAnchor), lockOverlay.trailingAnchor.constraint(equalTo: card.trailingAnchor), lockOverlay.topAnchor.constraint(equalTo: card.topAnchor), lockOverlay.bottomAnchor.constraint(equalTo: card.bottomAnchor), lockLabel.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor), lockLabel.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor), lockLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 10), lockLabel.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -10) ]) hit.toolTip = "Premium required. Click to open paywall." } return hit } private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton { let button = HoverButton(title: "", target: self, action: action) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = 16 button.layer?.backgroundColor = palette.inputBackground.cgColor button.layer?.borderColor = palette.inputBorder.cgColor button.layer?.borderWidth = 1 button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll schedule") button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) button.imagePosition = .imageOnly button.imageScaling = .scaleProportionallyDown button.contentTintColor = palette.textSecondary button.focusRingType = .none button.heightAnchor.constraint(equalToConstant: 32).isActive = true button.widthAnchor.constraint(equalToConstant: 32).isActive = true let baseColor = palette.inputBackground let baseBorder = palette.inputBorder let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder button.onHoverChanged = { [weak button] hovering in button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor } button.onHoverChanged?(false) return button } } private extension PremiumPlan { var displayName: String { switch self { case .weekly: return "Weekly" case .monthly: return "Monthly" case .yearly: return "Yearly" case .lifetime: return "Lifetime" } } } extension ViewController: NSTextFieldDelegate { func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) { browseOpenAddressClicked(nil) return true } return false } } extension ViewController: NSWindowDelegate { func windowWillClose(_ notification: Notification) { guard let closingWindow = notification.object as? NSWindow else { return } if closingWindow === paywallWindow { paywallWindow = nil paywallUpgradeFlowEnabled = false } } } /// Default `NSClipView` uses a non-flipped coordinate system, so a document shorter than the visible area is anchored to the **bottom** of the clip, leaving a large gap above (e.g. Schedule empty state). Flipped coordinates match Auto Layout’s top-leading anchors and keep content top-aligned. private final class TopAlignedClipView: NSClipView { override var isFlipped: Bool { true } } /// Wraps the Google auth control and draws a circular accent ring with a light pulse while the signed-in avatar is hovered. private final class GoogleProfileAuthHostView: NSView { weak var authButton: NSButton? { didSet { needsLayout = true } } private let ringLayer = CAShapeLayer() private var avatarRingMode = false private static let ringLineWidth: CGFloat = 2.25 override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true layer?.masksToBounds = false ringLayer.fillColor = nil ringLayer.strokeColor = NSColor.clear.cgColor ringLayer.lineWidth = Self.ringLineWidth ringLayer.lineCap = .round ringLayer.opacity = 0 ringLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) layer?.insertSublayer(ringLayer, at: 0) } @available(*, unavailable) required init?(coder: NSCoder) { nil } func setAvatarRingMode(_ enabled: Bool) { avatarRingMode = enabled if enabled == false { ringLayer.removeAllAnimations() ringLayer.opacity = 0 ringLayer.lineWidth = Self.ringLineWidth } needsLayout = true } func updateRingAppearance(isDark: Bool, accent: NSColor) { let stroke = isDark ? accent.blended(withFraction: 0.22, of: NSColor.white) ?? accent : accent CATransaction.begin() CATransaction.setDisableActions(true) ringLayer.strokeColor = stroke.withAlphaComponent(0.95).cgColor CATransaction.commit() } func setProfileHoverActive(_ active: Bool) { guard avatarRingMode else { return } ringLayer.removeAnimation(forKey: "pulse") if active { layoutRingPathIfNeeded() CATransaction.begin() CATransaction.setAnimationDuration(0.22) ringLayer.opacity = 1 CATransaction.commit() let pulse = CABasicAnimation(keyPath: "lineWidth") pulse.fromValue = Self.ringLineWidth * 0.88 pulse.toValue = Self.ringLineWidth * 1.45 pulse.duration = 0.72 pulse.autoreverses = true pulse.repeatCount = .infinity pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) ringLayer.add(pulse, forKey: "pulse") } else { CATransaction.begin() CATransaction.setAnimationDuration(0.18) ringLayer.opacity = 0 CATransaction.commit() ringLayer.lineWidth = Self.ringLineWidth } } private func layoutRingPathIfNeeded() { guard avatarRingMode, let btn = authButton else { return } let f = btn.frame guard f.width > 1, f.height > 1 else { return } let center = CGPoint(x: f.midX, y: f.midY) let avatarR = min(f.width, f.height) / 2 let gap: CGFloat = 3.5 let ringRadius = avatarR + gap let d = ringRadius * 2 CATransaction.begin() CATransaction.setDisableActions(true) ringLayer.bounds = CGRect(x: 0, y: 0, width: d, height: d) ringLayer.position = center ringLayer.path = CGPath(ellipseIn: CGRect(origin: .zero, size: CGSize(width: d, height: d)), transform: nil) CATransaction.commit() } override func layout() { super.layout() layoutRingPathIfNeeded() } } /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them. private class RowHitTestView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { return bounds.contains(point) ? self : nil } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } } private final class HoverTrackingView: RowHitTestView { var onHoverChanged: ((Bool) -> Void)? var onClick: (() -> Void)? var showsHandCursor = true private var trackingAreaRef: NSTrackingArea? private var isHovering = false { didSet { guard isHovering != oldValue else { return } onHoverChanged?(isHovering) } } override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingAreaRef { removeTrackingArea(trackingAreaRef) } let options: NSTrackingArea.Options = [ .activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited ] let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) addTrackingArea(area) trackingAreaRef = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false } override func resetCursorRects() { super.resetCursorRects() guard showsHandCursor else { return } addCursorRect(bounds, cursor: .pointingHand) } override func mouseUp(with event: NSEvent) { super.mouseUp(with: event) guard event.type == .leftMouseUp else { return } onClick?() } } /// Hover tracking without overriding hit-testing; keeps controls like text fields interactive. private final class HoverSurfaceView: NSView { var onHoverChanged: ((Bool) -> Void)? private var trackingAreaRef: NSTrackingArea? private var isHovering = false { didSet { guard isHovering != oldValue else { return } onHoverChanged?(isHovering) } } override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingAreaRef { removeTrackingArea(trackingAreaRef) } let options: NSTrackingArea.Options = [ .activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited ] let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) addTrackingArea(area) trackingAreaRef = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false } } private final class HoverButton: NSButton { var onHoverChanged: ((Bool) -> Void)? var showsHandCursor = true private var trackingAreaRef: NSTrackingArea? private var isHovering = false { didSet { guard isHovering != oldValue else { return } onHoverChanged?(isHovering) } } override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingAreaRef { removeTrackingArea(trackingAreaRef) } let options: NSTrackingArea.Options = [ .activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited ] let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) addTrackingArea(tracking) trackingAreaRef = tracking } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) if showsHandCursor { NSCursor.pointingHand.set() } isHovering = true } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false } } private final class HoverPopUpButton: NSPopUpButton { var onHoverChanged: ((Bool) -> Void)? private var trackingAreaRef: NSTrackingArea? private var isHovering = false { didSet { guard isHovering != oldValue else { return } onHoverChanged?(isHovering) } } override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingAreaRef { removeTrackingArea(trackingAreaRef) } let options: NSTrackingArea.Options = [ .activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited ] let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) addTrackingArea(tracking) trackingAreaRef = tracking } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false } } private func circularNSImage(_ image: NSImage, diameter: CGFloat) -> NSImage { let size = NSSize(width: diameter, height: diameter) let result = NSImage(size: size) result.lockFocus() if let ctx = NSGraphicsContext.current { ctx.imageInterpolation = .high } let rect = NSRect(origin: .zero, size: size) let path = NSBezierPath(ovalIn: rect) path.addClip() let src = image.size.width > 0 && image.size.height > 0 ? NSRect(origin: .zero, size: image.size) : NSRect(origin: .zero, size: size) image.draw(in: rect, from: src, operation: .copy, fraction: 1.0) result.unlockFocus() result.isTemplate = false return result } private final class GoogleAccountMenuViewController: NSViewController { private let palette: Palette private let darkModeEnabled: Bool private let displayName: String private let email: String private let avatar: NSImage? private let onSignOut: () -> Void init( palette: Palette, darkModeEnabled: Bool, displayName: String, email: String, avatar: NSImage?, onSignOut: @escaping () -> Void ) { self.palette = palette self.darkModeEnabled = darkModeEnabled self.displayName = displayName self.email = email self.avatar = avatar self.onSignOut = onSignOut super.init(nibName: nil, bundle: nil) view = makeContentView() view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua) preferredContentSize = NSSize(width: 300, height: 158) } @available(*, unavailable) required init?(coder: NSCoder) { nil } private func makeContentView() -> NSView { let root = NSView() root.translatesAutoresizingMaskIntoConstraints = false let card = NSView() card.translatesAutoresizingMaskIntoConstraints = false card.wantsLayer = true card.layer?.cornerRadius = 14 card.layer?.backgroundColor = palette.sectionCard.cgColor card.layer?.borderColor = palette.inputBorder.cgColor card.layer?.borderWidth = 1 card.layer?.shadowColor = NSColor.black.cgColor card.layer?.shadowOpacity = darkModeEnabled ? 0.5 : 0.2 card.layer?.shadowOffset = CGSize(width: 0, height: 6) card.layer?.shadowRadius = 18 card.layer?.masksToBounds = false root.addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8), card.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8), card.topAnchor.constraint(equalTo: root.topAnchor, constant: 8), card.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8), root.widthAnchor.constraint(equalToConstant: 300) ]) let inner = NSStackView() inner.translatesAutoresizingMaskIntoConstraints = false inner.orientation = .vertical inner.spacing = 0 inner.alignment = .leading card.addSubview(inner) NSLayoutConstraint.activate([ inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18), inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18), inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 18), inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10) ]) let headerRow = NSView() headerRow.translatesAutoresizingMaskIntoConstraints = false let avatarBox = NSView() avatarBox.translatesAutoresizingMaskIntoConstraints = false avatarBox.wantsLayer = true avatarBox.layer?.cornerRadius = 24 avatarBox.layer?.masksToBounds = true avatarBox.layer?.borderColor = palette.inputBorder.cgColor avatarBox.layer?.borderWidth = 1 let avatarView = NSImageView() avatarView.translatesAutoresizingMaskIntoConstraints = false avatarView.imageScaling = .scaleAxesIndependently avatarView.image = resolvedAvatarImage() avatarBox.addSubview(avatarView) NSLayoutConstraint.activate([ avatarBox.widthAnchor.constraint(equalToConstant: 48), avatarBox.heightAnchor.constraint(equalToConstant: 48), avatarView.leadingAnchor.constraint(equalTo: avatarBox.leadingAnchor), avatarView.trailingAnchor.constraint(equalTo: avatarBox.trailingAnchor), avatarView.topAnchor.constraint(equalTo: avatarBox.topAnchor), avatarView.bottomAnchor.constraint(equalTo: avatarBox.bottomAnchor) ]) let textColumn = NSStackView() textColumn.translatesAutoresizingMaskIntoConstraints = false textColumn.orientation = .vertical textColumn.spacing = 3 textColumn.alignment = .leading let nameField = NSTextField(labelWithString: displayName) nameField.translatesAutoresizingMaskIntoConstraints = false nameField.font = NSFont.systemFont(ofSize: 15, weight: .semibold) nameField.textColor = palette.textPrimary nameField.lineBreakMode = .byTruncatingTail nameField.maximumNumberOfLines = 1 nameField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let emailField = NSTextField(labelWithString: email) emailField.translatesAutoresizingMaskIntoConstraints = false emailField.font = NSFont.systemFont(ofSize: 12, weight: .regular) emailField.textColor = palette.textTertiary emailField.lineBreakMode = .byTruncatingTail emailField.maximumNumberOfLines = 1 emailField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textColumn.addArrangedSubview(nameField) textColumn.addArrangedSubview(emailField) headerRow.addSubview(avatarBox) headerRow.addSubview(textColumn) NSLayoutConstraint.activate([ avatarBox.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor), avatarBox.topAnchor.constraint(equalTo: headerRow.topAnchor), avatarBox.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor), textColumn.leadingAnchor.constraint(equalTo: avatarBox.trailingAnchor, constant: 14), textColumn.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor), textColumn.centerYAnchor.constraint(equalTo: avatarBox.centerYAnchor), textColumn.topAnchor.constraint(greaterThanOrEqualTo: headerRow.topAnchor), textColumn.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor) ]) inner.addArrangedSubview(headerRow) headerRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true inner.setCustomSpacing(14, after: headerRow) let separator = NSView() separator.translatesAutoresizingMaskIntoConstraints = false separator.wantsLayer = true separator.layer?.backgroundColor = palette.separator.cgColor separator.heightAnchor.constraint(equalToConstant: 1).isActive = true inner.addArrangedSubview(separator) separator.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true inner.setCustomSpacing(6, after: separator) let signOutRow = HoverTrackingView() signOutRow.translatesAutoresizingMaskIntoConstraints = false signOutRow.heightAnchor.constraint(equalToConstant: 44).isActive = true signOutRow.wantsLayer = true signOutRow.layer?.cornerRadius = 10 let signOutIcon = NSImageView() signOutIcon.translatesAutoresizingMaskIntoConstraints = false signOutIcon.imageScaling = .scaleProportionallyDown if let sym = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: nil) { signOutIcon.image = sym signOutIcon.contentTintColor = palette.textSecondary signOutIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium) } let signOutLabel = NSTextField(labelWithString: "Log out") signOutLabel.translatesAutoresizingMaskIntoConstraints = false signOutLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium) signOutLabel.textColor = palette.textPrimary signOutRow.addSubview(signOutIcon) signOutRow.addSubview(signOutLabel) NSLayoutConstraint.activate([ signOutIcon.leadingAnchor.constraint(equalTo: signOutRow.leadingAnchor, constant: 10), signOutIcon.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor), signOutIcon.widthAnchor.constraint(equalToConstant: 20), signOutIcon.heightAnchor.constraint(equalToConstant: 20), signOutLabel.leadingAnchor.constraint(equalTo: signOutIcon.trailingAnchor, constant: 10), signOutLabel.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor), signOutLabel.trailingAnchor.constraint(lessThanOrEqualTo: signOutRow.trailingAnchor, constant: -10) ]) let signOutClick = NSClickGestureRecognizer(target: self, action: #selector(signOutClicked)) signOutRow.addGestureRecognizer(signOutClick) signOutRow.onHoverChanged = { [weak self] hovering in guard let self else { return } signOutRow.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor } signOutRow.onHoverChanged?(false) inner.addArrangedSubview(signOutRow) signOutRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true return root } private func resolvedAvatarImage() -> NSImage { if let a = avatar { return circularNSImage(a, diameter: 48) } return initialLetterAvatar() } private func initialLetterAvatar() -> NSImage { let d: CGFloat = 48 let letter = displayName.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?" let img = NSImage(size: NSSize(width: d, height: d)) img.lockFocus() palette.primaryBlue.setFill() NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill() let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 20, weight: .semibold), .foregroundColor: NSColor.white ] let sz = (letter as NSString).size(withAttributes: attrs) let origin = NSPoint(x: (d - sz.width) / 2, y: (d - sz.height) / 2) (letter as NSString).draw(at: origin, withAttributes: attrs) img.unlockFocus() img.isTemplate = false return img } @objc private func signOutClicked() { onSignOut() } } private final class SettingsMenuViewController: NSViewController { private let palette: Palette private let typography: Typography private let onToggleDarkMode: (Bool) -> Void private let onAction: (SettingsAction, NSView?, NSPoint?) -> Void private var darkToggle: NSSwitch? init( palette: Palette, typography: Typography, darkModeEnabled: Bool, showRateUsInSettings: Bool, showUpgradeInSettings: Bool, onToggleDarkMode: @escaping (Bool) -> Void, onAction: @escaping (SettingsAction, NSView?, NSPoint?) -> Void ) { self.palette = palette self.typography = typography self.onToggleDarkMode = onToggleDarkMode self.onAction = onAction super.init(nibName: nil, bundle: nil) self.view = makeView( darkModeEnabled: darkModeEnabled, showRateUsInSettings: showRateUsInSettings, showUpgradeInSettings: showUpgradeInSettings ) } @available(*, unavailable) required init?(coder: NSCoder) { nil } func setDarkModeEnabled(_ enabled: Bool) { darkToggle?.state = enabled ? .on : .off } private func makeView( darkModeEnabled: Bool, showRateUsInSettings: Bool, showUpgradeInSettings: Bool ) -> NSView { let root = NSView() root.translatesAutoresizingMaskIntoConstraints = false let card = roundedCard() root.addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: root.leadingAnchor), card.trailingAnchor.constraint(equalTo: root.trailingAnchor), card.topAnchor.constraint(equalTo: root.topAnchor), card.bottomAnchor.constraint(equalTo: root.bottomAnchor), root.widthAnchor.constraint(equalToConstant: 260) ]) let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .vertical stack.spacing = 6 stack.alignment = .leading card.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14), stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14), stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14), stack.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -14) ]) stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled)) if showRateUsInSettings { stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs)) } stack.addArrangedSubview(settingsActionRow(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)) stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support)) stack.addArrangedSubview(settingsActionRow(icon: "📄", title: "Terms of Services", action: .termsOfServices)) stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp)) if showUpgradeInSettings { stack.addArrangedSubview(settingsActionRow(icon: "⬆︎", title: "Upgrade", action: .upgrade)) } for v in stack.arrangedSubviews { v.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } return root } private func roundedCard() -> NSView { let view = NSView() view.translatesAutoresizingMaskIntoConstraints = false view.wantsLayer = true view.layer?.cornerRadius = 12 view.layer?.backgroundColor = palette.sectionCard.cgColor view.layer?.borderColor = palette.inputBorder.cgColor view.layer?.borderWidth = 1 view.layer?.shadowColor = NSColor.black.cgColor view.layer?.shadowOpacity = 0.28 view.layer?.shadowOffset = CGSize(width: 0, height: -1) view.layer?.shadowRadius = 10 return view } private func settingsDarkModeRow(enabled: Bool) -> NSView { let row = NSView() row.translatesAutoresizingMaskIntoConstraints = false row.heightAnchor.constraint(equalToConstant: 44).isActive = true row.wantsLayer = true row.layer?.cornerRadius = 10 let icon = NSTextField(labelWithString: "◐") icon.translatesAutoresizingMaskIntoConstraints = false icon.font = NSFont.systemFont(ofSize: 18, weight: .medium) icon.textColor = palette.textPrimary let title = NSTextField(labelWithString: "Dark Mode") title.translatesAutoresizingMaskIntoConstraints = false title.font = NSFont.systemFont(ofSize: 16, weight: .semibold) title.textColor = palette.textPrimary let toggle = NSSwitch() toggle.translatesAutoresizingMaskIntoConstraints = false toggle.state = enabled ? .on : .off toggle.target = self toggle.action = #selector(darkModeToggled(_:)) darkToggle = toggle row.addSubview(icon) row.addSubview(title) row.addSubview(toggle) row.layer?.backgroundColor = NSColor.clear.cgColor NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4), icon.centerYAnchor.constraint(equalTo: row.centerYAnchor), title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10), title.centerYAnchor.constraint(equalTo: row.centerYAnchor), toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2), toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor) ]) return row } private func settingsActionRow(icon: String, title: String, action: SettingsAction) -> NSView { let row = HoverButton(title: "", target: self, action: #selector(settingsActionButtonPressed(_:))) row.tag = action.rawValue row.isBordered = false row.translatesAutoresizingMaskIntoConstraints = false row.heightAnchor.constraint(equalToConstant: 42).isActive = true row.wantsLayer = true row.layer?.cornerRadius = 10 row.layer?.backgroundColor = NSColor.clear.cgColor let iconLabel = NSTextField(labelWithString: icon) iconLabel.translatesAutoresizingMaskIntoConstraints = false iconLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium) iconLabel.textColor = palette.textPrimary let titleLabel = NSTextField(labelWithString: title) titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold) titleLabel.textColor = palette.textPrimary row.addSubview(iconLabel) row.addSubview(titleLabel) NSLayoutConstraint.activate([ iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4), iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor), titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10), titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor) ]) row.onHoverChanged = { hovering in row.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor } row.onHoverChanged?(false) return row } @objc private func darkModeToggled(_ sender: NSSwitch) { onToggleDarkMode(sender.state == .on) } @objc private func settingsActionButtonPressed(_ sender: NSButton) { guard let action = SettingsAction(rawValue: sender.tag) else { return } let clickPoint: NSPoint? if let event = NSApp.currentEvent { let pointInWindow = event.locationInWindow clickPoint = sender.convert(pointInWindow, from: nil) } else { clickPoint = nil } onAction(action, sender, clickPoint) } } private extension ViewController { private func requireGoogleLoginForCalendarScheduling() -> Bool { guard googleOAuth.loadTokens() != nil else { showSimpleAlert( title: "Connect Google", message: "Sign in with Google first to schedule a meeting from Calendar." ) return false } return true } func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView { let view = NSView() view.wantsLayer = true view.layer?.backgroundColor = color.cgColor view.layer?.cornerRadius = cornerRadius return view } func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) { view.layer?.borderColor = borderColor.cgColor view.layer?.borderWidth = borderWidth if shadow { view.layer?.shadowColor = NSColor.black.cgColor view.layer?.shadowOpacity = 0.18 view.layer?.shadowOffset = CGSize(width: 0, height: -1) view.layer?.shadowRadius = 5 } } func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { let label = NSTextField(labelWithString: text) label.translatesAutoresizingMaskIntoConstraints = false label.textColor = color label.font = font return label } func iconLabel(_ text: String, size: CGFloat) -> NSTextField { let label = NSTextField(labelWithString: text) label.translatesAutoresizingMaskIntoConstraints = false label.font = NSFont.systemFont(ofSize: size) return label } func sidebarSectionTitle(_ text: String) -> NSTextField { let field = textLabel(text, font: typography.sidebarSection, color: palette.textMuted) field.alignment = .left return field } func sidebarItem(_ text: String, icon: String, page: SidebarPage, logoImageName: String? = nil, systemSymbolName: String? = nil, logoIconWidth: CGFloat = 18, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true, showsDisclosure: Bool = false) -> NSView { let item = HoverButton(title: "", target: self, action: #selector(sidebarButtonClicked(_:))) item.tag = page.rawValue item.isBordered = false item.wantsLayer = true item.layer?.cornerRadius = 10 item.layer?.backgroundColor = NSColor.clear.cgColor item.translatesAutoresizingMaskIntoConstraints = false item.heightAnchor.constraint(equalToConstant: 36).isActive = true item.layer?.borderWidth = 0 sidebarPageByView[ObjectIdentifier(item)] = page let leadingView: NSView if let name = logoImageName, let logo = NSImage(named: name) { logo.isTemplate = true let imageView = NSImageView(image: logo) imageView.translatesAutoresizingMaskIntoConstraints = false imageView.imageScaling = .scaleProportionallyDown imageView.imageAlignment = .alignCenter imageView.isEditable = false leadingView = imageView } else if let symbolName = systemSymbolName, let symbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: text) { symbol.isTemplate = true let imageView = NSImageView(image: symbol) imageView.translatesAutoresizingMaskIntoConstraints = false imageView.imageScaling = .scaleProportionallyDown imageView.imageAlignment = .alignCenter imageView.isEditable = false leadingView = imageView } else { leadingView = textLabel(icon, font: typography.sidebarIcon, color: palette.textSecondary) } let titleLabel = textLabel(text, font: typography.sidebarItem, color: palette.textSecondary) titleLabel.alignment = .left item.addSubview(leadingView) item.addSubview(titleLabel) var constraints: [NSLayoutConstraint] = [ leadingView.leadingAnchor.constraint(equalTo: item.leadingAnchor, constant: 12), leadingView.centerYAnchor.constraint(equalTo: item.centerYAnchor), titleLabel.leadingAnchor.constraint(equalTo: leadingView.trailingAnchor, constant: 8), titleLabel.centerYAnchor.constraint(equalTo: item.centerYAnchor) ] if showsDisclosure { let chevron = textLabel("›", font: NSFont.systemFont(ofSize: 22, weight: .semibold), color: palette.textSecondary) chevron.translatesAutoresizingMaskIntoConstraints = false chevron.alignment = .right item.addSubview(chevron) constraints.append(contentsOf: [ chevron.trailingAnchor.constraint(equalTo: item.trailingAnchor, constant: -10), chevron.centerYAnchor.constraint(equalTo: item.centerYAnchor), titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8) ]) } else { constraints.append(titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: item.trailingAnchor, constant: -10)) } if logoImageName != nil || systemSymbolName != nil { let h = logoIconWidth * logoHeightMultiplier constraints.append(contentsOf: [ leadingView.widthAnchor.constraint(equalToConstant: logoIconWidth), leadingView.heightAnchor.constraint(equalToConstant: h) ]) } NSLayoutConstraint.activate(constraints) applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate) item.onHoverChanged = { [weak self, weak item] hovering in guard let self, let item else { return } self.applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate, hovering: hovering) } return item } func applySidebarRowStyle(_ item: NSView, page: SidebarPage, logoTemplate: Bool, hovering: Bool = false) { let selected = (page == selectedSidebarPage) let neutralHover = darkModeEnabled ? NSColor(calibratedWhite: 1, alpha: 0.07) : NSColor(calibratedWhite: 0, alpha: 0.08) let greenHover = palette.primaryBlue.withAlphaComponent(darkModeEnabled ? 0.22 : 0.14) let hoverColor = greenHover.blended(withFraction: 0.45, of: neutralHover) ?? greenHover item.layer?.backgroundColor = (selected ? palette.primaryBlue : (hovering ? hoverColor : NSColor.clear)).cgColor let tint = selected ? NSColor.white : palette.textSecondary let sidebarIconTint = darkModeEnabled ? tint : NSColor.black guard item.subviews.count >= 2 else { return } let leading = item.subviews[0] let title = item.subviews.first { $0 is NSTextField } as? NSTextField title?.textColor = tint // Optional disclosure chevron (if present) is the last text field. if let chevron = item.subviews.last as? NSTextField, chevron !== title { chevron.textColor = sidebarIconTint } if let imageView = leading as? NSImageView { if logoTemplate { imageView.contentTintColor = sidebarIconTint } } else if let iconField = leading as? NSTextField { iconField.textColor = sidebarIconTint } } func actionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat) -> NSView { let button = HoverTrackingView() button.wantsLayer = true button.layer?.cornerRadius = 9 button.layer?.backgroundColor = color.cgColor button.translatesAutoresizingMaskIntoConstraints = false button.widthAnchor.constraint(equalToConstant: width).isActive = true button.heightAnchor.constraint(equalToConstant: 36).isActive = true styleSurface(button, borderColor: title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder, borderWidth: 1, shadow: false) if title == "Cancel" { button.layer?.backgroundColor = palette.cancelButton.cgColor } let label = textLabel(title, font: typography.buttonText, color: textColor) button.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: button.centerXAnchor), label.centerYAnchor.constraint(equalTo: button.centerYAnchor) ]) let baseColor = (title == "Cancel") ? palette.cancelButton : color let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverColor = baseColor.blended(withFraction: 0.12, of: hoverBlend) ?? baseColor button.onHoverChanged = { hovering in button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor } button.onHoverChanged?(false) return button } func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView { let button = HoverTrackingView() button.wantsLayer = true button.layer?.cornerRadius = size / 2 button.layer?.backgroundColor = palette.inputBackground.cgColor button.translatesAutoresizingMaskIntoConstraints = false button.widthAnchor.constraint(equalToConstant: size).isActive = true button.heightAnchor.constraint(equalToConstant: size).isActive = true styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold) let iconView = NSImageView() iconView.translatesAutoresizingMaskIntoConstraints = false iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh") iconView.symbolConfiguration = symbolConfig iconView.contentTintColor = palette.textSecondary button.addSubview(iconView) NSLayoutConstraint.activate([ iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor), iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor) ]) let baseColor = palette.inputBackground let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor button.onHoverChanged = { hovering in button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor } button.onHoverChanged?(false) button.onClick = onClick return button } } private let calendarDayKeyFormatter: DateFormatter = { let f = DateFormatter() f.calendar = Calendar(identifier: .gregorian) f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone.current f.dateFormat = "yyyy-MM-dd" return f }() // MARK: - Calendar page actions + rendering private extension ViewController { private func makeCalendarHeaderPillButton(title: String, action: Selector) -> NSButton { let button = makeSchedulePillButton(title: title) button.target = self button.action = action button.heightAnchor.constraint(equalToConstant: 30).isActive = true return button } private func calendarStartOfMonth(for date: Date) -> Date { let calendar = Calendar.current let comps = calendar.dateComponents([.year, .month], from: date) return calendar.date(from: comps) ?? calendar.startOfDay(for: date) } private func calendarMonthTitleText(for monthAnchor: Date) -> String { let f = DateFormatter() f.locale = Locale.current f.timeZone = TimeZone.current f.dateFormat = "MMMM yyyy" return f.string(from: monthAnchor) } private func calendarWeekdaySymbolsStartingAtFirstWeekday() -> [String] { // Align weekday header to Calendar.current.firstWeekday let calendar = Calendar.current var symbols = DateFormatter().veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"] // veryShortWeekdaySymbols starts with Sunday in most locales; rotate to firstWeekday. let first = max(1, min(7, calendar.firstWeekday)) // 1..7 let shift = (first - 1) % 7 if shift == 0 { return symbols } let head = Array(symbols[shift...]) let tail = Array(symbols[.. totalDays { row.addArrangedSubview(calendarEmptyDayCell()) continue } guard let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) else { row.addArrangedSubview(calendarEmptyDayCell()) continue } let isSelected = calendar.isDate(date, inSameDayAs: calendarPageSelectedDate) let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: date)) let count = meetingCounts[key] ?? 0 row.addArrangedSubview(calendarDayCell(dayNumber: day, dateKey: key, meetingCount: count, isSelected: isSelected)) day += 1 } gridStack.addArrangedSubview(row) row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true } } private func calendarButton(forDateKey key: String) -> NSButton? { guard let gridStack = calendarPageGridStack else { return nil } for rowView in gridStack.arrangedSubviews { guard let row = rowView as? NSStackView else { continue } for cell in row.arrangedSubviews { if let button = cell as? NSButton, button.identifier?.rawValue == key { return button } } } return nil } private func renderCalendarSelectedDay() { let calendar = Calendar.current let selectedDay = calendar.startOfDay(for: calendarPageSelectedDate) let nextDay = calendar.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay.addingTimeInterval(86400) let meetings = scheduleCachedMeetings .filter { $0.startDate >= selectedDay && $0.startDate < nextDay } .sorted(by: { $0.startDate < $1.startDate }) let f = DateFormatter() f.locale = Locale.current f.timeZone = TimeZone.current f.dateFormat = "EEE, d MMM" if meetings.isEmpty { calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No meetings on \(f.string(from: selectedDay))" } else if meetings.count == 1 { calendarPageDaySummaryLabel?.stringValue = "1 meeting on \(f.string(from: selectedDay))" } else { calendarPageDaySummaryLabel?.stringValue = "\(meetings.count) meetings on \(f.string(from: selectedDay))" } } private func calendarMeetingCountsByDay(from meetings: [ScheduledMeeting], monthStart: Date, monthEnd: Date) -> [String: Int] { let calendar = Calendar.current var counts: [String: Int] = [:] for meeting in meetings { guard meeting.startDate >= monthStart && meeting.startDate < monthEnd else { continue } let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: meeting.startDate)) counts[key, default: 0] += 1 } return counts } private func calendarEmptyDayCell() -> NSView { let v = NSView() v.translatesAutoresizingMaskIntoConstraints = false v.heightAnchor.constraint(equalToConstant: 56).isActive = true return v } private func calendarDayCell(dayNumber: Int, dateKey: String, meetingCount: Int, isSelected: Bool) -> NSButton { let button = HoverButton(title: "", target: self, action: #selector(calendarDayCellPressed(_:))) button.translatesAutoresizingMaskIntoConstraints = false button.isBordered = false button.bezelStyle = .regularSquare button.wantsLayer = true button.layer?.cornerRadius = 12 button.layer?.masksToBounds = true button.identifier = NSUserInterfaceItemIdentifier(dateKey) button.heightAnchor.constraint(equalToConstant: 56).isActive = true button.setContentHuggingPriority(.required, for: .vertical) button.setContentCompressionResistancePriority(.required, for: .vertical) button.alignment = .left button.imagePosition = .noImage let base = palette.inputBackground let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base let selectedBackground = darkModeEnabled ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1) : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1) let borderIdle = palette.inputBorder let borderSelected = palette.primaryBlueBorder func applyAppearance(hovering: Bool) { button.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hover : base)).cgColor button.layer?.borderWidth = isSelected ? 1.5 : 1 button.layer?.borderColor = (isSelected ? borderSelected : borderIdle).cgColor } applyAppearance(hovering: false) button.onHoverChanged = { hovering in applyAppearance(hovering: hovering) } button.attributedTitle = NSAttributedString( string: " \(dayNumber)", attributes: [ .font: NSFont.systemFont(ofSize: 14, weight: .bold), .foregroundColor: palette.textPrimary ] ) if meetingCount > 0 { let dot = roundedContainer(cornerRadius: 4, color: palette.meetingBadge) dot.translatesAutoresizingMaskIntoConstraints = false dot.layer?.borderWidth = 0 button.addSubview(dot) NSLayoutConstraint.activate([ dot.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -10), dot.centerYAnchor.constraint(equalTo: button.centerYAnchor), dot.widthAnchor.constraint(equalToConstant: 8), dot.heightAnchor.constraint(equalToConstant: 8) ]) } return button } } private final class EnrolledClassDetailsViewController: NSViewController { private let details: ClassroomClassDetails private let palette: Palette private let typography: Typography private let defaultAuthorName: String private let defaultAuthorAvatar: NSImage? private let onOpenClass: () -> Void private let onOpenURL: (URL) -> Void private let onDownloadAttachment: (ClassroomAttachment) -> Void private var attachmentByButton = [ObjectIdentifier: ClassroomAttachment]() init(details: ClassroomClassDetails, palette: Palette, typography: Typography, defaultAuthorName: String, defaultAuthorAvatar: NSImage?, onOpenClass: @escaping () -> Void, onOpenURL: @escaping (URL) -> Void, onDownloadAttachment: @escaping (ClassroomAttachment) -> Void) { self.details = details self.palette = palette self.typography = typography self.defaultAuthorName = defaultAuthorName self.defaultAuthorAvatar = defaultAuthorAvatar self.onOpenClass = onOpenClass self.onOpenURL = onOpenURL self.onDownloadAttachment = onDownloadAttachment super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func loadView() { let root = NSView() root.translatesAutoresizingMaskIntoConstraints = false root.wantsLayer = true root.layer?.backgroundColor = palette.sectionCard.cgColor root.layer?.cornerRadius = 12 root.layer?.borderWidth = 1 root.layer?.borderColor = palette.inputBorder.cgColor let contentScroll = NSScrollView() contentScroll.translatesAutoresizingMaskIntoConstraints = false contentScroll.drawsBackground = false contentScroll.hasVerticalScroller = true contentScroll.hasHorizontalScroller = false contentScroll.autohidesScrollers = true contentScroll.borderType = .noBorder contentScroll.scrollerStyle = .overlay let clip = TopAlignedClipView() clip.drawsBackground = false contentScroll.contentView = clip root.addSubview(contentScroll) let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .vertical stack.alignment = .width stack.spacing = 16 contentScroll.documentView = stack let pageHeader = makePageHeader() stack.addArrangedSubview(pageHeader) if details.announcements.isEmpty, details.coursework.isEmpty { let empty = makeEmptyState() stack.addArrangedSubview(empty) empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } else { for item in details.announcements { let card = makeAnnouncementCard(item) stack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } for item in details.coursework { let card = makeCourseworkCard(item) stack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } } NSLayoutConstraint.activate([ root.widthAnchor.constraint(equalToConstant: 520), root.heightAnchor.constraint(equalToConstant: 560), contentScroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12), contentScroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12), contentScroll.topAnchor.constraint(equalTo: root.topAnchor, constant: 12), contentScroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12), stack.leadingAnchor.constraint(equalTo: contentScroll.contentView.leadingAnchor), stack.trailingAnchor.constraint(equalTo: contentScroll.contentView.trailingAnchor), stack.topAnchor.constraint(equalTo: contentScroll.contentView.topAnchor), stack.widthAnchor.constraint(equalTo: contentScroll.contentView.widthAnchor), pageHeader.widthAnchor.constraint(equalTo: stack.widthAnchor) ]) view = root } @objc private func openClassPressed(_ sender: NSButton) { onOpenClass() } @objc private func openLinkPressed(_ sender: NSButton) { guard let raw = sender.identifier?.rawValue, let url = URL(string: raw) else { return } onOpenURL(url) } @objc private func downloadPressed(_ sender: NSButton) { guard let attachment = attachmentByButton[ObjectIdentifier(sender)] else { return } onDownloadAttachment(attachment) } private func makePageHeader() -> NSView { let title = NSTextField(labelWithString: details.courseName) title.font = NSFont.systemFont(ofSize: 18, weight: .bold) title.textColor = palette.textPrimary title.maximumNumberOfLines = 2 title.lineBreakMode = .byWordWrapping let subtitle = NSTextField(labelWithString: "Class stream") subtitle.font = NSFont.systemFont(ofSize: 12, weight: .medium) subtitle.textColor = palette.textMuted let openClassButton = HoverButton(title: "", target: self, action: #selector(openClassPressed(_:))) openClassButton.translatesAutoresizingMaskIntoConstraints = false openClassButton.bezelStyle = .regularSquare openClassButton.image = NSImage(systemSymbolName: "arrow.up.right.square", accessibilityDescription: "Open in Classroom") openClassButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold) openClassButton.imagePosition = .imageOnly openClassButton.contentTintColor = palette.textPrimary openClassButton.setAccessibilityLabel("Open in Classroom") openClassButton.toolTip = "Open in Classroom" openClassButton.wantsLayer = true openClassButton.layer?.cornerRadius = 8 openClassButton.layer?.borderWidth = 1 openClassButton.widthAnchor.constraint(equalToConstant: 34).isActive = true openClassButton.heightAnchor.constraint(equalToConstant: 34).isActive = true let buttonBase = palette.inputBackground let buttonHover = buttonBase.blended(withFraction: 0.14, of: palette.primaryBlue) ?? buttonBase openClassButton.layer?.backgroundColor = buttonBase.cgColor openClassButton.layer?.borderColor = palette.inputBorder.cgColor openClassButton.onHoverChanged = { [weak openClassButton] hovering in guard let openClassButton else { return } openClassButton.layer?.backgroundColor = (hovering ? buttonHover : buttonBase).cgColor openClassButton.layer?.borderColor = (hovering ? self.palette.primaryBlueBorder : self.palette.inputBorder).cgColor } openClassButton.onHoverChanged?(false) let left = NSStackView(views: [title, subtitle]) left.orientation = .vertical left.alignment = .leading left.spacing = 4 let row = NSStackView(views: [left, NSView(), openClassButton]) row.translatesAutoresizingMaskIntoConstraints = false row.orientation = .horizontal row.alignment = .centerY let card = makeStreamCardShell() card.addSubview(row) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14), row.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14), row.topAnchor.constraint(equalTo: card.topAnchor, constant: 12), row.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12) ]) return card } private func makeEmptyState() -> NSView { let card = makeStreamCardShell() let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .vertical stack.alignment = .centerX stack.spacing = 8 let title = NSTextField(labelWithString: "No stream items yet") title.font = NSFont.systemFont(ofSize: 14, weight: .semibold) title.textColor = palette.textPrimary let subtitle = NSTextField(labelWithString: "Announcements and coursework will appear here.") subtitle.font = typography.cardSubtitle subtitle.textColor = palette.textSecondary stack.addArrangedSubview(title) stack.addArrangedSubview(subtitle) card.addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: card.centerXAnchor), stack.centerYAnchor.constraint(equalTo: card.centerYAnchor), stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16), card.heightAnchor.constraint(equalToConstant: 120) ]) return card } private func makeMutedLabel(_ text: String) -> NSTextField { let label = NSTextField(labelWithString: text) label.font = typography.cardSubtitle label.textColor = palette.textSecondary return label } private func makeAnnouncementCard(_ item: ClassroomAnnouncement) -> NSView { let container = NSStackView() container.translatesAutoresizingMaskIntoConstraints = false container.orientation = .vertical container.alignment = .leading container.spacing = 10 container.addArrangedSubview(makeStreamMetaRow(authorName: defaultAuthorName, date: item.postedAt)) container.addArrangedSubview(makeBodyLabel(item.text)) if !item.attachments.isEmpty { container.addArrangedSubview(makeAttachmentPreview(item.attachments[0])) } if let link = item.alternateLink { let open = makeStreamFooterActionButton(title: "Open announcement", systemSymbol: "arrow.up.right.square", url: link) container.addArrangedSubview(open) } return wrapInStreamCard(container, url: item.alternateLink) } private func makeCourseworkCard(_ item: ClassroomCourseWork) -> NSView { let container = NSStackView() container.translatesAutoresizingMaskIntoConstraints = false container.orientation = .vertical container.alignment = .leading container.spacing = 10 container.addArrangedSubview(makeStreamMetaRow(authorName: defaultAuthorName, date: item.dueDate)) let headline = "\(defaultAuthorName) posted a new \(item.subtitle.lowercased()): \(item.title)" container.addArrangedSubview(makeBodyLabel(headline)) if !item.attachments.isEmpty { container.addArrangedSubview(makeAttachmentPreview(item.attachments[0])) } if let link = item.alternateLink { let open = makeStreamFooterActionButton(title: "Open item", systemSymbol: "arrow.up.right.square", url: link) container.addArrangedSubview(open) } return wrapInStreamCard(container, url: item.alternateLink) } private func makeAttachmentsList(_ attachments: [ClassroomAttachment]) -> NSView { let stack = NSStackView() stack.orientation = .vertical stack.alignment = .leading stack.spacing = 2 let header = NSTextField(labelWithString: "Attachments") header.font = NSFont.systemFont(ofSize: 12, weight: .semibold) header.textColor = palette.textMuted stack.addArrangedSubview(header) for attachment in attachments { let button = NSButton(title: "Open file: \(attachment.title)", target: self, action: #selector(downloadPressed(_:))) button.bezelStyle = .inline button.font = NSFont.systemFont(ofSize: 12, weight: .medium) attachmentByButton[ObjectIdentifier(button)] = attachment stack.addArrangedSubview(button) } return stack } private func makeStreamMetaRow(authorName: String, date: Date?) -> NSView { let avatar = NSImageView() avatar.translatesAutoresizingMaskIntoConstraints = false if let defaultAuthorAvatar { avatar.image = circularNSImage(defaultAuthorAvatar, diameter: 34) avatar.contentTintColor = nil } else { avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Author") avatar.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 28, weight: .regular) avatar.contentTintColor = palette.textSecondary } avatar.widthAnchor.constraint(equalToConstant: 34).isActive = true avatar.heightAnchor.constraint(equalToConstant: 34).isActive = true let name = NSTextField(labelWithString: authorName) name.font = NSFont.systemFont(ofSize: 14, weight: .bold) name.textColor = palette.textPrimary let time = makeMutedLabel(timeText(date)) time.font = NSFont.systemFont(ofSize: 12, weight: .medium) let nameTime = NSStackView(views: [name, time]) nameTime.orientation = .vertical nameTime.alignment = .leading nameTime.spacing = 2 let menu = NSTextField(labelWithString: "⋮") menu.font = NSFont.systemFont(ofSize: 18, weight: .semibold) menu.textColor = palette.textMuted let row = NSStackView(views: [avatar, nameTime, NSView(), menu]) row.orientation = .horizontal row.alignment = .top row.distribution = .fill return row } private func makeBodyLabel(_ text: String) -> NSView { let body = NSTextField(wrappingLabelWithString: text) body.maximumNumberOfLines = 0 body.font = NSFont.systemFont(ofSize: 15, weight: .regular) body.textColor = palette.textPrimary return body } private func makeStreamFooterActionButton(title: String, systemSymbol: String, url: URL) -> NSView { let divider = NSBox() divider.boxType = .separator divider.translatesAutoresizingMaskIntoConstraints = false let actionButton = HoverButton(title: title, target: self, action: #selector(openLinkPressed(_:))) actionButton.translatesAutoresizingMaskIntoConstraints = false actionButton.isBordered = false actionButton.bezelStyle = .regularSquare actionButton.identifier = NSUserInterfaceItemIdentifier(url.absoluteString) actionButton.wantsLayer = true actionButton.layer?.cornerRadius = 8 actionButton.layer?.backgroundColor = NSColor.clear.cgColor actionButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold) actionButton.contentTintColor = palette.primaryBlue actionButton.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: title) actionButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) actionButton.imagePosition = .imageLeading actionButton.imageHugsTitle = true actionButton.alignment = .left actionButton.lineBreakMode = .byTruncatingTail actionButton.heightAnchor.constraint(equalToConstant: 28).isActive = true let baseHover = NSColor.clear let hoverBg = palette.inputBackground.blended(withFraction: 0.20, of: palette.primaryBlue) ?? palette.inputBackground actionButton.onHoverChanged = { [weak actionButton] hovering in actionButton?.layer?.backgroundColor = (hovering ? hoverBg : baseHover).cgColor } actionButton.onHoverChanged?(false) let container = NSStackView(views: [divider, actionButton]) container.translatesAutoresizingMaskIntoConstraints = false container.orientation = .vertical container.alignment = .width container.spacing = 8 return container } private func makeAttachmentPreview(_ attachment: ClassroomAttachment) -> NSView { let preview = HoverTrackingView() preview.translatesAutoresizingMaskIntoConstraints = false preview.wantsLayer = true preview.showsHandCursor = false preview.layer?.cornerRadius = 12 let previewBase = palette.sectionCard let previewHover = previewBase.blended(withFraction: 0.14, of: palette.primaryBlue) ?? previewBase preview.layer?.backgroundColor = previewBase.cgColor preview.layer?.borderWidth = 1 preview.layer?.borderColor = palette.inputBorder.cgColor preview.onHoverChanged = { [weak preview] hovering in guard let preview else { return } preview.layer?.backgroundColor = (hovering ? previewHover : previewBase).cgColor preview.layer?.borderColor = (hovering ? self.palette.primaryBlueBorder : self.palette.inputBorder).cgColor } preview.onHoverChanged?(false) let titleButton = NSButton(title: attachment.title, target: self, action: #selector(downloadPressed(_:))) titleButton.translatesAutoresizingMaskIntoConstraints = false titleButton.bezelStyle = .inline titleButton.font = NSFont.systemFont(ofSize: 16, weight: .semibold) titleButton.contentTintColor = palette.primaryBlue attachmentByButton[ObjectIdentifier(titleButton)] = attachment let type = makeMutedLabel(attachment.mimeType ?? attachment.sourceType.capitalized) type.translatesAutoresizingMaskIntoConstraints = false let textCol = NSStackView(views: [titleButton, type]) textCol.translatesAutoresizingMaskIntoConstraints = false textCol.orientation = .vertical textCol.alignment = .leading textCol.spacing = 2 let thumb = NSImageView() thumb.translatesAutoresizingMaskIntoConstraints = false thumb.image = NSImage(systemSymbolName: "doc.richtext", accessibilityDescription: nil) thumb.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .medium) thumb.contentTintColor = palette.textMuted thumb.wantsLayer = true thumb.layer?.backgroundColor = palette.inputBackground.cgColor thumb.layer?.cornerRadius = 8 thumb.widthAnchor.constraint(equalToConstant: 88).isActive = true preview.addSubview(textCol) preview.addSubview(thumb) NSLayoutConstraint.activate([ preview.heightAnchor.constraint(equalToConstant: 90), textCol.leadingAnchor.constraint(equalTo: preview.leadingAnchor, constant: 12), textCol.trailingAnchor.constraint(equalTo: thumb.leadingAnchor, constant: -10), textCol.centerYAnchor.constraint(equalTo: preview.centerYAnchor), thumb.trailingAnchor.constraint(equalTo: preview.trailingAnchor, constant: -10), thumb.topAnchor.constraint(equalTo: preview.topAnchor, constant: 10), thumb.bottomAnchor.constraint(equalTo: preview.bottomAnchor, constant: -10) ]) return preview } private func makeStreamCardShell() -> NSView { let card = HoverTrackingView() card.translatesAutoresizingMaskIntoConstraints = false card.wantsLayer = true card.showsHandCursor = false card.layer?.cornerRadius = 10 let baseColor = palette.inputBackground .blended(withFraction: 0.32, of: palette.sectionCard) ?? palette.inputBackground let hoverColor = baseColor.blended(withFraction: 0.12, of: palette.primaryBlue) ?? baseColor card.layer?.backgroundColor = baseColor.cgColor card.layer?.borderWidth = 1 card.layer?.borderColor = palette.inputBorder.cgColor card.onHoverChanged = { [weak card] hovering in guard let card else { return } card.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor card.layer?.borderColor = (hovering ? self.palette.primaryBlueBorder : self.palette.inputBorder).cgColor } card.onHoverChanged?(false) return card } private func wrapInStreamCard(_ content: NSView, url: URL? = nil) -> NSView { let card = makeStreamCardShell() card.addSubview(content) NSLayoutConstraint.activate([ content.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12), content.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12), content.topAnchor.constraint(equalTo: card.topAnchor, constant: 10), content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10), card.widthAnchor.constraint(greaterThanOrEqualToConstant: 220) ]) guard let url else { return card } let hit = HoverButton(title: "", target: self, action: #selector(openLinkPressed(_:))) hit.translatesAutoresizingMaskIntoConstraints = false hit.isBordered = false hit.bezelStyle = .regularSquare hit.identifier = NSUserInterfaceItemIdentifier(url.absoluteString) hit.toolTip = url.absoluteString hit.addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: hit.leadingAnchor), card.trailingAnchor.constraint(equalTo: hit.trailingAnchor), card.topAnchor.constraint(equalTo: hit.topAnchor), card.bottomAnchor.constraint(equalTo: hit.bottomAnchor) ]) return hit } private func timeText(_ date: Date?) -> String { guard let date else { return "Just now" } let formatter = DateFormatter() formatter.locale = Locale.current formatter.timeStyle = .short formatter.dateStyle = .none return formatter.string(from: date) } } private final class CalendarDayActionMenuViewController: NSViewController { private let palette: Palette private let onSchedule: () -> Void init(palette: Palette, onSchedule: @escaping () -> Void) { self.palette = palette self.onSchedule = onSchedule super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func loadView() { let root = NSView() root.translatesAutoresizingMaskIntoConstraints = false let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .vertical stack.alignment = .leading stack.spacing = 10 let title = NSTextField(labelWithString: "Actions") title.font = NSFont.systemFont(ofSize: 12, weight: .semibold) title.textColor = palette.textMuted let schedule = NSButton(title: "Schedule meeting", target: self, action: #selector(schedulePressed(_:))) schedule.bezelStyle = .rounded schedule.font = NSFont.systemFont(ofSize: 13, weight: .medium) stack.addArrangedSubview(title) stack.addArrangedSubview(schedule) root.addSubview(stack) NSLayoutConstraint.activate([ root.widthAnchor.constraint(equalToConstant: 220), root.heightAnchor.constraint(greaterThanOrEqualToConstant: 86), stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14), stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14), stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12), stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12) ]) view = root } @objc private func schedulePressed(_ sender: NSButton) { onSchedule() } } private final class CreateMeetingPopoverViewController: NSViewController, NSTextViewDelegate { struct Draft { let title: String let notes: String? let startDate: Date let endDate: Date } private let palette: Palette private let typography: Typography private let selectedDate: Date private let onCancel: () -> Void private let onSave: (Draft) -> Void private var titleField: NSTextField? private var timePicker: NSDatePicker? private var durationField: NSTextField? private var notesView: NSTextView? private var notesScrollView: NSScrollView? private var errorLabel: NSTextField? private var notesBorderIdle = NSColor.clear private var notesBorderHover = NSColor.clear private var notesBorderFocused = NSColor.clear private var notesIsHovered = false private var notesIsFocused = false init(palette: Palette, typography: Typography, selectedDate: Date, onCancel: @escaping () -> Void, onSave: @escaping (Draft) -> Void) { self.palette = palette self.typography = typography self.selectedDate = selectedDate self.onCancel = onCancel self.onSave = onSave super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func loadView() { let root = NSView() root.translatesAutoresizingMaskIntoConstraints = false root.userInterfaceLayoutDirection = .leftToRight root.wantsLayer = true root.layer?.cornerRadius = 14 root.layer?.masksToBounds = true root.layer?.backgroundColor = palette.sectionCard.cgColor root.layer?.borderWidth = 1 root.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.9).cgColor let stack = NSStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.orientation = .vertical stack.alignment = .leading stack.spacing = 14 stack.userInterfaceLayoutDirection = .leftToRight let inputSurface = palette.inputBackground.blended(withFraction: 0.18, of: palette.sectionCard) ?? palette.inputBackground let fieldBorder = palette.textSecondary.withAlphaComponent(0.4) notesBorderIdle = fieldBorder notesBorderHover = palette.textSecondary.withAlphaComponent(0.72) notesBorderFocused = palette.primaryBlueBorder let header = NSTextField(labelWithString: "Schedule meeting") header.font = NSFont.systemFont(ofSize: 16, weight: .semibold) header.textColor = palette.textPrimary header.alignment = .left header.userInterfaceLayoutDirection = .leftToRight header.baseWritingDirection = .leftToRight let titleLabel = NSTextField(labelWithString: "Title") titleLabel.font = typography.fieldLabel titleLabel.textColor = palette.textSecondary titleLabel.alignment = .left titleLabel.userInterfaceLayoutDirection = .leftToRight titleLabel.baseWritingDirection = .leftToRight let titleShell = NSView() titleShell.translatesAutoresizingMaskIntoConstraints = false titleShell.wantsLayer = true titleShell.layer?.cornerRadius = 8 titleShell.layer?.backgroundColor = inputSurface.cgColor titleShell.layer?.borderColor = fieldBorder.cgColor titleShell.layer?.borderWidth = 1.2 titleShell.heightAnchor.constraint(equalToConstant: 40).isActive = true let titleField = NSTextField(string: "") titleField.translatesAutoresizingMaskIntoConstraints = false titleField.isBordered = false titleField.drawsBackground = false titleField.focusRingType = .none titleField.font = NSFont.systemFont(ofSize: 14, weight: .regular) titleField.textColor = palette.textPrimary titleField.placeholderString = "Team sync" titleShell.addSubview(titleField) NSLayoutConstraint.activate([ titleField.leadingAnchor.constraint(equalTo: titleShell.leadingAnchor, constant: 10), titleField.trailingAnchor.constraint(equalTo: titleShell.trailingAnchor, constant: -10), titleField.centerYAnchor.constraint(equalTo: titleShell.centerYAnchor) ]) self.titleField = titleField let timeRow = NSStackView() timeRow.translatesAutoresizingMaskIntoConstraints = false timeRow.orientation = .horizontal timeRow.alignment = .centerY timeRow.spacing = 10 timeRow.distribution = .fill let startLabel = NSTextField(labelWithString: "Start") startLabel.font = typography.fieldLabel startLabel.textColor = palette.textSecondary startLabel.alignment = .left startLabel.userInterfaceLayoutDirection = .leftToRight startLabel.baseWritingDirection = .leftToRight let pickerShell = NSView() pickerShell.translatesAutoresizingMaskIntoConstraints = false pickerShell.wantsLayer = true pickerShell.layer?.cornerRadius = 8 pickerShell.layer?.backgroundColor = inputSurface.cgColor pickerShell.layer?.borderColor = fieldBorder.cgColor pickerShell.layer?.borderWidth = 1.2 pickerShell.heightAnchor.constraint(equalToConstant: 34).isActive = true let timePicker = NSDatePicker() timePicker.translatesAutoresizingMaskIntoConstraints = false timePicker.isBordered = false timePicker.drawsBackground = false timePicker.focusRingType = .none timePicker.datePickerStyle = .textFieldAndStepper timePicker.datePickerElements = [.hourMinute] timePicker.font = typography.filterText timePicker.textColor = palette.textSecondary timePicker.dateValue = Date() pickerShell.addSubview(timePicker) NSLayoutConstraint.activate([ timePicker.leadingAnchor.constraint(equalTo: pickerShell.leadingAnchor, constant: 8), timePicker.trailingAnchor.constraint(equalTo: pickerShell.trailingAnchor, constant: -8), timePicker.centerYAnchor.constraint(equalTo: pickerShell.centerYAnchor) ]) self.timePicker = timePicker let durationLabel = NSTextField(labelWithString: "Duration (min)") durationLabel.font = typography.fieldLabel durationLabel.textColor = palette.textSecondary durationLabel.alignment = .left durationLabel.userInterfaceLayoutDirection = .leftToRight durationLabel.baseWritingDirection = .leftToRight let durationShell = NSView() durationShell.translatesAutoresizingMaskIntoConstraints = false durationShell.wantsLayer = true durationShell.layer?.cornerRadius = 8 durationShell.layer?.backgroundColor = inputSurface.cgColor durationShell.layer?.borderColor = fieldBorder.cgColor durationShell.layer?.borderWidth = 1.2 durationShell.heightAnchor.constraint(equalToConstant: 34).isActive = true let durationField = NSTextField(string: "30") durationField.translatesAutoresizingMaskIntoConstraints = false durationField.isBordered = false durationField.drawsBackground = false durationField.focusRingType = .none durationField.font = typography.filterText durationField.textColor = palette.textSecondary durationField.formatter = NumberFormatter() durationShell.addSubview(durationField) NSLayoutConstraint.activate([ durationField.leadingAnchor.constraint(equalTo: durationShell.leadingAnchor, constant: 8), durationField.trailingAnchor.constraint(equalTo: durationShell.trailingAnchor, constant: -8), durationField.centerYAnchor.constraint(equalTo: durationShell.centerYAnchor) ]) self.durationField = durationField let startGroup = NSStackView(views: [startLabel, pickerShell]) startGroup.translatesAutoresizingMaskIntoConstraints = false startGroup.orientation = .vertical startGroup.alignment = .leading startGroup.spacing = 6 let durationGroup = NSStackView(views: [durationLabel, durationShell]) durationGroup.translatesAutoresizingMaskIntoConstraints = false durationGroup.orientation = .vertical durationGroup.alignment = .leading durationGroup.spacing = 6 timeRow.addArrangedSubview(startGroup) timeRow.addArrangedSubview(durationGroup) startGroup.widthAnchor.constraint(equalTo: durationGroup.widthAnchor).isActive = true let notesLabel = NSTextField(labelWithString: "Notes") notesLabel.font = typography.fieldLabel notesLabel.textColor = palette.textSecondary notesLabel.alignment = .left notesLabel.userInterfaceLayoutDirection = .leftToRight notesLabel.baseWritingDirection = .leftToRight let notesScroll = HoverFocusScrollView() notesScroll.translatesAutoresizingMaskIntoConstraints = false notesScroll.drawsBackground = true notesScroll.backgroundColor = inputSurface notesScroll.hasVerticalScroller = true notesScroll.hasHorizontalScroller = false notesScroll.borderType = .noBorder notesScroll.wantsLayer = true notesScroll.layer?.cornerRadius = 8 notesScroll.layer?.masksToBounds = true notesScroll.layer?.borderWidth = 1.2 notesScroll.layer?.borderColor = notesBorderIdle.cgColor notesScroll.heightAnchor.constraint(equalToConstant: 100).isActive = true notesScroll.onHoverChanged = { [weak self] hovering in guard let self else { return } self.notesIsHovered = hovering self.updateNotesBorderAppearance() } notesScroll.onMouseDown = { [weak self] in guard let self, let notesView = self.notesView as? ImmediateFocusTextView else { return } self.view.window?.makeFirstResponder(notesView) notesView.ensureCaretVisibleImmediately() self.notesIsFocused = true self.updateNotesBorderAppearance() } let notesView = ImmediateFocusTextView(frame: .zero) notesView.drawsBackground = false notesView.font = NSFont.systemFont(ofSize: 13, weight: .regular) notesView.textColor = palette.textPrimary notesView.insertionPointColor = palette.textPrimary notesView.isEditable = true notesView.isSelectable = true notesView.isRichText = false notesView.importsGraphics = false notesView.isHorizontallyResizable = false notesView.isVerticallyResizable = true notesView.autoresizingMask = [.width] notesView.textContainerInset = NSSize(width: 6, height: 6) notesView.textContainer?.widthTracksTextView = true notesView.textContainer?.containerSize = NSSize( width: notesScroll.contentSize.width, height: CGFloat.greatestFiniteMagnitude ) notesView.delegate = self notesScroll.documentView = notesView self.notesView = notesView self.notesScrollView = notesScroll let error = NSTextField(labelWithString: "") error.translatesAutoresizingMaskIntoConstraints = false error.textColor = NSColor.systemRed error.font = NSFont.systemFont(ofSize: 12, weight: .semibold) error.isHidden = true self.errorLabel = error let actions = NSStackView() actions.translatesAutoresizingMaskIntoConstraints = false actions.orientation = .horizontal actions.alignment = .centerY actions.spacing = 10 let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed(_:))) cancel.bezelStyle = .rounded let save = NSButton(title: "Save", target: self, action: #selector(savePressed(_:))) save.bezelStyle = .rounded save.keyEquivalent = "\r" let actionsSpacer = NSView() actionsSpacer.translatesAutoresizingMaskIntoConstraints = false actionsSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) actions.addArrangedSubview(cancel) actions.addArrangedSubview(actionsSpacer) actions.addArrangedSubview(save) stack.addArrangedSubview(header) stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(titleShell) stack.addArrangedSubview(timeRow) stack.addArrangedSubview(notesLabel) stack.addArrangedSubview(notesScroll) stack.addArrangedSubview(error) stack.addArrangedSubview(actions) root.addSubview(stack) NSLayoutConstraint.activate([ root.widthAnchor.constraint(equalToConstant: 372), stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 16), stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16), titleShell.widthAnchor.constraint(equalTo: stack.widthAnchor), timeRow.widthAnchor.constraint(equalTo: stack.widthAnchor), notesScroll.widthAnchor.constraint(equalTo: stack.widthAnchor), error.widthAnchor.constraint(equalTo: stack.widthAnchor), actions.widthAnchor.constraint(equalTo: stack.widthAnchor) ]) view = root } @objc private func cancelPressed(_ sender: NSButton) { onCancel() } @objc private func savePressed(_ sender: NSButton) { setError(nil) let title = (titleField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if title.isEmpty { setError("Please enter a title.") return } let durationText = (durationField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let durationMinutes = Int(durationText) ?? 0 if durationMinutes <= 0 { setError("Duration must be a positive number of minutes.") return } let time = timePicker?.dateValue ?? Date() let calendar = Calendar.current let dateParts = calendar.dateComponents([.year, .month, .day], from: selectedDate) let timeParts = calendar.dateComponents([.hour, .minute], from: time) var merged = DateComponents() merged.year = dateParts.year merged.month = dateParts.month merged.day = dateParts.day merged.hour = timeParts.hour merged.minute = timeParts.minute guard let start = calendar.date(from: merged) else { setError("Invalid start time.") return } let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60)) let notes = notesView?.string.trimmingCharacters(in: .whitespacesAndNewlines) let cleanedNotes = (notes?.isEmpty == false) ? notes : nil onSave(Draft(title: title, notes: cleanedNotes, startDate: start, endDate: end)) } private func updateNotesBorderAppearance() { let color: NSColor let width: CGFloat if notesIsFocused { color = notesBorderFocused width = 1.6 } else if notesIsHovered { color = notesBorderHover width = 1.4 } else { color = notesBorderIdle width = 1.2 } notesScrollView?.layer?.borderColor = color.cgColor notesScrollView?.layer?.borderWidth = width } private func setError(_ message: String?) { guard let errorLabel else { return } errorLabel.stringValue = message ?? "" errorLabel.isHidden = message == nil } func textDidBeginEditing(_ notification: Notification) { guard let current = notification.object as? NSTextView, current === notesView else { return } notesIsFocused = true updateNotesBorderAppearance() } func textDidEndEditing(_ notification: Notification) { guard let current = notification.object as? NSTextView, current === notesView else { return } notesIsFocused = false updateNotesBorderAppearance() } } private final class HoverFocusScrollView: NSScrollView { var onHoverChanged: ((Bool) -> Void)? var onMouseDown: (() -> Void)? private var hoverTrackingArea: NSTrackingArea? override func updateTrackingAreas() { super.updateTrackingAreas() if let hoverTrackingArea { removeTrackingArea(hoverTrackingArea) } let area = NSTrackingArea( rect: bounds, options: [.activeInActiveApp, .inVisibleRect, .mouseEnteredAndExited], owner: self, userInfo: nil ) addTrackingArea(area) hoverTrackingArea = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) onHoverChanged?(true) } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) onHoverChanged?(false) } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func mouseDown(with event: NSEvent) { onMouseDown?() if let window, !window.isKeyWindow { window.makeKeyAndOrderFront(nil) return } // Forward the click straight to the text view so caret placement/blink starts immediately. if let textView = documentView as? NSTextView { window?.makeFirstResponder(textView) textView.mouseDown(with: event) return } super.mouseDown(with: event) } } private final class ImmediateFocusTextView: NSTextView { override var acceptsFirstResponder: Bool { true } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } @discardableResult override func becomeFirstResponder() -> Bool { let accepted = super.becomeFirstResponder() if accepted { ensureCaretVisibleImmediately() } return accepted } override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self) super.mouseDown(with: event) ensureCaretVisibleImmediately() } func ensureCaretVisibleImmediately() { var range = selectedRange() if range.location == NSNotFound { range = NSRange(location: string.utf16.count, length: 0) } setSelectedRange(range) scrollRangeToVisible(range) needsDisplay = true displayIfNeeded() } } // MARK: - Schedule actions (OAuth entry) private extension ViewController { @objc func scheduleReloadButtonPressed(_ sender: NSButton) { scheduleReloadClicked() } @objc func scheduleScrollLeftPressed(_ sender: NSButton) { scrollScheduleCards(direction: -1) } @objc func scheduleScrollRightPressed(_ sender: NSButton) { scrollScheduleCards(direction: 1) } @objc func scheduleCardButtonPressed(_ sender: NSButton) { guard storeKitCoordinator.hasPremiumAccess else { showPaywall() return } guard let raw = sender.identifier?.rawValue, let url = URL(string: raw) else { return } openURL(url) } @objc func scheduleConnectButtonPressed(_ sender: NSButton) { scheduleConnectClicked() } @objc func schedulePageLoadMorePressed(_ sender: NSButton) { appendSchedulePageBatchIfNeeded() } private func scheduleInitialHeadingText() -> String { googleOAuth.loadTokens() == nil ? "Connect Google to see your to-do" : "Loading…" } private func schedulePageInitialHeadingText() -> String { googleOAuth.loadTokens() == nil ? "Connect Google to see your to-do" : "Loading to-do…" } private func enrolledPageInitialHeadingText() -> String { googleOAuth.loadTokens() == nil ? "Connect Google to see enrolled classes" : "Loading classes…" } private func teachingPageInitialHeadingText() -> String { googleOAuth.loadTokens() == nil ? "Connect Google to see teaching classes" : "Loading classes…" } @objc func enrolledPageRefreshPressed(_ sender: NSButton) { Task { [weak self] in await self?.loadEnrolledClasses() } } @objc func teachingPageRefreshPressed(_ sender: NSButton) { Task { [weak self] in await self?.loadTeachingClasses() } } @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) { guard let selectedItem = sender.selectedItem, let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return } applyScheduleFilter(filter) } private func applyScheduleFilter(_ filter: ScheduleFilter) { scheduleFilter = filter scheduleFilterDropdown?.selectItem(at: filter.rawValue) Task { [weak self] in await self?.loadSchedule() } } @objc func schedulePageFilterDropdownChanged(_ sender: NSPopUpButton) { guard let selectedItem = sender.selectedItem, let filter = SchedulePageFilter(rawValue: selectedItem.tag) else { return } schedulePageFilter = filter refreshSchedulePageDateFilterUI() applySchedulePageFiltersAndRender() } @objc func schedulePageDatePickerChanged(_ sender: NSDatePicker) { schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate } @objc func schedulePageApplyDateRangePressed(_ sender: NSButton) { schedulePageFilter = .customRange schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.customRange.rawValue) refreshSchedulePageDateFilterUI() applySchedulePageFiltersAndRender() } @objc func schedulePageResetFiltersPressed(_ sender: NSButton) { schedulePageFilter = .all schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.all.rawValue) let today = Calendar.current.startOfDay(for: Date()) schedulePageFromDate = today schedulePageToDate = today schedulePageFromDatePicker?.dateValue = today schedulePageToDatePicker?.dateValue = today refreshSchedulePageDateFilterUI() applySchedulePageFiltersAndRender() } @objc func schedulePageAddMeetingPressed(_ sender: NSButton) { guard requireGoogleLoginForCalendarScheduling() else { return } let accessory = NSView(frame: NSRect(x: 0, y: 0, width: 230, height: 28)) let datePicker = NSDatePicker(frame: accessory.bounds) datePicker.autoresizingMask = [.width, .height] datePicker.datePickerMode = .single datePicker.datePickerStyle = .textFieldAndStepper datePicker.datePickerElements = [.yearMonthDay] datePicker.dateValue = Calendar.current.startOfDay(for: Date()) accessory.addSubview(datePicker) let alert = NSAlert() alert.messageText = "Select date" alert.informativeText = "Choose a date to schedule your meeting." alert.alertStyle = .informational alert.accessoryView = accessory alert.addButton(withTitle: "Continue") alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } calendarPageSelectedDate = Calendar.current.startOfDay(for: datePicker.dateValue) presentCreateMeetingPopover(relativeTo: sender) } private func todoDueText(for todo: ClassroomTodoItem) -> String { guard let due = todo.dueDate else { return "No due date" } let f = DateFormatter() f.locale = Locale.current f.timeZone = TimeZone.current f.dateStyle = .none f.timeStyle = .short return "Due \(f.string(from: due))" } private func todoDayText(for todo: ClassroomTodoItem) -> String { guard let due = todo.dueDate else { return "Anytime" } let f = DateFormatter() f.locale = Locale.current f.timeZone = TimeZone.current f.dateFormat = "EEE, d MMM" return f.string(from: due) } private func scheduleHeadingText(for todos: [ClassroomTodoItem]) -> String { guard let first = todos.first else { return googleOAuth.loadTokens() == nil ? "Connect Google to see to-do" : "No upcoming work" } guard let due = first.dueDate else { return "No due dates" } let day = Calendar.current.startOfDay(for: due) let f = DateFormatter() f.locale = Locale.current f.timeZone = TimeZone.current f.dateFormat = "EEEE, d MMM" return f.string(from: day) } private func enrolledPageHeadingText(for courses: [ClassroomCourse]) -> String { if googleOAuth.loadTokens() == nil { return "Connect Google to see enrolled classes" } if courses.isEmpty { return "No active enrolled classes" } return "\(courses.count) enrolled class\(courses.count == 1 ? "" : "es")" } private func teachingPageHeadingText(for courses: [ClassroomCourse]) -> String { if googleOAuth.loadTokens() == nil { return "Connect Google to see teaching classes" } if courses.isEmpty { return "No active teaching classes" } return "\(courses.count) teaching class\(courses.count == 1 ? "" : "es")" } private func openURL(_ url: URL) { openURLWithRouting(url, policy: inAppBrowserDefaultPolicy) } private func renderEnrolledClassCards(_ courses: [ClassroomCourse]) { guard let stack = enrolledPageCardsStack else { return } enrolledCourseByCardID.removeAll() stack.arrangedSubviews.forEach { view in stack.removeArrangedSubview(view) view.removeFromSuperview() } if courses.isEmpty { let empty = roundedContainer(cornerRadius: 12, color: palette.sectionCard) empty.translatesAutoresizingMaskIntoConstraints = false empty.heightAnchor.constraint(equalToConstant: 128).isActive = true styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let emptyLabel = textLabel( googleOAuth.loadTokens() == nil ? "Connect to load classes" : "No enrolled classes found", font: typography.cardSubtitle, color: palette.textSecondary ) emptyLabel.translatesAutoresizingMaskIntoConstraints = false emptyLabel.alignment = .left empty.addSubview(emptyLabel) NSLayoutConstraint.activate([ emptyLabel.leadingAnchor.constraint(equalTo: empty.leadingAnchor, constant: 18), emptyLabel.trailingAnchor.constraint(equalTo: empty.trailingAnchor, constant: -18), emptyLabel.centerYAnchor.constraint(equalTo: empty.centerYAnchor) ]) stack.addArrangedSubview(empty) empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true return } for course in courses { let card = enrolledClassCardButton(course: course) stack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } } private func renderTeachingClassCards(_ courses: [ClassroomCourse]) { guard let stack = teachingPageCardsStack else { return } teachingCourseByCardID.removeAll() stack.arrangedSubviews.forEach { view in stack.removeArrangedSubview(view) view.removeFromSuperview() } if courses.isEmpty { let empty = roundedContainer(cornerRadius: 12, color: palette.sectionCard) empty.translatesAutoresizingMaskIntoConstraints = false empty.heightAnchor.constraint(equalToConstant: 128).isActive = true styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let emptyLabel = textLabel( googleOAuth.loadTokens() == nil ? "Connect to load classes" : "No teaching classes found", font: typography.cardSubtitle, color: palette.textSecondary ) emptyLabel.translatesAutoresizingMaskIntoConstraints = false emptyLabel.alignment = .left empty.addSubview(emptyLabel) NSLayoutConstraint.activate([ emptyLabel.leadingAnchor.constraint(equalTo: empty.leadingAnchor, constant: 18), emptyLabel.trailingAnchor.constraint(equalTo: empty.trailingAnchor, constant: -18), emptyLabel.centerYAnchor.constraint(equalTo: empty.centerYAnchor) ]) stack.addArrangedSubview(empty) empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true return } for course in courses { let card = teachingClassCardButton(course: course) stack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } } private func enrolledClassCardButton(course: ClassroomCourse) -> NSView { let hit = HoverButton(title: "", target: self, action: #selector(enrolledCardButtonPressed(_:))) hit.translatesAutoresizingMaskIntoConstraints = false hit.isBordered = false hit.bezelStyle = .regularSquare hit.wantsLayer = true hit.identifier = NSUserInterfaceItemIdentifier(course.id) enrolledCourseByCardID[course.id] = course hit.heightAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true let card = enrolledClassCard(course: course) hit.addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: hit.leadingAnchor), card.trailingAnchor.constraint(equalTo: hit.trailingAnchor), card.topAnchor.constraint(equalTo: hit.topAnchor), card.bottomAnchor.constraint(equalTo: hit.bottomAnchor) ]) hit.onHoverChanged = { [weak self, weak card] hovering in guard let self, let card else { return } let base = self.palette.sectionCard let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base card.layer?.backgroundColor = (hovering ? hover : base).cgColor } hit.onHoverChanged?(false) return hit } @objc private func enrolledCardButtonPressed(_ sender: NSButton) { guard let courseID = sender.identifier?.rawValue, let course = enrolledCourseByCardID[courseID] else { return } enrolledClassCardClicked(view: sender, course: course) } @objc private func teachingCardButtonPressed(_ sender: NSButton) { guard let courseID = sender.identifier?.rawValue, let course = teachingCourseByCardID[courseID] else { return } enrolledClassCardClicked(view: sender, course: course) } private func enrolledClassCard(course: ClassroomCourse) -> NSView { let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard) card.translatesAutoresizingMaskIntoConstraints = false styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) card.heightAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true let title = textLabel(course.name, font: NSFont.systemFont(ofSize: 17, weight: .semibold), color: palette.textPrimary) title.translatesAutoresizingMaskIntoConstraints = false title.alignment = .left title.maximumNumberOfLines = 2 title.lineBreakMode = .byWordWrapping let sectionText = [course.section, course.room] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .joined(separator: " • ") let sectionLabel = textLabel(sectionText.isEmpty ? "Section details unavailable" : sectionText, font: typography.cardSubtitle, color: palette.textSecondary) sectionLabel.translatesAutoresizingMaskIntoConstraints = false sectionLabel.alignment = .left sectionLabel.maximumNumberOfLines = 1 sectionLabel.lineBreakMode = .byTruncatingTail let teachers = course.teacherNames.isEmpty ? "Teacher unavailable" : course.teacherNames.joined(separator: ", ") let teacherLabel = textLabel("Teacher: \(teachers)", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textMuted) teacherLabel.translatesAutoresizingMaskIntoConstraints = false teacherLabel.alignment = .left teacherLabel.maximumNumberOfLines = 2 teacherLabel.lineBreakMode = .byWordWrapping card.addSubview(title) card.addSubview(sectionLabel) card.addSubview(teacherLabel) NSLayoutConstraint.activate([ title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18), title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18), title.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), sectionLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor), sectionLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor), sectionLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8), teacherLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor), teacherLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor), teacherLabel.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 10), teacherLabel.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -16) ]) return card } private func teachingClassCardButton(course: ClassroomCourse) -> NSView { let hit = HoverButton(title: "", target: self, action: #selector(teachingCardButtonPressed(_:))) hit.translatesAutoresizingMaskIntoConstraints = false hit.isBordered = false hit.bezelStyle = .regularSquare hit.wantsLayer = true hit.identifier = NSUserInterfaceItemIdentifier(course.id) teachingCourseByCardID[course.id] = course hit.heightAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true let card = enrolledClassCard(course: course) hit.addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: hit.leadingAnchor), card.trailingAnchor.constraint(equalTo: hit.trailingAnchor), card.topAnchor.constraint(equalTo: hit.topAnchor), card.bottomAnchor.constraint(equalTo: hit.bottomAnchor) ]) hit.onHoverChanged = { [weak self, weak card] hovering in guard let self, let card else { return } let base = self.palette.sectionCard let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base card.layer?.backgroundColor = (hovering ? hover : base).cgColor } hit.onHoverChanged?(false) return hit } private func enrolledClassCardClicked(view: NSView, course: ClassroomCourse) { Task { [weak self, weak view] in guard let self, let view else { return } do { if self.googleOAuth.loadTokens() == nil { await MainActor.run { self.showSimpleAlert(title: "Connect Google", message: "Connect your Google account to view class details.") } return } let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window) let details = try await self.classroomClient.fetchClassDetails( accessToken: token, courseId: course.id, courseName: course.name ) await MainActor.run { let authorName = course.teacherNames.first ?? "Classroom" self.showEnrolledClassDetailsPopover(details: details, defaultAuthorName: authorName, relativeTo: view) } } catch { await MainActor.run { if self.errorRequiresReconsentForClassroomScopes(error) { _ = try? self.googleOAuth.signOut() self.applyGoogleProfile(nil) self.updateGoogleAuthButtonTitle() self.showSimpleAlert( title: "Reconnect Google", message: "We added new Google Classroom permissions for class announcements. Please reconnect your Google account and grant access." ) return } self.showSimpleError("Couldn’t load class details.", error: error) } } } } private func showEnrolledClassDetailsPopover(details: ClassroomClassDetails, defaultAuthorName: String, relativeTo anchor: NSView) { enrolledClassDetailsPopover?.performClose(nil) let popover = NSPopover() popover.behavior = .transient popover.animates = true popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua) popover.contentViewController = EnrolledClassDetailsViewController( details: details, palette: palette, typography: typography, defaultAuthorName: defaultAuthorName, defaultAuthorAvatar: scheduleProfileMenuAvatar, onOpenClass: { [weak self] in guard let self, let url = details.courseLink else { return } self.openURL(url) }, onOpenURL: { [weak self] url in self?.openURL(url) }, onDownloadAttachment: { [weak self] attachment in self?.downloadClassAttachment(attachment) } ) enrolledClassDetailsPopover = popover popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX) } private func downloadClassAttachment(_ attachment: ClassroomAttachment) { Task { [weak self] in guard let self else { return } do { let token = try? await self.googleOAuth.validAccessToken(presentingWindow: self.view.window) var request = URLRequest(url: attachment.url) request.httpMethod = "GET" if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { if http.statusCode == 401 || http.statusCode == 403 { await MainActor.run { self.openURL(attachment.url) } return } let body = String(data: data, encoding: .utf8) ?? "" throw GoogleClassroomClientError.httpStatus(http.statusCode, body) } if let http = response as? HTTPURLResponse, let contentType = http.value(forHTTPHeaderField: "Content-Type")?.lowercased(), contentType.contains("text/html") { await MainActor.run { self.openURL(attachment.url) } return } let destination = try self.uniqueDownloadURL(for: attachment) try data.write(to: destination) await MainActor.run { _ = NSWorkspace.shared.open(destination) } } catch { await MainActor.run { self.showSimpleError("Couldn’t download attachment.", error: error) } } } } private func uniqueDownloadURL(for attachment: ClassroomAttachment) throws -> URL { let fileManager = FileManager.default let downloadsDir = try fileManager.url( for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) let preferredName = attachment.title.trimmingCharacters(in: .whitespacesAndNewlines) let fallbackName = attachment.url.lastPathComponent.isEmpty ? "attachment" : attachment.url.lastPathComponent let originalName = (preferredName.isEmpty ? fallbackName : preferredName) let originalExtension = (attachment.url.pathExtension.isEmpty ? URL(fileURLWithPath: originalName).pathExtension : attachment.url.pathExtension) let baseName = URL(fileURLWithPath: originalName).deletingPathExtension().lastPathComponent var candidate = downloadsDir.appendingPathComponent(originalName) if fileManager.fileExists(atPath: candidate.path) == false { return candidate } var index = 1 while true { let numbered = "\(baseName)-\(index)\(originalExtension.isEmpty ? "" : ".\(originalExtension)")" candidate = downloadsDir.appendingPathComponent(numbered) if fileManager.fileExists(atPath: candidate.path) == false { return candidate } index += 1 } } private func renderScheduleCards(into stack: NSStackView, todos: [ClassroomTodoItem]) { displayedScheduleTodos = todos let shouldShowScrollControls = todos.count > 3 scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls scheduleScrollRightButton?.isHidden = !shouldShowScrollControls scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero) if let scroll = scheduleCardsScrollView { scroll.reflectScrolledClipView(scroll.contentView) } stack.arrangedSubviews.forEach { v in stack.removeArrangedSubview(v) v.removeFromSuperview() } if todos.isEmpty { let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard) empty.translatesAutoresizingMaskIntoConstraints = false empty.widthAnchor.constraint(equalToConstant: 240).isActive = true empty.heightAnchor.constraint(equalToConstant: 150).isActive = true styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load to-do" : "No work due", font: typography.cardSubtitle, color: palette.textSecondary) label.translatesAutoresizingMaskIntoConstraints = false empty.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: empty.centerXAnchor), label.centerYAnchor.constraint(equalTo: empty.centerYAnchor) ]) stack.addArrangedSubview(empty) return } for todo in todos { stack.addArrangedSubview(scheduleCard(todo: todo)) } } private func filteredTodos(_ todos: [ClassroomTodoItem]) -> [ClassroomTodoItem] { switch scheduleFilter { case .all: return todos case .today: let start = Calendar.current.startOfDay(for: Date()) let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400) return todos.filter { ($0.dueDate ?? .distantPast) >= start && ($0.dueDate ?? .distantPast) < end } case .week: let now = Date() let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400) return todos.filter { guard let due = $0.dueDate else { return false } return due >= now && due <= end } } } private func filteredTodosForSchedulePage(_ todos: [ClassroomTodoItem]) -> [ClassroomTodoItem] { let calendar = Calendar.current switch schedulePageFilter { case .all: return todos case .today: let start = calendar.startOfDay(for: Date()) let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400) return todos.filter { guard let due = $0.dueDate else { return false } return due >= start && due < end } case .week: let now = Date() let end = calendar.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400) return todos.filter { guard let due = $0.dueDate else { return false } return due >= now && due <= end } case .month: let now = Date() let end = calendar.date(byAdding: .month, value: 1, to: now) ?? now.addingTimeInterval(30 * 86400) return todos.filter { guard let due = $0.dueDate else { return false } return due >= now && due <= end } case .customRange: let start = calendar.startOfDay(for: schedulePageFromDate) let inclusiveEndDay = calendar.startOfDay(for: schedulePageToDate) guard let end = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else { return todos } return todos.filter { guard let due = $0.dueDate else { return false } return due >= start && due < end } } } private func refreshSchedulePageDateFilterUI() { let isCustom = schedulePageFilter == .customRange schedulePageFromDatePicker?.isEnabled = isCustom schedulePageToDatePicker?.isEnabled = isCustom let dim: CGFloat = isCustom ? 1.0 : 0.65 schedulePageFromDatePicker?.alphaValue = 1 schedulePageToDatePicker?.alphaValue = 1 schedulePageFromDatePicker?.superview?.alphaValue = dim schedulePageToDatePicker?.superview?.alphaValue = dim } private func schedulePageHasValidCustomRange() -> Bool { let start = Calendar.current.startOfDay(for: schedulePageFromDate) let end = Calendar.current.startOfDay(for: schedulePageToDate) return start <= end } private func setSchedulePageRangeError(_ message: String?) { guard let label = schedulePageRangeErrorLabel else { return } label.stringValue = message ?? "" label.isHidden = message == nil } private func applySchedulePageFiltersAndRender() { schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate if schedulePageFilter == .customRange && !schedulePageHasValidCustomRange() { setSchedulePageRangeError("Start date must be on or before end date.") schedulePageFilteredTodos = [] schedulePageVisibleCount = 0 renderSchedulePageCards() schedulePageDateHeadingLabel?.stringValue = "Invalid custom date range" return } setSchedulePageRangeError(nil) schedulePageFilteredTodos = filteredTodosForSchedulePage(scheduleCachedTodos) schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredTodos.count) renderSchedulePageCards() schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredTodos) } private func appendSchedulePageBatchIfNeeded() { guard schedulePageVisibleCount < schedulePageFilteredTodos.count else { return } let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredTodos.count) guard nextCount > schedulePageVisibleCount else { return } schedulePageVisibleCount = nextCount renderSchedulePageCards() // If we're still near the bottom after adding a batch (large viewport), // immediately evaluate again so pagination keeps advancing in chunks of 6. DispatchQueue.main.async { [weak self] in self?.schedulePageScrolled() } } private func schedulePageScrolled() { guard let scroll = schedulePageCardsScrollView else { return } let contentBounds = scroll.contentView.bounds let contentHeight = scroll.documentView?.bounds.height ?? 0 guard contentHeight > 0 else { return } let remaining = contentHeight - (contentBounds.origin.y + contentBounds.height) if remaining <= 200 { appendSchedulePageBatchIfNeeded() } } private func renderSchedulePageCards() { guard let stack = schedulePageCardsStack else { return } stack.arrangedSubviews.forEach { v in stack.removeArrangedSubview(v) v.removeFromSuperview() } let visibleTodos = Array(schedulePageFilteredTodos.prefix(schedulePageVisibleCount)) if visibleTodos.isEmpty { let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard) empty.translatesAutoresizingMaskIntoConstraints = false empty.heightAnchor.constraint(equalToConstant: 140).isActive = true styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false) let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load to-do" : "No work for selected filters", font: typography.cardSubtitle, color: palette.textSecondary) label.translatesAutoresizingMaskIntoConstraints = false empty.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: empty.centerXAnchor), label.centerYAnchor.constraint(equalTo: empty.centerYAnchor) ]) stack.addArrangedSubview(empty) empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true return } var index = 0 while index < visibleTodos.count { let row = NSStackView() row.translatesAutoresizingMaskIntoConstraints = false row.userInterfaceLayoutDirection = .leftToRight row.orientation = .horizontal row.alignment = .top row.spacing = schedulePageCardSpacing row.distribution = .fillEqually let rowEnd = min(index + schedulePageCardsPerRow, visibleTodos.count) for todo in visibleTodos[index.. Bool { if case let GoogleClassroomClientError.httpStatus(status, body) = error { guard status == 403 else { return false } return body.contains("ACCESS_TOKEN_SCOPE_INSUFFICIENT") || body.contains("insufficient authentication scopes") } return false } private func loadSchedule() async { do { if googleOAuth.loadTokens() == nil { await MainActor.run { updateGoogleAuthButtonTitle() applyGoogleProfile(nil) scheduleDateHeadingLabel?.stringValue = "Connect Google to see your to-do" schedulePageDateHeadingLabel?.stringValue = "Connect Google to see your to-do" if let stack = scheduleCardsStack { renderScheduleCards(into: stack, todos: []) } scheduleCachedMeetings = [] scheduleCachedTodos = [] applySchedulePageFiltersAndRender() if calendarPageGridStack != nil { calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor) renderCalendarMonthGrid() renderCalendarSelectedDay() } } return } let token = try await googleOAuth.validAccessToken(presentingWindow: view.window) let profile = try? await googleOAuth.fetchUserProfile(accessToken: token) let todos = try await classroomClient.fetchTodo(accessToken: token) let filtered = filteredTodos(todos) let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token) await MainActor.run { updateGoogleAuthButtonTitle() applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) }) scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered) if let stack = scheduleCardsStack { renderScheduleCards(into: stack, todos: filtered) } scheduleCachedMeetings = meetings scheduleCachedTodos = todos applySchedulePageFiltersAndRender() if calendarPageGridStack != nil { calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor) renderCalendarMonthGrid() renderCalendarSelectedDay() } } } catch { await MainActor.run { if errorRequiresReconsentForClassroomScopes(error) { _ = try? googleOAuth.signOut() applyGoogleProfile(nil) updateGoogleAuthButtonTitle() scheduleDateHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions" schedulePageDateHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions" if let stack = scheduleCardsStack { renderScheduleCards(into: stack, todos: []) } scheduleCachedMeetings = [] scheduleCachedTodos = [] applySchedulePageFiltersAndRender() if calendarPageGridStack != nil { calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor) renderCalendarMonthGrid() renderCalendarSelectedDay() } showSimpleAlert( title: "Reconnect Google", message: "We added Google Classroom permissions. Please connect your Google account again so Google can grant access to assignments and quizzes." ) return } updateGoogleAuthButtonTitle() if googleOAuth.loadTokens() == nil { applyGoogleProfile(nil) } scheduleDateHeadingLabel?.stringValue = "Couldn’t load to-do" schedulePageDateHeadingLabel?.stringValue = "Couldn’t load to-do" if let stack = scheduleCardsStack { renderScheduleCards(into: stack, todos: []) } scheduleCachedMeetings = [] scheduleCachedTodos = [] applySchedulePageFiltersAndRender() if calendarPageGridStack != nil { calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor) renderCalendarMonthGrid() renderCalendarSelectedDay() } showSimpleError("Couldn’t load to-do.", error: error) } } } private func loadEnrolledClasses() async { do { if googleOAuth.loadTokens() == nil { await MainActor.run { enrolledCachedCourses = [] enrolledPageHeadingLabel?.stringValue = "Connect Google to see enrolled classes" renderEnrolledClassCards([]) } return } let token = try await googleOAuth.validAccessToken(presentingWindow: view.window) let courses = try await classroomClient.fetchEnrolledCourses(accessToken: token) await MainActor.run { enrolledCachedCourses = courses enrolledPageHeadingLabel?.stringValue = enrolledPageHeadingText(for: courses) renderEnrolledClassCards(courses) } } catch { await MainActor.run { if errorRequiresReconsentForClassroomScopes(error) { _ = try? googleOAuth.signOut() applyGoogleProfile(nil) updateGoogleAuthButtonTitle() enrolledCachedCourses = [] enrolledPageHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions" renderEnrolledClassCards([]) showSimpleAlert( title: "Reconnect Google", message: "We added Google Classroom permissions. Please connect your Google account again so Google can grant access to your classes." ) return } enrolledCachedCourses = [] enrolledPageHeadingLabel?.stringValue = "Couldn’t load enrolled classes" renderEnrolledClassCards([]) showSimpleError("Couldn’t load enrolled classes.", error: error) } } } private func loadTeachingClasses() async { do { if googleOAuth.loadTokens() == nil { await MainActor.run { teachingCachedCourses = [] teachingPageHeadingLabel?.stringValue = "Connect Google to see teaching classes" renderTeachingClassCards([]) } return } let token = try await googleOAuth.validAccessToken(presentingWindow: view.window) let courses = try await classroomClient.fetchTeachingCourses(accessToken: token) await MainActor.run { teachingCachedCourses = courses teachingPageHeadingLabel?.stringValue = teachingPageHeadingText(for: courses) renderTeachingClassCards(courses) } } catch { await MainActor.run { if errorRequiresReconsentForClassroomScopes(error) { _ = try? googleOAuth.signOut() applyGoogleProfile(nil) updateGoogleAuthButtonTitle() teachingCachedCourses = [] teachingPageHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions" renderTeachingClassCards([]) showSimpleAlert( title: "Reconnect Google", message: "We added Google Classroom permissions. Please connect your Google account again so Google can grant access to your classes." ) return } teachingCachedCourses = [] teachingPageHeadingLabel?.stringValue = "Couldn’t load teaching classes" renderTeachingClassCards([]) showSimpleError("Couldn’t load teaching classes.", error: error) } } } func showScheduleHelp() { let alert = NSAlert() alert.messageText = "Google Classroom to-do" alert.informativeText = "To show assignments and quizzes, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme." alert.addButton(withTitle: "OK") alert.runModal() } func scheduleReloadClicked() { Task { [weak self] in guard let self else { return } do { try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window) _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window) await MainActor.run { self.scheduleDateHeadingLabel?.stringValue = "Refreshing…" self.pageCache[.joinMeetings] = nil self.pageCache[.photo] = nil self.pageCache[.enrolled] = nil self.pageCache[.teaching] = nil self.showSidebarPage(self.selectedSidebarPage) } await self.loadSchedule() } catch { await MainActor.run { self.showSimpleError("Couldn’t refresh schedule.", error: error) } } } } func scheduleConnectClicked() { Task { [weak self] in guard let self else { return } do { if self.googleOAuth.loadTokens() != nil { await MainActor.run { self.showGoogleAccountMenu() } return } try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window) let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window) let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token) await MainActor.run { self.updateGoogleAuthButtonTitle() self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) }) self.pageCache[.joinMeetings] = nil self.pageCache[.photo] = nil self.pageCache[.enrolled] = nil self.pageCache[.teaching] = nil self.pageCache[.video] = nil self.pageCache[.settings] = nil self.showSidebarPage(self.selectedSidebarPage) } } catch { self.showSimpleError("Couldn’t connect Google account.", error: error) } } } private func showGoogleAccountMenu() { guard let button = scheduleGoogleAuthButton else { return } if googleAccountPopover?.isShown == true { googleAccountPopover?.performClose(nil) googleAccountPopover = nil return } let popover = NSPopover() popover.behavior = .transient popover.animates = true popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua) let name = scheduleCurrentProfile?.name ?? "Google account" let email = scheduleCurrentProfile?.email ?? "Signed in" let avatar = scheduleProfileMenuAvatar popover.contentViewController = GoogleAccountMenuViewController( palette: palette, darkModeEnabled: darkModeEnabled, displayName: name, email: email, avatar: avatar, onSignOut: { [weak self] in self?.googleAccountPopover?.performClose(nil) self?.googleAccountPopover = nil self?.performGoogleSignOut() } ) googleAccountPopover = popover popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) } private func performGoogleSignOut() { do { try googleOAuth.signOut() applyGoogleProfile(nil) updateGoogleAuthButtonTitle() pageCache[.joinMeetings] = nil pageCache[.photo] = nil pageCache[.enrolled] = nil pageCache[.teaching] = nil pageCache[.video] = nil pageCache[.settings] = nil showSidebarPage(selectedSidebarPage) Task { [weak self] in await self?.loadSchedule() } } catch { showSimpleError("Couldn’t logout Google account.", error: error) } } private func updateGoogleAuthButtonTitle() { let signedIn = (googleOAuth.loadTokens() != nil) guard let button = scheduleGoogleAuthButton else { return } let profileName = scheduleCurrentProfile?.name ?? "Google account" let ringHostInset: CGFloat = signedIn ? 14 : 0 scheduleGoogleAuthHostPadWidthConstraint?.constant = ringHostInset scheduleGoogleAuthHostPadHeightConstraint?.constant = ringHostInset scheduleGoogleAuthHostView?.setAvatarRingMode(signedIn) scheduleGoogleAuthHostView?.updateRingAppearance(isDark: darkModeEnabled, accent: palette.primaryBlue) if signedIn == false { scheduleGoogleAuthHostView?.setProfileHoverActive(false) } if signedIn { button.setAccessibilityLabel("\(profileName), Google account") button.attributedTitle = NSAttributedString(string: "") button.imagePosition = .imageOnly button.imageScaling = .scaleProportionallyDown button.symbolConfiguration = nil scheduleGoogleAuthButtonHeightConstraint?.constant = scheduleGoogleSignedInAvatarSize scheduleGoogleAuthButtonWidthConstraint?.constant = scheduleGoogleSignedInAvatarSize button.layer?.cornerRadius = scheduleGoogleSignedInAvatarSize / 2 let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile") if let symbol { let sized = resizedImage(symbol, to: NSSize(width: scheduleGoogleSignedInAvatarSize, height: scheduleGoogleSignedInAvatarSize)) button.image = sized button.contentTintColor = palette.textSecondary } else { button.image = nil button.contentTintColor = nil } scheduleProfileMenuAvatar = button.image } else { button.setAccessibilityLabel("Sign in with Google") let title = "Sign in with Google" let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium) let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1) button.attributedTitle = NSAttributedString(string: title, attributes: [ .font: titleFont, .foregroundColor: titleColor ]) let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width let idealWidth = ceil(textWidth + 80) scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth)) scheduleGoogleAuthButtonHeightConstraint?.constant = 42 button.layer?.cornerRadius = 21 button.imagePosition = .imageLeading button.imageScaling = .scaleNone if let g = NSImage(named: "GoogleGLogo") { button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8) } else { button.image = nil } button.contentTintColor = nil } applyGoogleAuthButtonSurface() } private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay { let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines) let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines) return GoogleProfileDisplay( name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User", email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in", pictureURL: profile.picture.flatMap(URL.init(string:)) ) } private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) { scheduleProfileImageTask?.cancel() scheduleProfileImageTask = nil if profile == nil { scheduleProfileMenuAvatar = nil } scheduleCurrentProfile = profile updateGoogleAuthButtonTitle() guard let profile, let pictureURL = profile.pictureURL else { return } let avatarDiameter = scheduleGoogleSignedInAvatarSize scheduleProfileImageTask = Task { [weak self] in do { let (data, _) = try await URLSession.shared.data(from: pictureURL) if Task.isCancelled { return } guard let image = NSImage(data: data) else { return } await MainActor.run { [weak self] in guard let self else { return } let rounded = self.circularProfileImage(image, diameter: avatarDiameter) self.scheduleProfileMenuAvatar = circularNSImage(rounded, diameter: 48) self.scheduleGoogleAuthButton?.image = rounded self.scheduleGoogleAuthButton?.contentTintColor = nil } } catch { // Keep placeholder avatar if image fetch fails. } } } private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage { let result = NSImage(size: size) result.lockFocus() image.draw(in: NSRect(origin: .zero, size: size), from: NSRect(origin: .zero, size: image.size), operation: .copy, fraction: 1.0) result.unlockFocus() result.isTemplate = false return result } /// Clips a photo to a circle for the signed-in avatar (Google userinfo `picture` URLs are usually square). private func circularProfileImage(_ image: NSImage, diameter: CGFloat) -> NSImage { circularNSImage(image, diameter: diameter) } private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage { let base = resizedImage(image, to: iconSize) let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height) let result = NSImage(size: canvas) result.lockFocus() base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height), from: NSRect(origin: .zero, size: base.size), operation: .copy, fraction: 1.0) result.unlockFocus() result.isTemplate = false return result } private func applyGoogleAuthButtonSurface() { guard let button = scheduleGoogleAuthButton else { return } let signedIn = (googleOAuth.loadTokens() != nil) let isDark = darkModeEnabled if signedIn { button.layer?.backgroundColor = NSColor.clear.cgColor button.layer?.borderWidth = 0 scheduleGoogleAuthHostView?.updateRingAppearance(isDark: isDark, accent: palette.primaryBlue) return } let baseBackground = isDark ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1) : NSColor.white let hoverBlend = isDark ? NSColor.white : NSColor.black let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground let baseBorder = isDark ? NSColor(calibratedWhite: 0.50, alpha: 1) : NSColor(calibratedWhite: 0.72, alpha: 1) let hoverBorder = isDark ? NSColor(calibratedWhite: 0.62, alpha: 1) : NSColor(calibratedWhite: 0.56, alpha: 1) button.layer?.borderWidth = 1 button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor } @MainActor func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws { if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return } let alert = NSAlert() alert.messageText = "Enter Google OAuth credentials" alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON." let accessory = NSStackView() accessory.orientation = .vertical accessory.spacing = 8 accessory.alignment = .leading let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "") idField.placeholderString = "Client ID (....apps.googleusercontent.com)" idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24) let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "") secretField.placeholderString = "Client Secret (GOCSPX-...)" secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24) accessory.addArrangedSubview(idField) accessory.addArrangedSubview(secretField) alert.accessoryView = accessory alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Cancel") // Keep this synchronous to avoid additional sheet state handling. let response = alert.runModal() if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId } let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if idValue.isEmpty { throw GoogleOAuthError.missingClientId } if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret } googleOAuth.setClientIdForTesting(idValue) googleOAuth.setClientSecretForTesting(secretValue) } func showSimpleError(_ title: String, error: Error) { DispatchQueue.main.async { let alert = NSAlert() alert.alertStyle = .warning alert.messageText = title alert.informativeText = error.localizedDescription alert.addButton(withTitle: "OK") alert.runModal() } } } private struct Palette { let pageBackground: NSColor let sidebarBackground: NSColor let sectionCard: NSColor let tabBarBackground: NSColor let tabIdleBackground: NSColor let inputBackground: NSColor let inputBorder: NSColor let primaryBlue: NSColor let primaryBlueBorder: NSColor let cancelButton: NSColor let meetingBadge: NSColor let separator: NSColor let textPrimary: NSColor let textSecondary: NSColor let textTertiary: NSColor let textMuted: NSColor init(isDarkMode: Bool) { if isDarkMode { pageBackground = NSColor(calibratedRed: 10.0 / 255.0, green: 11.0 / 255.0, blue: 12.0 / 255.0, alpha: 1) sidebarBackground = NSColor(calibratedRed: 16.0 / 255.0, green: 17.0 / 255.0, blue: 19.0 / 255.0, alpha: 1) sectionCard = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1) tabBarBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1) tabIdleBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1) inputBackground = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1) inputBorder = NSColor(calibratedRed: 38.0 / 255.0, green: 40.0 / 255.0, blue: 44.0 / 255.0, alpha: 1) primaryBlue = NSColor(calibratedRed: 15.0 / 255.0, green: 157.0 / 255.0, blue: 88.0 / 255.0, alpha: 1) primaryBlueBorder = NSColor(calibratedRed: 13.0 / 255.0, green: 134.0 / 255.0, blue: 73.0 / 255.0, alpha: 1) cancelButton = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1) meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1) separator = NSColor(calibratedRed: 26.0 / 255.0, green: 27.0 / 255.0, blue: 30.0 / 255.0, alpha: 1) textPrimary = NSColor(calibratedWhite: 0.98, alpha: 1) textSecondary = NSColor(calibratedWhite: 0.78, alpha: 1) textTertiary = NSColor(calibratedWhite: 0.66, alpha: 1) textMuted = NSColor(calibratedWhite: 0.44, alpha: 1) } else { pageBackground = NSColor(calibratedRed: 244.0 / 255.0, green: 246.0 / 255.0, blue: 249.0 / 255.0, alpha: 1) sidebarBackground = NSColor(calibratedRed: 232.0 / 255.0, green: 236.0 / 255.0, blue: 242.0 / 255.0, alpha: 1) sectionCard = NSColor.white tabBarBackground = NSColor.white tabIdleBackground = NSColor.white inputBackground = NSColor(calibratedRed: 247.0 / 255.0, green: 249.0 / 255.0, blue: 252.0 / 255.0, alpha: 1) inputBorder = NSColor(calibratedRed: 211.0 / 255.0, green: 218.0 / 255.0, blue: 228.0 / 255.0, alpha: 1) primaryBlue = NSColor(calibratedRed: 15.0 / 255.0, green: 157.0 / 255.0, blue: 88.0 / 255.0, alpha: 1) primaryBlueBorder = NSColor(calibratedRed: 13.0 / 255.0, green: 134.0 / 255.0, blue: 73.0 / 255.0, alpha: 1) cancelButton = NSColor(calibratedRed: 240.0 / 255.0, green: 243.0 / 255.0, blue: 248.0 / 255.0, alpha: 1) meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1) separator = NSColor(calibratedRed: 212.0 / 255.0, green: 219.0 / 255.0, blue: 229.0 / 255.0, alpha: 1) textPrimary = NSColor(calibratedRed: 32.0 / 255.0, green: 38.0 / 255.0, blue: 47.0 / 255.0, alpha: 1) textSecondary = NSColor(calibratedRed: 82.0 / 255.0, green: 92.0 / 255.0, blue: 107.0 / 255.0, alpha: 1) textTertiary = NSColor(calibratedRed: 110.0 / 255.0, green: 120.0 / 255.0, blue: 136.0 / 255.0, alpha: 1) textMuted = NSColor(calibratedRed: 134.0 / 255.0, green: 145.0 / 255.0, blue: 162.0 / 255.0, alpha: 1) } } } private struct Typography { let sidebarBrand = NSFont.systemFont(ofSize: 26, weight: .bold) let sidebarSection = NSFont.systemFont(ofSize: 11, weight: .medium) let sidebarIcon = NSFont.systemFont(ofSize: 12, weight: .medium) let sidebarItem = NSFont.systemFont(ofSize: 16, weight: .medium) let pageTitle = NSFont.systemFont(ofSize: 27, weight: .semibold) let joinWithURLTitle = NSFont.systemFont(ofSize: 17, weight: .semibold) let sectionTitleBold = NSFont.systemFont(ofSize: 25, weight: .bold) let dateHeading = NSFont.systemFont(ofSize: 18, weight: .medium) let tabIcon = NSFont.systemFont(ofSize: 13, weight: .regular) let tabTitle = NSFont.systemFont(ofSize: 31 / 2, weight: .semibold) let fieldLabel = NSFont.systemFont(ofSize: 15, weight: .medium) let inputPlaceholder = NSFont.systemFont(ofSize: 14, weight: .regular) let buttonText = NSFont.systemFont(ofSize: 16, weight: .medium) let filterText = NSFont.systemFont(ofSize: 15, weight: .regular) let filterArrow = NSFont.systemFont(ofSize: 12, weight: .regular) let iconButton = NSFont.systemFont(ofSize: 14, weight: .medium) let cardIcon = NSFont.systemFont(ofSize: 8, weight: .bold) let cardTitle = NSFont.systemFont(ofSize: 15, weight: .semibold) let cardSubtitle = NSFont.systemFont(ofSize: 13, weight: .bold) let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular) } // MARK: - In-app browser (macOS WKWebView + chrome) // Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs. private enum InAppBrowserURLPolicy: Equatable { case allowAll case whitelist(hostSuffixes: [String]) } private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool { let scheme = (url.scheme ?? "").lowercased() if scheme == "about" { return true } guard scheme == "http" || scheme == "https" else { return false } guard let host = url.host?.lowercased() else { return false } switch policy { case .allowAll: return true case .whitelist(let suffixes): for suffix in suffixes { let s = suffix.lowercased() if host == s || host.hasSuffix("." + s) { return true } } return false } } private enum InAppBrowserWebKitSupport { static func makeWebViewConfiguration() -> WKWebViewConfiguration { let config = WKWebViewConfiguration() config.websiteDataStore = .default() config.preferences.javaScriptCanOpenWindowsAutomatically = true if #available(macOS 12.3, *) { config.preferences.isElementFullscreenEnabled = true } config.mediaTypesRequiringUserActionForPlayback = [] if #available(macOS 11.0, *) { config.defaultWebpagePreferences.allowsContentJavaScript = true } config.applicationNameForUserAgent = "MeetingsApp/1.0" return config } } private final class InAppBrowserWindowController: NSWindowController { private static let defaultContentSize = NSSize(width: 1100, height: 760) private static let minimumContentSize = NSSize(width: 800, height: 520) private let browserViewController = InAppBrowserContainerViewController() init() { let browserWindow = NSWindow( contentRect: NSRect(origin: .zero, size: Self.defaultContentSize), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) browserWindow.title = "Browser" browserWindow.isRestorable = false browserWindow.setFrameAutosaveName("") browserWindow.minSize = browserWindow.frameRect(forContentRect: NSRect(origin: .zero, size: Self.minimumContentSize)).size browserWindow.center() browserWindow.contentViewController = browserViewController super.init(window: browserWindow) } @available(*, unavailable) required init?(coder: NSCoder) { nil } /// Resets size and position each time the browser is shown so a previously tiny window is never reused. func applyDefaultFrameCenteredOnVisibleScreen() { guard let w = window, let screen = w.screen ?? NSScreen.main else { return } let windowFrame = w.frameRect(forContentRect: NSRect(origin: .zero, size: Self.defaultContentSize)) let vf = screen.visibleFrame var frame = windowFrame frame.origin.x = vf.midX - frame.width / 2 frame.origin.y = vf.midY - frame.height / 2 if frame.maxX > vf.maxX { frame.origin.x = vf.maxX - frame.width } if frame.minX < vf.minX { frame.origin.x = vf.minX } if frame.maxY > vf.maxY { frame.origin.y = vf.maxY - frame.height } if frame.minY < vf.minY { frame.origin.y = vf.minY } w.setFrame(frame, display: true) } func load(url: URL, policy: InAppBrowserURLPolicy) { browserViewController.setNavigationPolicy(policy) browserViewController.load(url: url) } } private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate { private var webView: WKWebView! private var webContainerView: NSView! private weak var urlField: NSTextField? private var backButton: NSButton! private var forwardButton: NSButton! private var reloadStopButton: NSButton! private var goButton: NSButton! private var progressBar: NSProgressIndicator! private var lastLoadedURL: URL? private var navigationPolicy: InAppBrowserURLPolicy = .allowAll private var processTerminateRetryCount = 0 /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash. private let maxProcessTerminateRetries = 3 private var kvoTokens: [NSKeyValueObservation] = [] deinit { kvoTokens.removeAll() } func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) { navigationPolicy = policy } override func loadView() { let root = NSView() root.translatesAutoresizingMaskIntoConstraints = false let wv = makeWebView() webView = wv let webHost = NSView() webHost.translatesAutoresizingMaskIntoConstraints = false webHost.wantsLayer = true webHost.addSubview(wv) NSLayoutConstraint.activate([ wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor), wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor), wv.topAnchor.constraint(equalTo: webHost.topAnchor), wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor) ]) webContainerView = webHost let toolbar = NSStackView() toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.orientation = .horizontal toolbar.spacing = 8 toolbar.alignment = .centerY toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back") backButton.target = self backButton.action = #selector(goBack) forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward") forwardButton.target = self forwardButton.action = #selector(goForward) reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload") reloadStopButton.target = self reloadStopButton.action = #selector(reloadOrStop) let field = NSTextField(string: "") field.translatesAutoresizingMaskIntoConstraints = false field.font = NSFont.systemFont(ofSize: 13, weight: .regular) field.placeholderString = "Address" field.cell?.sendsActionOnEndEditing = false field.delegate = self urlField = field goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted)) goButton.translatesAutoresizingMaskIntoConstraints = false goButton.bezelStyle = .rounded toolbar.addArrangedSubview(backButton) toolbar.addArrangedSubview(forwardButton) toolbar.addArrangedSubview(reloadStopButton) toolbar.addArrangedSubview(field) toolbar.addArrangedSubview(goButton) field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true let bar = NSProgressIndicator() bar.translatesAutoresizingMaskIntoConstraints = false bar.style = .bar bar.isIndeterminate = false bar.minValue = 0 bar.maxValue = 1 bar.doubleValue = 0 bar.isHidden = true progressBar = bar let separator = NSBox() separator.translatesAutoresizingMaskIntoConstraints = false separator.boxType = .separator webView.navigationDelegate = self webView.uiDelegate = self root.addSubview(toolbar) root.addSubview(bar) root.addSubview(separator) root.addSubview(webHost) NSLayoutConstraint.activate([ toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor), toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor), toolbar.topAnchor.constraint(equalTo: root.topAnchor), bar.leadingAnchor.constraint(equalTo: root.leadingAnchor), bar.trailingAnchor.constraint(equalTo: root.trailingAnchor), bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor), bar.heightAnchor.constraint(equalToConstant: 3), separator.leadingAnchor.constraint(equalTo: root.leadingAnchor), separator.trailingAnchor.constraint(equalTo: root.trailingAnchor), separator.topAnchor.constraint(equalTo: bar.bottomAnchor), webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor), webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor), webHost.topAnchor.constraint(equalTo: separator.bottomAnchor), webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor) ]) view = root installWebViewObservers() syncToolbarFromWebView() } private func makeWebView() -> WKWebView { let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration()) wv.translatesAutoresizingMaskIntoConstraints = false return wv } private func teardownWebViewObservers() { kvoTokens.removeAll() } /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet). private func replaceWebViewAndLoad(url: URL) { teardownWebViewObservers() webView.navigationDelegate = nil webView.uiDelegate = nil webView.removeFromSuperview() let wv = makeWebView() webView = wv webContainerView.addSubview(wv) NSLayoutConstraint.activate([ wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor), wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor), wv.topAnchor.constraint(equalTo: webContainerView.topAnchor), wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor) ]) webView.navigationDelegate = self webView.uiDelegate = self installWebViewObservers() syncToolbarFromWebView() webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData)) } private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton { let b = NSButton() b.translatesAutoresizingMaskIntoConstraints = false b.bezelStyle = .texturedRounded b.setAccessibilityLabel(accessibilityDescription) if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) { b.image = img b.imagePosition = .imageOnly } else { b.title = title } b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true return b } private func installWebViewObservers() { kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in self?.syncToolbarFromWebView() }) kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in self?.syncToolbarFromWebView() }) kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in self?.syncToolbarFromWebView() }) kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in self?.syncProgressFromWebView() }) kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in self?.syncWindowTitle() }) kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in self?.syncAddressFieldFromWebView() }) } private func syncToolbarFromWebView() { backButton?.isEnabled = webView.canGoBack forwardButton?.isEnabled = webView.canGoForward if webView.isLoading { if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") { reloadStopButton.image = img reloadStopButton.imagePosition = .imageOnly reloadStopButton.title = "" } else { reloadStopButton.title = "Stop" } } else { if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") { reloadStopButton.image = img reloadStopButton.imagePosition = .imageOnly reloadStopButton.title = "" } else { reloadStopButton.title = "Reload" } } syncProgressFromWebView() } private func syncProgressFromWebView() { guard let progressBar else { return } if webView.isLoading { progressBar.isHidden = false progressBar.doubleValue = webView.estimatedProgress } else { progressBar.isHidden = true progressBar.doubleValue = 0 } } private func syncAddressFieldFromWebView() { guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return } urlField.stringValue = url.absoluteString } private func syncWindowTitle() { let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let host = webView.url?.host ?? "" view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t } func load(url: URL) { lastLoadedURL = url processTerminateRetryCount = 0 urlField?.stringValue = url.absoluteString webView.load(URLRequest(url: url)) syncWindowTitle() } @objc private func goBack() { webView.goBack() } @objc private func goForward() { webView.goForward() } @objc private func reloadOrStop() { if webView.isLoading { webView.stopLoading() } else { webView.reload() } } @objc private func addressFieldSubmitted() { let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard raw.isEmpty == false else { return } var normalized = raw if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false { normalized = "https://\(normalized)" } guard let url = URL(string: normalized), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", url.host != nil else { let alert = NSAlert() alert.messageText = "Invalid address" alert.informativeText = "Enter a valid web address, for example https://example.com" alert.addButton(withTitle: "OK") alert.runModal() return } guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else { presentBlockedHostAlert() return } load(url: url) } private func presentBlockedHostAlert() { let alert = NSAlert() alert.messageText = "Address not allowed" alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)." alert.addButton(withTitle: "OK") alert.runModal() } private func openExternally(_ url: URL) { NSWorkspace.shared.open(url) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { processTerminateRetryCount = 0 syncAddressFieldFromWebView() syncWindowTitle() } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { let nsError = error as NSError if nsError.code == NSURLErrorCancelled { return } let alert = NSAlert() alert.messageText = "Unable to load page" alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)" alert.addButton(withTitle: "Try Again") alert.addButton(withTitle: "OK") if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL { processTerminateRetryCount = 0 webView.load(URLRequest(url: url)) } } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { guard let url = lastLoadedURL else { return } if processTerminateRetryCount < maxProcessTerminateRetries { processTerminateRetryCount += 1 replaceWebViewAndLoad(url: url) return } let alert = NSAlert() alert.messageText = "Page stopped loading" alert.informativeText = "The in-app browser closed this page unexpectedly. You can try loading it again in this same window." alert.addButton(withTitle: "Try Again") alert.addButton(withTitle: "OK") if alert.runModal() == .alertFirstButtonReturn { processTerminateRetryCount = 0 replaceWebViewAndLoad(url: url) } } func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { guard let url = navigationAction.request.url else { decisionHandler(.allow) return } let scheme = (url.scheme ?? "").lowercased() if scheme != "http" && scheme != "https" { openExternally(url) decisionHandler(.cancel) return } if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false { if navigationAction.targetFrame?.isMainFrame != false { DispatchQueue.main.async { [weak self] in self?.presentBlockedHostAlert() } } decisionHandler(.cancel) return } decisionHandler(.allow) } func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url { let scheme = (requestURL.scheme ?? "").lowercased() if scheme != "http" && scheme != "https" { openExternally(requestURL) } else if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) { webView.load(URLRequest(url: requestURL)) } else { presentBlockedHostAlert() } } return nil } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) { addressFieldSubmitted() return true } return false } }