Răsfoiți Sursa

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 2 luni în urmă
părinte
comite
ce75d79d9c

+ 3 - 4
App for Indeed/AppDelegate.swift

@@ -20,8 +20,8 @@ enum AppWindowConfiguration {
         window.titlebarAppearsTransparent = true
         window.titleVisibility = .hidden
         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 targetFrame = window.frameRect(forContentRect: targetContent)
@@ -48,8 +48,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     private var lastSubscriptionRefreshAt: Date?
 
     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) {

+ 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 savedJobsStack = NSStackView()
     private let settingsPageContainer = NSView()
+    private weak var appearanceModeSegment: NSSegmentedControl?
     private let cvMakerPageContainer = NSView()
     private lazy var cvMakerPageView: CVMakerPageView = {
         CVMakerPageView()
@@ -1557,6 +1558,23 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         contentStack.alignment = .leading
         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: [
             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))
@@ -1580,6 +1598,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         aboutStack.alignment = .leading
         aboutStack.translatesAutoresizingMaskIntoConstraints = false
 
+        contentStack.addArrangedSubview(appearanceStack)
         contentStack.addArrangedSubview(settingsSection)
         contentStack.addArrangedSubview(aboutStack)
         settingsPageContainer.addSubview(contentStack)
@@ -1588,6 +1607,8 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
             contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
             contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
             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),
             aboutStack.widthAnchor.constraint(equalTo: contentStack.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 {
         let section = NSStackView()
         section.orientation = .vertical