فهرست منبع

Add dedicated settings screen with sidebar and menu navigation.

Replace the header gear button with a full settings view focused on share, theme, and about links.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 ماه پیش
والد
کامیت
69f07d5046

+ 16 - 0
smart_printer/AppDelegate.swift

@@ -13,9 +13,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     func applicationDidFinishLaunching(_ notification: Notification) {
         resolveMainWindowController()
         configureMainWindow()
+        configurePreferencesMenu()
         NSApp.activate(ignoringOtherApps: true)
     }
 
+    @objc func showSettings(_ sender: Any?) {
+        NSApp.activate(ignoringOtherApps: true)
+        mainWindow?.makeKeyAndOrderFront(nil)
+        NotificationCenter.default.post(name: .showSettings, object: nil)
+    }
+
+    private func configurePreferencesMenu() {
+        guard let preferencesItem = NSApp.mainMenu?
+            .item(withTitle: "smart_printer")?
+            .submenu?
+            .item(withTitle: "Preferences…") else { return }
+        preferencesItem.target = self
+        preferencesItem.action = #selector(showSettings(_:))
+    }
+
     func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
         false
     }

+ 164 - 0
smart_printer/AppSettings.swift

@@ -0,0 +1,164 @@
+import Cocoa
+
+enum PaperSize: String, CaseIterable {
+    case a4 = "A4"
+    case letter = "Letter"
+    case legal = "Legal"
+}
+
+enum PrintQuality: String, CaseIterable {
+    case draft = "Draft"
+    case normal = "Normal"
+    case best = "Best"
+}
+
+enum ColorMode: String, CaseIterable {
+    case color = "Color"
+    case grayscale = "Grayscale"
+}
+
+enum ScanResolution: String, CaseIterable {
+    case dpi150 = "150 DPI"
+    case dpi300 = "300 DPI"
+    case dpi600 = "600 DPI"
+}
+
+enum ScanFormat: String, CaseIterable {
+    case pdf = "PDF"
+    case jpeg = "JPEG"
+    case png = "PNG"
+}
+
+enum AppThemePreference: Int, CaseIterable {
+    case system
+    case light
+    case dark
+
+    var title: String {
+        switch self {
+        case .system: "System"
+        case .light: "Light"
+        case .dark: "Dark"
+        }
+    }
+}
+
+enum AppSettings {
+    private static let defaults = UserDefaults.standard
+
+    private enum Key {
+        static let launchAtLogin = "settings.launchAtLogin"
+        static let defaultPaperSize = "settings.defaultPaperSize"
+        static let printQuality = "settings.printQuality"
+        static let colorMode = "settings.colorMode"
+        static let duplexEnabled = "settings.duplexEnabled"
+        static let scanResolution = "settings.scanResolution"
+        static let scanFormat = "settings.scanFormat"
+        static let showPrintNotifications = "settings.showPrintNotifications"
+        static let saveRecentFiles = "settings.saveRecentFiles"
+        static let defaultPrinterName = "settings.defaultPrinterName"
+        static let appTheme = "settings.appTheme"
+        static let appLanguage = "settings.appLanguage"
+    }
+
+    static var launchAtLogin: Bool {
+        get { defaults.bool(forKey: Key.launchAtLogin) }
+        set { defaults.set(newValue, forKey: Key.launchAtLogin) }
+    }
+
+    static var defaultPaperSize: PaperSize {
+        get {
+            PaperSize(rawValue: defaults.string(forKey: Key.defaultPaperSize) ?? "") ?? .a4
+        }
+        set { defaults.set(newValue.rawValue, forKey: Key.defaultPaperSize) }
+    }
+
+    static var printQuality: PrintQuality {
+        get {
+            PrintQuality(rawValue: defaults.string(forKey: Key.printQuality) ?? "") ?? .normal
+        }
+        set { defaults.set(newValue.rawValue, forKey: Key.printQuality) }
+    }
+
+    static var colorMode: ColorMode {
+        get {
+            ColorMode(rawValue: defaults.string(forKey: Key.colorMode) ?? "") ?? .color
+        }
+        set { defaults.set(newValue.rawValue, forKey: Key.colorMode) }
+    }
+
+    static var duplexEnabled: Bool {
+        get { defaults.bool(forKey: Key.duplexEnabled) }
+        set { defaults.set(newValue, forKey: Key.duplexEnabled) }
+    }
+
+    static var scanResolution: ScanResolution {
+        get {
+            ScanResolution(rawValue: defaults.string(forKey: Key.scanResolution) ?? "") ?? .dpi300
+        }
+        set { defaults.set(newValue.rawValue, forKey: Key.scanResolution) }
+    }
+
+    static var scanFormat: ScanFormat {
+        get {
+            ScanFormat(rawValue: defaults.string(forKey: Key.scanFormat) ?? "") ?? .pdf
+        }
+        set { defaults.set(newValue.rawValue, forKey: Key.scanFormat) }
+    }
+
+    static var showPrintNotifications: Bool {
+        get {
+            if defaults.object(forKey: Key.showPrintNotifications) == nil { return true }
+            return defaults.bool(forKey: Key.showPrintNotifications)
+        }
+        set { defaults.set(newValue, forKey: Key.showPrintNotifications) }
+    }
+
+    static var saveRecentFiles: Bool {
+        get {
+            if defaults.object(forKey: Key.saveRecentFiles) == nil { return true }
+            return defaults.bool(forKey: Key.saveRecentFiles)
+        }
+        set { defaults.set(newValue, forKey: Key.saveRecentFiles) }
+    }
+
+    static var defaultPrinterName: String? {
+        get { defaults.string(forKey: Key.defaultPrinterName) }
+        set {
+            if let newValue {
+                defaults.set(newValue, forKey: Key.defaultPrinterName)
+            } else {
+                defaults.removeObject(forKey: Key.defaultPrinterName)
+            }
+        }
+    }
+
+    static var availablePrinters: [String] {
+        let printInfo = NSPrintInfo.shared
+        let printerNames = NSPrinter.printerNames
+        if printerNames.isEmpty {
+            return [printInfo.printer.name]
+        }
+        return printerNames
+    }
+
+    static var effectiveDefaultPrinter: String {
+        if let saved = defaultPrinterName, availablePrinters.contains(saved) {
+            return saved
+        }
+        return NSPrintInfo.shared.printer.name
+    }
+
+    static var appTheme: AppThemePreference {
+        get {
+            let raw = defaults.integer(forKey: Key.appTheme)
+            return AppThemePreference(rawValue: raw) ?? .light
+        }
+        set { defaults.set(newValue.rawValue, forKey: Key.appTheme) }
+    }
+
+    static var appLanguage: String {
+        get { defaults.string(forKey: Key.appLanguage) ?? "English" }
+        set { defaults.set(newValue, forKey: Key.appLanguage) }
+    }
+}

+ 393 - 0
smart_printer/SettingsView.swift

@@ -0,0 +1,393 @@
+import Cocoa
+
+// MARK: - Root
+
+final class SettingsView: NSView {
+    init() {
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.backgroundColor = AppTheme.background.cgColor
+        translatesAutoresizingMaskIntoConstraints = false
+        setup()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    private func setup() {
+        let scrollView = NSScrollView()
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
+        scrollView.hasVerticalScroller = true
+        scrollView.autohidesScrollers = true
+        scrollView.drawsBackground = false
+        scrollView.borderType = .noBorder
+
+        let document = FlippedSettingsDocumentView()
+        document.translatesAutoresizingMaskIntoConstraints = false
+
+        let panel = SettingsPanelView()
+        panel.translatesAutoresizingMaskIntoConstraints = false
+
+        let stack = NSStackView()
+        stack.orientation = .vertical
+        stack.alignment = .leading
+        stack.spacing = 28
+        stack.translatesAutoresizingMaskIntoConstraints = false
+
+        let appSection = makeSection(title: "App", card: makeAppCard())
+        let aboutSection = makeSection(title: "About", card: makeAboutCard())
+        stack.addArrangedSubview(appSection)
+        stack.addArrangedSubview(aboutSection)
+
+        [appSection, aboutSection].forEach { section in
+            section.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        }
+
+        panel.addSubview(stack)
+        document.addSubview(panel)
+        scrollView.documentView = document
+        addSubview(scrollView)
+
+        let guide = scrollView.contentView
+        NSLayoutConstraint.activate([
+            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            scrollView.topAnchor.constraint(equalTo: topAnchor),
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            document.topAnchor.constraint(equalTo: guide.topAnchor),
+            document.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
+            document.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
+            document.widthAnchor.constraint(equalTo: guide.widthAnchor),
+
+            panel.topAnchor.constraint(equalTo: document.topAnchor, constant: 8),
+            panel.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
+            panel.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
+            panel.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -32),
+            panel.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -48),
+
+            stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
+            stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
+            stack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 28),
+            stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -28),
+            stack.widthAnchor.constraint(equalTo: panel.widthAnchor, constant: -56),
+        ])
+    }
+
+    private func makeSection(title: String, card: NSView) -> NSView {
+        let container = NSStackView()
+        container.orientation = .vertical
+        container.alignment = .leading
+        container.spacing = 12
+        container.translatesAutoresizingMaskIntoConstraints = false
+
+        let label = NSTextField(labelWithString: title)
+        label.font = AppTheme.semiboldFont(size: 18)
+        label.textColor = AppTheme.textPrimary
+        label.translatesAutoresizingMaskIntoConstraints = false
+
+        container.addArrangedSubview(label)
+        container.addArrangedSubview(card)
+        card.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
+        return container
+    }
+
+    private func makeAppCard() -> NSView {
+        let card = SettingsGroupCard()
+
+        card.addRow(SettingsActionRow(symbolName: "square.and.arrow.up", title: "Share App") {
+            guard let url = Bundle.main.bundleURL as URL? else { return }
+            let picker = NSSharingServicePicker(items: [url])
+            if let view = NSApp.keyWindow?.contentView {
+                picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
+            }
+        })
+        card.addRow(SettingsThemeRow())
+
+        return card
+    }
+
+    private func makeAboutCard() -> NSView {
+        let card = SettingsGroupCard()
+
+        card.addRow(SettingsActionRow(symbolName: "link", title: "Website") {
+            NSWorkspace.shared.open(URL(string: "https://example.com")!)
+        })
+        card.addRow(SettingsActionRow(symbolName: "questionmark.circle", title: "Support") {
+            NSWorkspace.shared.open(URL(string: "mailto:support@example.com")!)
+        })
+        card.addRow(SettingsActionRow(symbolName: "doc.text", title: "Terms of Use") {
+            NSWorkspace.shared.open(URL(string: "https://example.com/terms")!)
+        })
+        card.addRow(SettingsActionRow(symbolName: "shield", title: "Privacy Policy", isLast: true) {
+            NSWorkspace.shared.open(URL(string: "https://example.com/privacy")!)
+        })
+
+        return card
+    }
+}
+
+// MARK: - Panel
+
+private final class SettingsPanelView: NSView {
+    init() {
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        layer?.cornerRadius = 22
+        layer?.borderWidth = 1
+        layer?.borderColor = NSColor(calibratedWhite: 0.92, alpha: 1).cgColor
+        applyCardShadow()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+}
+
+private final class SettingsGroupCard: NSView {
+    private let stack = NSStackView()
+
+    init() {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.backgroundColor = NSColor.white.cgColor
+        layer?.cornerRadius = 16
+        layer?.borderWidth = 1
+        layer?.borderColor = NSColor(calibratedWhite: 0.93, alpha: 1).cgColor
+
+        stack.orientation = .vertical
+        stack.spacing = 0
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        addSubview(stack)
+
+        NSLayoutConstraint.activate([
+            stack.leadingAnchor.constraint(equalTo: leadingAnchor),
+            stack.trailingAnchor.constraint(equalTo: trailingAnchor),
+            stack.topAnchor.constraint(equalTo: topAnchor),
+            stack.bottomAnchor.constraint(equalTo: bottomAnchor),
+        ])
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func addRow(_ row: NSView) {
+        stack.addArrangedSubview(row)
+        row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+    }
+}
+
+// MARK: - Icon
+
+private final class SettingsIconBadge: NSView {
+    init(symbolName: String) {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.backgroundColor = AppTheme.blueLight.cgColor
+        layer?.cornerRadius = 10
+
+        let icon = NSImageView()
+        icon.translatesAutoresizingMaskIntoConstraints = false
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
+            let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
+            icon.image = image.withSymbolConfiguration(config)
+        }
+        icon.contentTintColor = AppTheme.blue
+        addSubview(icon)
+
+        NSLayoutConstraint.activate([
+            widthAnchor.constraint(equalToConstant: 36),
+            heightAnchor.constraint(equalToConstant: 36),
+            icon.centerXAnchor.constraint(equalTo: centerXAnchor),
+            icon.centerYAnchor.constraint(equalTo: centerYAnchor),
+            icon.widthAnchor.constraint(equalToConstant: 18),
+            icon.heightAnchor.constraint(equalToConstant: 18),
+        ])
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+}
+
+// MARK: - Rows
+
+private class SettingsRowBase: NSView {
+    init(isLast: Bool) {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        heightAnchor.constraint(equalToConstant: 56).isActive = true
+        if !isLast { addDivider() }
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func addDivider() {
+        let divider = NSBox()
+        divider.boxType = .separator
+        divider.translatesAutoresizingMaskIntoConstraints = false
+        addSubview(divider)
+        NSLayoutConstraint.activate([
+            divider.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 60),
+            divider.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
+            divider.bottomAnchor.constraint(equalTo: bottomAnchor),
+            divider.heightAnchor.constraint(equalToConstant: 1),
+        ])
+    }
+
+    func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
+        let badge = SettingsIconBadge(symbolName: symbolName)
+        let titleLabel = NSTextField(labelWithString: title)
+        titleLabel.font = AppTheme.mediumFont(size: 15)
+        titleLabel.textColor = AppTheme.textPrimary
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+        trailing.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(badge)
+        addSubview(titleLabel)
+        addSubview(trailing)
+
+        NSLayoutConstraint.activate([
+            badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
+            badge.centerYAnchor.constraint(equalTo: centerYAnchor),
+
+            titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+
+            trailing.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
+            trailing.centerYAnchor.constraint(equalTo: centerYAnchor),
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailing.leadingAnchor, constant: -12),
+        ])
+
+        return titleLabel
+    }
+}
+
+private final class SettingsActionRow: SettingsRowBase {
+    init(symbolName: String, title: String, isLast: Bool = false, action: @escaping () -> Void) {
+        super.init(isLast: isLast)
+
+        let badge = SettingsIconBadge(symbolName: symbolName)
+        let titleLabel = NSTextField(labelWithString: title)
+        titleLabel.font = AppTheme.mediumFont(size: 15)
+        titleLabel.textColor = AppTheme.textPrimary
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(badge)
+        addSubview(titleLabel)
+
+        NSLayoutConstraint.activate([
+            badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
+            badge.centerYAnchor.constraint(equalTo: centerYAnchor),
+            titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
+        ])
+
+        let control = LinkControl(onActivate: action)
+        control.translatesAutoresizingMaskIntoConstraints = false
+        addSubview(control)
+        NSLayoutConstraint.activate([
+            control.leadingAnchor.constraint(equalTo: leadingAnchor),
+            control.trailingAnchor.constraint(equalTo: trailingAnchor),
+            control.topAnchor.constraint(equalTo: topAnchor),
+            control.bottomAnchor.constraint(equalTo: bottomAnchor),
+        ])
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+}
+
+private final class SettingsPopupRow: SettingsRowBase {
+    private let popupTarget: PopupTarget
+
+    init(symbolName: String, title: String, options: [String], selection: String, isLast: Bool = false, onChange: @escaping (String) -> Void) {
+        popupTarget = PopupTarget(handler: onChange)
+        super.init(isLast: isLast)
+
+        let popup = NSPopUpButton()
+        popup.bezelStyle = .rounded
+        popup.addItems(withTitles: options)
+        popup.selectItem(withTitle: selection)
+        popup.font = AppTheme.regularFont(size: 13)
+        popup.target = popupTarget
+        popup.action = #selector(PopupTarget.changed(_:))
+
+        _ = install(icon: symbolName, title: title, trailing: popup)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+}
+
+private final class SettingsThemeRow: SettingsRowBase {
+    private let segmentTarget: SegmentTarget
+
+    init() {
+        segmentTarget = SegmentTarget()
+        super.init(isLast: true)
+
+        let segment = NSSegmentedControl(labels: AppThemePreference.allCases.map(\.title), trackingMode: .selectOne, target: segmentTarget, action: #selector(SegmentTarget.changed(_:)))
+        segment.selectedSegment = AppSettings.appTheme.rawValue
+        segmentTarget.handler = { index in
+            if let theme = AppThemePreference(rawValue: index) {
+                AppSettings.appTheme = theme
+            }
+        }
+
+        _ = install(icon: "circle.lefthalf.filled", title: "Theme", trailing: segment)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+}
+
+// MARK: - Helpers
+
+private final class LinkControl: NSControl {
+    private let onActivate: () -> Void
+
+    init(onActivate: @escaping () -> Void) {
+        self.onActivate = onActivate
+        super.init(frame: .zero)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    override func mouseUp(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onActivate()
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
+private final class PopupTarget: NSObject {
+    private let handler: (String) -> Void
+
+    init(handler: @escaping (String) -> Void) {
+        self.handler = handler
+    }
+
+    @objc func changed(_ sender: NSPopUpButton) {
+        handler(sender.titleOfSelectedItem ?? "")
+    }
+}
+
+private final class SegmentTarget: NSObject {
+    var handler: ((Int) -> Void)?
+
+    @objc func changed(_ sender: NSSegmentedControl) {
+        handler?(sender.selectedSegment)
+    }
+}
+
+private final class FlippedSettingsDocumentView: NSView {
+    override var isFlipped: Bool { true }
+}

+ 21 - 1
smart_printer/SidebarView.swift

@@ -4,6 +4,7 @@ enum SidebarDestination: Int, CaseIterable {
     case home
     case scan
     case scanAndHome
+    case settings
 }
 
 final class SidebarView: NSView {
@@ -52,24 +53,38 @@ final class SidebarView: NSView {
         navStack.alignment = .leading
         navStack.translatesAutoresizingMaskIntoConstraints = false
 
+        let bottomNavStack = NSStackView()
+        bottomNavStack.orientation = .vertical
+        bottomNavStack.spacing = 4
+        bottomNavStack.alignment = .leading
+        bottomNavStack.translatesAutoresizingMaskIntoConstraints = false
+
         let homeItem = SidebarNavItem(title: "Home", symbolName: "house.fill", isSelected: true)
         let scanItem = SidebarNavItem(title: "Scan", symbolName: "viewfinder")
         let scanAndHomeItem = SidebarNavItem(title: "Premium", symbolName: "diamond.fill", accent: .premium)
+        let settingsItem = SidebarNavItem(title: "Settings", symbolName: "gearshape")
+
+        let mainNavItems = [homeItem, scanItem, scanAndHomeItem]
+        navItems = mainNavItems + [settingsItem]
 
-        navItems = [homeItem, scanItem, scanAndHomeItem]
         for (index, item) in navItems.enumerated() {
             item.onClick = { [weak self] in
                 guard let self, let destination = SidebarDestination(rawValue: index) else { return }
                 self.select(destination)
                 self.onDestinationSelected?(destination)
             }
+        }
+
+        for item in mainNavItems {
             navStack.addArrangedSubview(item)
         }
+        bottomNavStack.addArrangedSubview(settingsItem)
 
         addSubview(logoContainer)
         logoContainer.addSubview(logoIcon)
         addSubview(appNameLabel)
         addSubview(navStack)
+        addSubview(bottomNavStack)
 
         let divider = NSBox()
         divider.boxType = .separator
@@ -96,9 +111,14 @@ final class SidebarView: NSView {
             navStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
             navStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
 
+            bottomNavStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
+            bottomNavStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
+            bottomNavStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
+
             homeItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
             scanItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
             scanAndHomeItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
+            settingsItem.widthAnchor.constraint(equalTo: bottomNavStack.widthAnchor),
 
             divider.trailingAnchor.constraint(equalTo: trailingAnchor),
             divider.topAnchor.constraint(equalTo: topAnchor),

+ 76 - 29
smart_printer/ViewController.swift

@@ -12,9 +12,15 @@ class ViewController: NSViewController {
     private var homeContentView: NSView!
     private var scanContentView: NSView!
     private var scanAndHomeContentView: NSView!
+    private var settingsContentView: NSView!
     private var contentContainer: NSView!
     private var paywallOverlay: PaywallOverlayView?
 
+    private var headerView: NSView!
+    private var wavePatternView: WavePatternView!
+    private var contentTopBelowHeader: NSLayoutConstraint!
+    private var contentTopBelowWindow: NSLayoutConstraint!
+
     override func loadView() {
         let container = NSView(frame: NSRect(x: 0, y: 0, width: AppTheme.windowWidth, height: AppTheme.windowHeight))
         container.autoresizingMask = [.width, .height]
@@ -26,6 +32,20 @@ class ViewController: NSViewController {
     override func viewDidLoad() {
         super.viewDidLoad()
         setupLayout()
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(showSettingsFromMenu),
+            name: .showSettings,
+            object: nil
+        )
+    }
+
+    deinit {
+        NotificationCenter.default.removeObserver(self)
+    }
+
+    @objc private func showSettingsFromMenu() {
+        navigateToSettings()
     }
 
     private func setupLayout() {
@@ -36,7 +56,7 @@ class ViewController: NSViewController {
         mainContentView.wantsLayer = true
         mainContentView.layer?.backgroundColor = AppTheme.background.cgColor
 
-        let header = makeHeader()
+        headerView = makeHeader()
 
         contentContainer = NSView()
         contentContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -44,19 +64,21 @@ class ViewController: NSViewController {
         homeContentView = makeHomeContentView()
         scanContentView = makeScanContentView()
         scanAndHomeContentView = makeScanAndHomeContentView()
+        settingsContentView = makeSettingsContentView()
 
         contentContainer.addSubview(homeContentView)
         contentContainer.addSubview(scanContentView)
         contentContainer.addSubview(scanAndHomeContentView)
+        contentContainer.addSubview(settingsContentView)
 
-        let wavePattern = WavePatternView()
-        wavePattern.translatesAutoresizingMaskIntoConstraints = false
+        wavePatternView = WavePatternView()
+        wavePatternView.translatesAutoresizingMaskIntoConstraints = false
 
         view.addSubview(sidebar)
         view.addSubview(mainContentView)
-        mainContentView.addSubview(header)
+        mainContentView.addSubview(headerView)
         mainContentView.addSubview(contentContainer)
-        mainContentView.addSubview(wavePattern)
+        mainContentView.addSubview(wavePatternView)
 
         sidebar.onDestinationSelected = { [weak self] destination in
             self?.showDestination(destination)
@@ -72,25 +94,30 @@ class ViewController: NSViewController {
             mainContentView.topAnchor.constraint(equalTo: view.topAnchor),
             mainContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
 
-            header.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
-            header.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
-            header.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: 16),
-            header.heightAnchor.constraint(equalToConstant: 44),
+            headerView.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
+            headerView.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
+            headerView.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: 16),
+            headerView.heightAnchor.constraint(equalToConstant: 44),
 
             contentContainer.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
             contentContainer.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
-            contentContainer.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 8),
             contentContainer.bottomAnchor.constraint(equalTo: mainContentView.bottomAnchor),
 
-            wavePattern.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
-            wavePattern.bottomAnchor.constraint(equalTo: mainContentView.bottomAnchor),
-            wavePattern.widthAnchor.constraint(equalToConstant: 200),
-            wavePattern.heightAnchor.constraint(equalToConstant: 140),
+            wavePatternView.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
+            wavePatternView.bottomAnchor.constraint(equalTo: mainContentView.bottomAnchor),
+            wavePatternView.widthAnchor.constraint(equalToConstant: 200),
+            wavePatternView.heightAnchor.constraint(equalToConstant: 140),
         ])
 
         pinContentView(homeContentView)
         pinContentView(scanContentView)
         pinContentView(scanAndHomeContentView)
+        pinContentView(settingsContentView)
+
+        contentTopBelowHeader = contentContainer.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 8)
+        contentTopBelowWindow = contentContainer.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: 12)
+        contentTopBelowHeader.isActive = true
+
         showDestination(.home)
     }
 
@@ -107,6 +134,13 @@ class ViewController: NSViewController {
         homeContentView.isHidden = destination != .home
         scanContentView.isHidden = destination != .scan
         scanAndHomeContentView.isHidden = destination != .scanAndHome
+        settingsContentView.isHidden = destination != .settings
+
+        let isSettings = destination == .settings
+        headerView.isHidden = isSettings
+        wavePatternView.isHidden = isSettings
+        contentTopBelowHeader.isActive = !isSettings
+        contentTopBelowWindow.isActive = isSettings
 
         if destination == .scanAndHome {
             presentPaywall()
@@ -242,31 +276,40 @@ class ViewController: NSViewController {
         titleLabel.alignment = .center
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        let settingsButton = NSButton()
-        settingsButton.isBordered = false
-        settingsButton.translatesAutoresizingMaskIntoConstraints = false
-        if let image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") {
-            let config = NSImage.SymbolConfiguration(pointSize: 18, weight: .regular)
-            settingsButton.image = image.withSymbolConfiguration(config)
-        }
-        settingsButton.contentTintColor = AppTheme.textSecondary
-
         header.addSubview(titleLabel)
-        header.addSubview(settingsButton)
 
         NSLayoutConstraint.activate([
             titleLabel.centerXAnchor.constraint(equalTo: header.centerXAnchor),
             titleLabel.centerYAnchor.constraint(equalTo: header.centerYAnchor),
-
-            settingsButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -28),
-            settingsButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
-            settingsButton.widthAnchor.constraint(equalToConstant: 32),
-            settingsButton.heightAnchor.constraint(equalToConstant: 32),
         ])
 
         return header
     }
 
+    private func makeSettingsContentView() -> NSView {
+        let container = NSView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        let settingsView = SettingsView()
+        container.addSubview(settingsView)
+
+        NSLayoutConstraint.activate([
+            settingsView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            settingsView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            settingsView.topAnchor.constraint(equalTo: container.topAnchor),
+            settingsView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+        ])
+
+        return container
+    }
+
+    private func navigateToSettings() {
+        if paywallOverlay != nil {
+            dismissPaywall()
+        }
+        sidebar.select(.settings)
+        showDestination(.settings)
+    }
+
     private func makeScrollView() -> NSScrollView {
         let scrollView = NSScrollView()
         scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -498,3 +541,7 @@ class ViewController: NSViewController {
 private final class FlippedDocumentView: NSView {
     override var isFlipped: Bool { true }
 }
+
+extension Notification.Name {
+    static let showSettings = Notification.Name("showSettings")
+}