Parcourir la source

Add System, Light, and Dark theme picker in Settings.

Persist appearance preference and apply it at launch so users can match macOS or force light or dark chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 il y a 2 mois
Parent
commit
ce75d79d9c

+ 3 - 4
App for Indeed/AppDelegate.swift

@@ -20,8 +20,8 @@ enum AppWindowConfiguration {
         window.titlebarAppearsTransparent = true
         window.titlebarAppearsTransparent = true
         window.titleVisibility = .hidden
         window.titleVisibility = .hidden
         window.isMovableByWindowBackground = true
         window.isMovableByWindowBackground = true
-        // Same as `DashboardView` chrome — avoids a white halo outside the grey frame with fullSizeContentView.
-        window.backgroundColor = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
+        // Same as dashboard chrome — avoids a halo outside the frame with fullSizeContentView.
+        window.backgroundColor = AppAppearanceManager.shared.windowChromeColor
 
 
         let targetContent = NSRect(origin: .zero, size: defaultContentSize)
         let targetContent = NSRect(origin: .zero, size: defaultContentSize)
         let targetFrame = window.frameRect(forContentRect: targetContent)
         let targetFrame = window.frameRect(forContentRect: targetContent)
@@ -48,8 +48,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     private var lastSubscriptionRefreshAt: Date?
     private var lastSubscriptionRefreshAt: Date?
 
 
     func applicationWillFinishLaunching(_ notification: Notification) {
     func applicationWillFinishLaunching(_ notification: Notification) {
-        // Dashboard is light-themed; without this, a Dark Mode Mac paints a dark title bar.
-        NSApp.appearance = NSAppearance(named: .aqua)
+        AppAppearanceManager.shared.apply()
     }
     }
 
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
     func applicationDidFinishLaunching(_ aNotification: Notification) {

+ 98 - 0
App for Indeed/Services/AppAppearanceManager.swift

@@ -0,0 +1,98 @@
+//
+//  AppAppearanceManager.swift
+//  App for Indeed
+//
+
+import AppKit
+
+/// Persists and applies the user’s light / dark / system appearance preference.
+@MainActor
+final class AppAppearanceManager {
+    static let shared = AppAppearanceManager()
+
+    static let didChangeNotification = Notification.Name("AppAppearanceManager.didChange")
+
+    enum Mode: String, CaseIterable {
+        case light
+        case dark
+        case system
+
+        var segmentIndex: Int {
+            switch self {
+            case .system: 0
+            case .light: 1
+            case .dark: 2
+            }
+        }
+
+        init?(segmentIndex: Int) {
+            switch segmentIndex {
+            case 0: self = .system
+            case 1: self = .light
+            case 2: self = .dark
+            default: return nil
+            }
+        }
+    }
+
+    private enum UserDefaultsKey {
+        static let appearanceMode = "com.appforindeed.appearanceMode"
+    }
+
+    private var systemThemeObserver: NSObjectProtocol?
+
+    private init() {
+        systemThemeObserver = DistributedNotificationCenter.default().addObserver(
+            forName: Notification.Name("AppleInterfaceThemeChangedNotification"),
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            guard let self, self.mode == .system else { return }
+            self.updateWindowChrome()
+        }
+    }
+
+    var mode: Mode {
+        get {
+            guard let raw = UserDefaults.standard.string(forKey: UserDefaultsKey.appearanceMode),
+                  let stored = Mode(rawValue: raw) else {
+                return .light
+            }
+            return stored
+        }
+        set {
+            guard newValue != mode else { return }
+            UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.appearanceMode)
+            apply()
+            NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
+        }
+    }
+
+    /// Window backing color aligned with dashboard chrome for the active appearance.
+    var windowChromeColor: NSColor {
+        let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+        if isDark {
+            return NSColor(srgbRed: 28 / 255, green: 28 / 255, blue: 30 / 255, alpha: 1)
+        }
+        return NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
+    }
+
+    func apply() {
+        switch mode {
+        case .light:
+            NSApp.appearance = NSAppearance(named: .aqua)
+        case .dark:
+            NSApp.appearance = NSAppearance(named: .darkAqua)
+        case .system:
+            NSApp.appearance = nil
+        }
+        updateWindowChrome()
+    }
+
+    private func updateWindowChrome() {
+        let color = windowChromeColor
+        for window in NSApp.windows where window.isVisible || window.canBecomeKey {
+            window.backgroundColor = color
+        }
+    }
+}

+ 41 - 0
App for Indeed/Views/DashboardView.swift

@@ -119,6 +119,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
     private let savedJobsDocumentView = JobListingsDocumentView()
     private let savedJobsDocumentView = JobListingsDocumentView()
     private let savedJobsStack = NSStackView()
     private let savedJobsStack = NSStackView()
     private let settingsPageContainer = NSView()
     private let settingsPageContainer = NSView()
+    private weak var appearanceModeSegment: NSSegmentedControl?
     private let cvMakerPageContainer = NSView()
     private let cvMakerPageContainer = NSView()
     private lazy var cvMakerPageView: CVMakerPageView = {
     private lazy var cvMakerPageView: CVMakerPageView = {
         CVMakerPageView()
         CVMakerPageView()
@@ -1557,6 +1558,23 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         contentStack.alignment = .leading
         contentStack.alignment = .leading
         contentStack.translatesAutoresizingMaskIntoConstraints = false
         contentStack.translatesAutoresizingMaskIntoConstraints = false
 
 
+        let appearanceTitle = NSTextField(labelWithString: "Appearance")
+        appearanceTitle.font = .systemFont(ofSize: 12, weight: .semibold)
+        appearanceTitle.textColor = Theme.secondaryText
+        appearanceTitle.alignment = .left
+
+        let themeSegment = makeAppearanceModeSegment()
+        appearanceModeSegment = themeSegment
+        let appearanceSection = makeSettingsSection(rows: [
+            makeSettingsRow(title: "Theme", systemImage: "circle.lefthalf.filled", accessory: themeSegment, tapAction: nil)
+        ])
+
+        let appearanceStack = NSStackView(views: [appearanceTitle, appearanceSection])
+        appearanceStack.orientation = .vertical
+        appearanceStack.spacing = 14
+        appearanceStack.alignment = .leading
+        appearanceStack.translatesAutoresizingMaskIntoConstraints = false
+
         let settingsSection = makeSettingsSection(rows: [
         let settingsSection = makeSettingsSection(rows: [
             makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
             makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
             makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps))
             makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps))
@@ -1580,6 +1598,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         aboutStack.alignment = .leading
         aboutStack.alignment = .leading
         aboutStack.translatesAutoresizingMaskIntoConstraints = false
         aboutStack.translatesAutoresizingMaskIntoConstraints = false
 
 
+        contentStack.addArrangedSubview(appearanceStack)
         contentStack.addArrangedSubview(settingsSection)
         contentStack.addArrangedSubview(settingsSection)
         contentStack.addArrangedSubview(aboutStack)
         contentStack.addArrangedSubview(aboutStack)
         settingsPageContainer.addSubview(contentStack)
         settingsPageContainer.addSubview(contentStack)
@@ -1588,6 +1607,8 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
             contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
             contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
             contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
             contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
             contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48),
             contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48),
+            appearanceStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
+            appearanceSection.widthAnchor.constraint(equalTo: appearanceStack.widthAnchor),
             settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
             settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
             aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
             aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
             aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor),
             aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor),
@@ -1595,6 +1616,26 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         ])
         ])
     }
     }
 
 
+    private func makeAppearanceModeSegment() -> NSSegmentedControl {
+        let segment = NSSegmentedControl(
+            labels: ["System", "Light", "Dark"],
+            trackingMode: .selectOne,
+            target: self,
+            action: #selector(appearanceModeChanged(_:))
+        )
+        segment.translatesAutoresizingMaskIntoConstraints = false
+        segment.segmentStyle = .automatic
+        segment.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex
+        segment.setContentHuggingPriority(.required, for: .horizontal)
+        segment.setContentCompressionResistancePriority(.required, for: .horizontal)
+        return segment
+    }
+
+    @objc private func appearanceModeChanged(_ sender: NSSegmentedControl) {
+        guard let mode = AppAppearanceManager.Mode(segmentIndex: sender.selectedSegment) else { return }
+        AppAppearanceManager.shared.mode = mode
+    }
+
     private func makeSettingsSection(rows: [NSView]) -> NSView {
     private func makeSettingsSection(rows: [NSView]) -> NSView {
         let section = NSStackView()
         let section = NSStackView()
         section.orientation = .vertical
         section.orientation = .vertical