Bladeren bron

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 5 uur geleden
bovenliggende
commit
69f07d5046

+ 16 - 0
smart_printer/AppDelegate.swift

@@ -13,9 +13,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
13 13
     func applicationDidFinishLaunching(_ notification: Notification) {
14 14
         resolveMainWindowController()
15 15
         configureMainWindow()
16
+        configurePreferencesMenu()
16 17
         NSApp.activate(ignoringOtherApps: true)
17 18
     }
18 19
 
20
+    @objc func showSettings(_ sender: Any?) {
21
+        NSApp.activate(ignoringOtherApps: true)
22
+        mainWindow?.makeKeyAndOrderFront(nil)
23
+        NotificationCenter.default.post(name: .showSettings, object: nil)
24
+    }
25
+
26
+    private func configurePreferencesMenu() {
27
+        guard let preferencesItem = NSApp.mainMenu?
28
+            .item(withTitle: "smart_printer")?
29
+            .submenu?
30
+            .item(withTitle: "Preferences…") else { return }
31
+        preferencesItem.target = self
32
+        preferencesItem.action = #selector(showSettings(_:))
33
+    }
34
+
19 35
     func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
20 36
         false
21 37
     }

+ 164 - 0
smart_printer/AppSettings.swift

@@ -0,0 +1,164 @@
1
+import Cocoa
2
+
3
+enum PaperSize: String, CaseIterable {
4
+    case a4 = "A4"
5
+    case letter = "Letter"
6
+    case legal = "Legal"
7
+}
8
+
9
+enum PrintQuality: String, CaseIterable {
10
+    case draft = "Draft"
11
+    case normal = "Normal"
12
+    case best = "Best"
13
+}
14
+
15
+enum ColorMode: String, CaseIterable {
16
+    case color = "Color"
17
+    case grayscale = "Grayscale"
18
+}
19
+
20
+enum ScanResolution: String, CaseIterable {
21
+    case dpi150 = "150 DPI"
22
+    case dpi300 = "300 DPI"
23
+    case dpi600 = "600 DPI"
24
+}
25
+
26
+enum ScanFormat: String, CaseIterable {
27
+    case pdf = "PDF"
28
+    case jpeg = "JPEG"
29
+    case png = "PNG"
30
+}
31
+
32
+enum AppThemePreference: Int, CaseIterable {
33
+    case system
34
+    case light
35
+    case dark
36
+
37
+    var title: String {
38
+        switch self {
39
+        case .system: "System"
40
+        case .light: "Light"
41
+        case .dark: "Dark"
42
+        }
43
+    }
44
+}
45
+
46
+enum AppSettings {
47
+    private static let defaults = UserDefaults.standard
48
+
49
+    private enum Key {
50
+        static let launchAtLogin = "settings.launchAtLogin"
51
+        static let defaultPaperSize = "settings.defaultPaperSize"
52
+        static let printQuality = "settings.printQuality"
53
+        static let colorMode = "settings.colorMode"
54
+        static let duplexEnabled = "settings.duplexEnabled"
55
+        static let scanResolution = "settings.scanResolution"
56
+        static let scanFormat = "settings.scanFormat"
57
+        static let showPrintNotifications = "settings.showPrintNotifications"
58
+        static let saveRecentFiles = "settings.saveRecentFiles"
59
+        static let defaultPrinterName = "settings.defaultPrinterName"
60
+        static let appTheme = "settings.appTheme"
61
+        static let appLanguage = "settings.appLanguage"
62
+    }
63
+
64
+    static var launchAtLogin: Bool {
65
+        get { defaults.bool(forKey: Key.launchAtLogin) }
66
+        set { defaults.set(newValue, forKey: Key.launchAtLogin) }
67
+    }
68
+
69
+    static var defaultPaperSize: PaperSize {
70
+        get {
71
+            PaperSize(rawValue: defaults.string(forKey: Key.defaultPaperSize) ?? "") ?? .a4
72
+        }
73
+        set { defaults.set(newValue.rawValue, forKey: Key.defaultPaperSize) }
74
+    }
75
+
76
+    static var printQuality: PrintQuality {
77
+        get {
78
+            PrintQuality(rawValue: defaults.string(forKey: Key.printQuality) ?? "") ?? .normal
79
+        }
80
+        set { defaults.set(newValue.rawValue, forKey: Key.printQuality) }
81
+    }
82
+
83
+    static var colorMode: ColorMode {
84
+        get {
85
+            ColorMode(rawValue: defaults.string(forKey: Key.colorMode) ?? "") ?? .color
86
+        }
87
+        set { defaults.set(newValue.rawValue, forKey: Key.colorMode) }
88
+    }
89
+
90
+    static var duplexEnabled: Bool {
91
+        get { defaults.bool(forKey: Key.duplexEnabled) }
92
+        set { defaults.set(newValue, forKey: Key.duplexEnabled) }
93
+    }
94
+
95
+    static var scanResolution: ScanResolution {
96
+        get {
97
+            ScanResolution(rawValue: defaults.string(forKey: Key.scanResolution) ?? "") ?? .dpi300
98
+        }
99
+        set { defaults.set(newValue.rawValue, forKey: Key.scanResolution) }
100
+    }
101
+
102
+    static var scanFormat: ScanFormat {
103
+        get {
104
+            ScanFormat(rawValue: defaults.string(forKey: Key.scanFormat) ?? "") ?? .pdf
105
+        }
106
+        set { defaults.set(newValue.rawValue, forKey: Key.scanFormat) }
107
+    }
108
+
109
+    static var showPrintNotifications: Bool {
110
+        get {
111
+            if defaults.object(forKey: Key.showPrintNotifications) == nil { return true }
112
+            return defaults.bool(forKey: Key.showPrintNotifications)
113
+        }
114
+        set { defaults.set(newValue, forKey: Key.showPrintNotifications) }
115
+    }
116
+
117
+    static var saveRecentFiles: Bool {
118
+        get {
119
+            if defaults.object(forKey: Key.saveRecentFiles) == nil { return true }
120
+            return defaults.bool(forKey: Key.saveRecentFiles)
121
+        }
122
+        set { defaults.set(newValue, forKey: Key.saveRecentFiles) }
123
+    }
124
+
125
+    static var defaultPrinterName: String? {
126
+        get { defaults.string(forKey: Key.defaultPrinterName) }
127
+        set {
128
+            if let newValue {
129
+                defaults.set(newValue, forKey: Key.defaultPrinterName)
130
+            } else {
131
+                defaults.removeObject(forKey: Key.defaultPrinterName)
132
+            }
133
+        }
134
+    }
135
+
136
+    static var availablePrinters: [String] {
137
+        let printInfo = NSPrintInfo.shared
138
+        let printerNames = NSPrinter.printerNames
139
+        if printerNames.isEmpty {
140
+            return [printInfo.printer.name]
141
+        }
142
+        return printerNames
143
+    }
144
+
145
+    static var effectiveDefaultPrinter: String {
146
+        if let saved = defaultPrinterName, availablePrinters.contains(saved) {
147
+            return saved
148
+        }
149
+        return NSPrintInfo.shared.printer.name
150
+    }
151
+
152
+    static var appTheme: AppThemePreference {
153
+        get {
154
+            let raw = defaults.integer(forKey: Key.appTheme)
155
+            return AppThemePreference(rawValue: raw) ?? .light
156
+        }
157
+        set { defaults.set(newValue.rawValue, forKey: Key.appTheme) }
158
+    }
159
+
160
+    static var appLanguage: String {
161
+        get { defaults.string(forKey: Key.appLanguage) ?? "English" }
162
+        set { defaults.set(newValue, forKey: Key.appLanguage) }
163
+    }
164
+}

+ 393 - 0
smart_printer/SettingsView.swift

@@ -0,0 +1,393 @@
1
+import Cocoa
2
+
3
+// MARK: - Root
4
+
5
+final class SettingsView: NSView {
6
+    init() {
7
+        super.init(frame: .zero)
8
+        wantsLayer = true
9
+        layer?.backgroundColor = AppTheme.background.cgColor
10
+        translatesAutoresizingMaskIntoConstraints = false
11
+        setup()
12
+    }
13
+
14
+    @available(*, unavailable)
15
+    required init?(coder: NSCoder) { nil }
16
+
17
+    private func setup() {
18
+        let scrollView = NSScrollView()
19
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
20
+        scrollView.hasVerticalScroller = true
21
+        scrollView.autohidesScrollers = true
22
+        scrollView.drawsBackground = false
23
+        scrollView.borderType = .noBorder
24
+
25
+        let document = FlippedSettingsDocumentView()
26
+        document.translatesAutoresizingMaskIntoConstraints = false
27
+
28
+        let panel = SettingsPanelView()
29
+        panel.translatesAutoresizingMaskIntoConstraints = false
30
+
31
+        let stack = NSStackView()
32
+        stack.orientation = .vertical
33
+        stack.alignment = .leading
34
+        stack.spacing = 28
35
+        stack.translatesAutoresizingMaskIntoConstraints = false
36
+
37
+        let appSection = makeSection(title: "App", card: makeAppCard())
38
+        let aboutSection = makeSection(title: "About", card: makeAboutCard())
39
+        stack.addArrangedSubview(appSection)
40
+        stack.addArrangedSubview(aboutSection)
41
+
42
+        [appSection, aboutSection].forEach { section in
43
+            section.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
44
+        }
45
+
46
+        panel.addSubview(stack)
47
+        document.addSubview(panel)
48
+        scrollView.documentView = document
49
+        addSubview(scrollView)
50
+
51
+        let guide = scrollView.contentView
52
+        NSLayoutConstraint.activate([
53
+            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
54
+            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
55
+            scrollView.topAnchor.constraint(equalTo: topAnchor),
56
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
57
+
58
+            document.topAnchor.constraint(equalTo: guide.topAnchor),
59
+            document.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
60
+            document.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
61
+            document.widthAnchor.constraint(equalTo: guide.widthAnchor),
62
+
63
+            panel.topAnchor.constraint(equalTo: document.topAnchor, constant: 8),
64
+            panel.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
65
+            panel.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
66
+            panel.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -32),
67
+            panel.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -48),
68
+
69
+            stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
70
+            stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
71
+            stack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 28),
72
+            stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -28),
73
+            stack.widthAnchor.constraint(equalTo: panel.widthAnchor, constant: -56),
74
+        ])
75
+    }
76
+
77
+    private func makeSection(title: String, card: NSView) -> NSView {
78
+        let container = NSStackView()
79
+        container.orientation = .vertical
80
+        container.alignment = .leading
81
+        container.spacing = 12
82
+        container.translatesAutoresizingMaskIntoConstraints = false
83
+
84
+        let label = NSTextField(labelWithString: title)
85
+        label.font = AppTheme.semiboldFont(size: 18)
86
+        label.textColor = AppTheme.textPrimary
87
+        label.translatesAutoresizingMaskIntoConstraints = false
88
+
89
+        container.addArrangedSubview(label)
90
+        container.addArrangedSubview(card)
91
+        card.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
92
+        return container
93
+    }
94
+
95
+    private func makeAppCard() -> NSView {
96
+        let card = SettingsGroupCard()
97
+
98
+        card.addRow(SettingsActionRow(symbolName: "square.and.arrow.up", title: "Share App") {
99
+            guard let url = Bundle.main.bundleURL as URL? else { return }
100
+            let picker = NSSharingServicePicker(items: [url])
101
+            if let view = NSApp.keyWindow?.contentView {
102
+                picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
103
+            }
104
+        })
105
+        card.addRow(SettingsThemeRow())
106
+
107
+        return card
108
+    }
109
+
110
+    private func makeAboutCard() -> NSView {
111
+        let card = SettingsGroupCard()
112
+
113
+        card.addRow(SettingsActionRow(symbolName: "link", title: "Website") {
114
+            NSWorkspace.shared.open(URL(string: "https://example.com")!)
115
+        })
116
+        card.addRow(SettingsActionRow(symbolName: "questionmark.circle", title: "Support") {
117
+            NSWorkspace.shared.open(URL(string: "mailto:support@example.com")!)
118
+        })
119
+        card.addRow(SettingsActionRow(symbolName: "doc.text", title: "Terms of Use") {
120
+            NSWorkspace.shared.open(URL(string: "https://example.com/terms")!)
121
+        })
122
+        card.addRow(SettingsActionRow(symbolName: "shield", title: "Privacy Policy", isLast: true) {
123
+            NSWorkspace.shared.open(URL(string: "https://example.com/privacy")!)
124
+        })
125
+
126
+        return card
127
+    }
128
+}
129
+
130
+// MARK: - Panel
131
+
132
+private final class SettingsPanelView: NSView {
133
+    init() {
134
+        super.init(frame: .zero)
135
+        wantsLayer = true
136
+        layer?.backgroundColor = AppTheme.cardBackground.cgColor
137
+        layer?.cornerRadius = 22
138
+        layer?.borderWidth = 1
139
+        layer?.borderColor = NSColor(calibratedWhite: 0.92, alpha: 1).cgColor
140
+        applyCardShadow()
141
+    }
142
+
143
+    @available(*, unavailable)
144
+    required init?(coder: NSCoder) { nil }
145
+}
146
+
147
+private final class SettingsGroupCard: NSView {
148
+    private let stack = NSStackView()
149
+
150
+    init() {
151
+        super.init(frame: .zero)
152
+        translatesAutoresizingMaskIntoConstraints = false
153
+        wantsLayer = true
154
+        layer?.backgroundColor = NSColor.white.cgColor
155
+        layer?.cornerRadius = 16
156
+        layer?.borderWidth = 1
157
+        layer?.borderColor = NSColor(calibratedWhite: 0.93, alpha: 1).cgColor
158
+
159
+        stack.orientation = .vertical
160
+        stack.spacing = 0
161
+        stack.translatesAutoresizingMaskIntoConstraints = false
162
+        addSubview(stack)
163
+
164
+        NSLayoutConstraint.activate([
165
+            stack.leadingAnchor.constraint(equalTo: leadingAnchor),
166
+            stack.trailingAnchor.constraint(equalTo: trailingAnchor),
167
+            stack.topAnchor.constraint(equalTo: topAnchor),
168
+            stack.bottomAnchor.constraint(equalTo: bottomAnchor),
169
+        ])
170
+    }
171
+
172
+    @available(*, unavailable)
173
+    required init?(coder: NSCoder) { nil }
174
+
175
+    func addRow(_ row: NSView) {
176
+        stack.addArrangedSubview(row)
177
+        row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
178
+    }
179
+}
180
+
181
+// MARK: - Icon
182
+
183
+private final class SettingsIconBadge: NSView {
184
+    init(symbolName: String) {
185
+        super.init(frame: .zero)
186
+        translatesAutoresizingMaskIntoConstraints = false
187
+        wantsLayer = true
188
+        layer?.backgroundColor = AppTheme.blueLight.cgColor
189
+        layer?.cornerRadius = 10
190
+
191
+        let icon = NSImageView()
192
+        icon.translatesAutoresizingMaskIntoConstraints = false
193
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
194
+            let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
195
+            icon.image = image.withSymbolConfiguration(config)
196
+        }
197
+        icon.contentTintColor = AppTheme.blue
198
+        addSubview(icon)
199
+
200
+        NSLayoutConstraint.activate([
201
+            widthAnchor.constraint(equalToConstant: 36),
202
+            heightAnchor.constraint(equalToConstant: 36),
203
+            icon.centerXAnchor.constraint(equalTo: centerXAnchor),
204
+            icon.centerYAnchor.constraint(equalTo: centerYAnchor),
205
+            icon.widthAnchor.constraint(equalToConstant: 18),
206
+            icon.heightAnchor.constraint(equalToConstant: 18),
207
+        ])
208
+    }
209
+
210
+    @available(*, unavailable)
211
+    required init?(coder: NSCoder) { nil }
212
+}
213
+
214
+// MARK: - Rows
215
+
216
+private class SettingsRowBase: NSView {
217
+    init(isLast: Bool) {
218
+        super.init(frame: .zero)
219
+        translatesAutoresizingMaskIntoConstraints = false
220
+        heightAnchor.constraint(equalToConstant: 56).isActive = true
221
+        if !isLast { addDivider() }
222
+    }
223
+
224
+    @available(*, unavailable)
225
+    required init?(coder: NSCoder) { nil }
226
+
227
+    func addDivider() {
228
+        let divider = NSBox()
229
+        divider.boxType = .separator
230
+        divider.translatesAutoresizingMaskIntoConstraints = false
231
+        addSubview(divider)
232
+        NSLayoutConstraint.activate([
233
+            divider.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 60),
234
+            divider.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
235
+            divider.bottomAnchor.constraint(equalTo: bottomAnchor),
236
+            divider.heightAnchor.constraint(equalToConstant: 1),
237
+        ])
238
+    }
239
+
240
+    func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
241
+        let badge = SettingsIconBadge(symbolName: symbolName)
242
+        let titleLabel = NSTextField(labelWithString: title)
243
+        titleLabel.font = AppTheme.mediumFont(size: 15)
244
+        titleLabel.textColor = AppTheme.textPrimary
245
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
246
+        trailing.translatesAutoresizingMaskIntoConstraints = false
247
+
248
+        addSubview(badge)
249
+        addSubview(titleLabel)
250
+        addSubview(trailing)
251
+
252
+        NSLayoutConstraint.activate([
253
+            badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
254
+            badge.centerYAnchor.constraint(equalTo: centerYAnchor),
255
+
256
+            titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
257
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
258
+
259
+            trailing.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
260
+            trailing.centerYAnchor.constraint(equalTo: centerYAnchor),
261
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailing.leadingAnchor, constant: -12),
262
+        ])
263
+
264
+        return titleLabel
265
+    }
266
+}
267
+
268
+private final class SettingsActionRow: SettingsRowBase {
269
+    init(symbolName: String, title: String, isLast: Bool = false, action: @escaping () -> Void) {
270
+        super.init(isLast: isLast)
271
+
272
+        let badge = SettingsIconBadge(symbolName: symbolName)
273
+        let titleLabel = NSTextField(labelWithString: title)
274
+        titleLabel.font = AppTheme.mediumFont(size: 15)
275
+        titleLabel.textColor = AppTheme.textPrimary
276
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
277
+
278
+        addSubview(badge)
279
+        addSubview(titleLabel)
280
+
281
+        NSLayoutConstraint.activate([
282
+            badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
283
+            badge.centerYAnchor.constraint(equalTo: centerYAnchor),
284
+            titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
285
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
286
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
287
+        ])
288
+
289
+        let control = LinkControl(onActivate: action)
290
+        control.translatesAutoresizingMaskIntoConstraints = false
291
+        addSubview(control)
292
+        NSLayoutConstraint.activate([
293
+            control.leadingAnchor.constraint(equalTo: leadingAnchor),
294
+            control.trailingAnchor.constraint(equalTo: trailingAnchor),
295
+            control.topAnchor.constraint(equalTo: topAnchor),
296
+            control.bottomAnchor.constraint(equalTo: bottomAnchor),
297
+        ])
298
+    }
299
+
300
+    @available(*, unavailable)
301
+    required init?(coder: NSCoder) { nil }
302
+}
303
+
304
+private final class SettingsPopupRow: SettingsRowBase {
305
+    private let popupTarget: PopupTarget
306
+
307
+    init(symbolName: String, title: String, options: [String], selection: String, isLast: Bool = false, onChange: @escaping (String) -> Void) {
308
+        popupTarget = PopupTarget(handler: onChange)
309
+        super.init(isLast: isLast)
310
+
311
+        let popup = NSPopUpButton()
312
+        popup.bezelStyle = .rounded
313
+        popup.addItems(withTitles: options)
314
+        popup.selectItem(withTitle: selection)
315
+        popup.font = AppTheme.regularFont(size: 13)
316
+        popup.target = popupTarget
317
+        popup.action = #selector(PopupTarget.changed(_:))
318
+
319
+        _ = install(icon: symbolName, title: title, trailing: popup)
320
+    }
321
+
322
+    @available(*, unavailable)
323
+    required init?(coder: NSCoder) { nil }
324
+}
325
+
326
+private final class SettingsThemeRow: SettingsRowBase {
327
+    private let segmentTarget: SegmentTarget
328
+
329
+    init() {
330
+        segmentTarget = SegmentTarget()
331
+        super.init(isLast: true)
332
+
333
+        let segment = NSSegmentedControl(labels: AppThemePreference.allCases.map(\.title), trackingMode: .selectOne, target: segmentTarget, action: #selector(SegmentTarget.changed(_:)))
334
+        segment.selectedSegment = AppSettings.appTheme.rawValue
335
+        segmentTarget.handler = { index in
336
+            if let theme = AppThemePreference(rawValue: index) {
337
+                AppSettings.appTheme = theme
338
+            }
339
+        }
340
+
341
+        _ = install(icon: "circle.lefthalf.filled", title: "Theme", trailing: segment)
342
+    }
343
+
344
+    @available(*, unavailable)
345
+    required init?(coder: NSCoder) { nil }
346
+}
347
+
348
+// MARK: - Helpers
349
+
350
+private final class LinkControl: NSControl {
351
+    private let onActivate: () -> Void
352
+
353
+    init(onActivate: @escaping () -> Void) {
354
+        self.onActivate = onActivate
355
+        super.init(frame: .zero)
356
+    }
357
+
358
+    @available(*, unavailable)
359
+    required init?(coder: NSCoder) { nil }
360
+
361
+    override func mouseUp(with event: NSEvent) {
362
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
363
+        onActivate()
364
+    }
365
+
366
+    override func resetCursorRects() {
367
+        addCursorRect(bounds, cursor: .pointingHand)
368
+    }
369
+}
370
+
371
+private final class PopupTarget: NSObject {
372
+    private let handler: (String) -> Void
373
+
374
+    init(handler: @escaping (String) -> Void) {
375
+        self.handler = handler
376
+    }
377
+
378
+    @objc func changed(_ sender: NSPopUpButton) {
379
+        handler(sender.titleOfSelectedItem ?? "")
380
+    }
381
+}
382
+
383
+private final class SegmentTarget: NSObject {
384
+    var handler: ((Int) -> Void)?
385
+
386
+    @objc func changed(_ sender: NSSegmentedControl) {
387
+        handler?(sender.selectedSegment)
388
+    }
389
+}
390
+
391
+private final class FlippedSettingsDocumentView: NSView {
392
+    override var isFlipped: Bool { true }
393
+}

+ 21 - 1
smart_printer/SidebarView.swift

@@ -4,6 +4,7 @@ enum SidebarDestination: Int, CaseIterable {
4 4
     case home
5 5
     case scan
6 6
     case scanAndHome
7
+    case settings
7 8
 }
8 9
 
9 10
 final class SidebarView: NSView {
@@ -52,24 +53,38 @@ final class SidebarView: NSView {
52 53
         navStack.alignment = .leading
53 54
         navStack.translatesAutoresizingMaskIntoConstraints = false
54 55
 
56
+        let bottomNavStack = NSStackView()
57
+        bottomNavStack.orientation = .vertical
58
+        bottomNavStack.spacing = 4
59
+        bottomNavStack.alignment = .leading
60
+        bottomNavStack.translatesAutoresizingMaskIntoConstraints = false
61
+
55 62
         let homeItem = SidebarNavItem(title: "Home", symbolName: "house.fill", isSelected: true)
56 63
         let scanItem = SidebarNavItem(title: "Scan", symbolName: "viewfinder")
57 64
         let scanAndHomeItem = SidebarNavItem(title: "Premium", symbolName: "diamond.fill", accent: .premium)
65
+        let settingsItem = SidebarNavItem(title: "Settings", symbolName: "gearshape")
66
+
67
+        let mainNavItems = [homeItem, scanItem, scanAndHomeItem]
68
+        navItems = mainNavItems + [settingsItem]
58 69
 
59
-        navItems = [homeItem, scanItem, scanAndHomeItem]
60 70
         for (index, item) in navItems.enumerated() {
61 71
             item.onClick = { [weak self] in
62 72
                 guard let self, let destination = SidebarDestination(rawValue: index) else { return }
63 73
                 self.select(destination)
64 74
                 self.onDestinationSelected?(destination)
65 75
             }
76
+        }
77
+
78
+        for item in mainNavItems {
66 79
             navStack.addArrangedSubview(item)
67 80
         }
81
+        bottomNavStack.addArrangedSubview(settingsItem)
68 82
 
69 83
         addSubview(logoContainer)
70 84
         logoContainer.addSubview(logoIcon)
71 85
         addSubview(appNameLabel)
72 86
         addSubview(navStack)
87
+        addSubview(bottomNavStack)
73 88
 
74 89
         let divider = NSBox()
75 90
         divider.boxType = .separator
@@ -96,9 +111,14 @@ final class SidebarView: NSView {
96 111
             navStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
97 112
             navStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
98 113
 
114
+            bottomNavStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
115
+            bottomNavStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
116
+            bottomNavStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
117
+
99 118
             homeItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
100 119
             scanItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
101 120
             scanAndHomeItem.widthAnchor.constraint(equalTo: navStack.widthAnchor),
121
+            settingsItem.widthAnchor.constraint(equalTo: bottomNavStack.widthAnchor),
102 122
 
103 123
             divider.trailingAnchor.constraint(equalTo: trailingAnchor),
104 124
             divider.topAnchor.constraint(equalTo: topAnchor),

+ 76 - 29
smart_printer/ViewController.swift

@@ -12,9 +12,15 @@ class ViewController: NSViewController {
12 12
     private var homeContentView: NSView!
13 13
     private var scanContentView: NSView!
14 14
     private var scanAndHomeContentView: NSView!
15
+    private var settingsContentView: NSView!
15 16
     private var contentContainer: NSView!
16 17
     private var paywallOverlay: PaywallOverlayView?
17 18
 
19
+    private var headerView: NSView!
20
+    private var wavePatternView: WavePatternView!
21
+    private var contentTopBelowHeader: NSLayoutConstraint!
22
+    private var contentTopBelowWindow: NSLayoutConstraint!
23
+
18 24
     override func loadView() {
19 25
         let container = NSView(frame: NSRect(x: 0, y: 0, width: AppTheme.windowWidth, height: AppTheme.windowHeight))
20 26
         container.autoresizingMask = [.width, .height]
@@ -26,6 +32,20 @@ class ViewController: NSViewController {
26 32
     override func viewDidLoad() {
27 33
         super.viewDidLoad()
28 34
         setupLayout()
35
+        NotificationCenter.default.addObserver(
36
+            self,
37
+            selector: #selector(showSettingsFromMenu),
38
+            name: .showSettings,
39
+            object: nil
40
+        )
41
+    }
42
+
43
+    deinit {
44
+        NotificationCenter.default.removeObserver(self)
45
+    }
46
+
47
+    @objc private func showSettingsFromMenu() {
48
+        navigateToSettings()
29 49
     }
30 50
 
31 51
     private func setupLayout() {
@@ -36,7 +56,7 @@ class ViewController: NSViewController {
36 56
         mainContentView.wantsLayer = true
37 57
         mainContentView.layer?.backgroundColor = AppTheme.background.cgColor
38 58
 
39
-        let header = makeHeader()
59
+        headerView = makeHeader()
40 60
 
41 61
         contentContainer = NSView()
42 62
         contentContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -44,19 +64,21 @@ class ViewController: NSViewController {
44 64
         homeContentView = makeHomeContentView()
45 65
         scanContentView = makeScanContentView()
46 66
         scanAndHomeContentView = makeScanAndHomeContentView()
67
+        settingsContentView = makeSettingsContentView()
47 68
 
48 69
         contentContainer.addSubview(homeContentView)
49 70
         contentContainer.addSubview(scanContentView)
50 71
         contentContainer.addSubview(scanAndHomeContentView)
72
+        contentContainer.addSubview(settingsContentView)
51 73
 
52
-        let wavePattern = WavePatternView()
53
-        wavePattern.translatesAutoresizingMaskIntoConstraints = false
74
+        wavePatternView = WavePatternView()
75
+        wavePatternView.translatesAutoresizingMaskIntoConstraints = false
54 76
 
55 77
         view.addSubview(sidebar)
56 78
         view.addSubview(mainContentView)
57
-        mainContentView.addSubview(header)
79
+        mainContentView.addSubview(headerView)
58 80
         mainContentView.addSubview(contentContainer)
59
-        mainContentView.addSubview(wavePattern)
81
+        mainContentView.addSubview(wavePatternView)
60 82
 
61 83
         sidebar.onDestinationSelected = { [weak self] destination in
62 84
             self?.showDestination(destination)
@@ -72,25 +94,30 @@ class ViewController: NSViewController {
72 94
             mainContentView.topAnchor.constraint(equalTo: view.topAnchor),
73 95
             mainContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
74 96
 
75
-            header.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
76
-            header.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
77
-            header.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: 16),
78
-            header.heightAnchor.constraint(equalToConstant: 44),
97
+            headerView.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
98
+            headerView.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
99
+            headerView.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: 16),
100
+            headerView.heightAnchor.constraint(equalToConstant: 44),
79 101
 
80 102
             contentContainer.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
81 103
             contentContainer.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
82
-            contentContainer.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 8),
83 104
             contentContainer.bottomAnchor.constraint(equalTo: mainContentView.bottomAnchor),
84 105
 
85
-            wavePattern.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
86
-            wavePattern.bottomAnchor.constraint(equalTo: mainContentView.bottomAnchor),
87
-            wavePattern.widthAnchor.constraint(equalToConstant: 200),
88
-            wavePattern.heightAnchor.constraint(equalToConstant: 140),
106
+            wavePatternView.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
107
+            wavePatternView.bottomAnchor.constraint(equalTo: mainContentView.bottomAnchor),
108
+            wavePatternView.widthAnchor.constraint(equalToConstant: 200),
109
+            wavePatternView.heightAnchor.constraint(equalToConstant: 140),
89 110
         ])
90 111
 
91 112
         pinContentView(homeContentView)
92 113
         pinContentView(scanContentView)
93 114
         pinContentView(scanAndHomeContentView)
115
+        pinContentView(settingsContentView)
116
+
117
+        contentTopBelowHeader = contentContainer.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 8)
118
+        contentTopBelowWindow = contentContainer.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: 12)
119
+        contentTopBelowHeader.isActive = true
120
+
94 121
         showDestination(.home)
95 122
     }
96 123
 
@@ -107,6 +134,13 @@ class ViewController: NSViewController {
107 134
         homeContentView.isHidden = destination != .home
108 135
         scanContentView.isHidden = destination != .scan
109 136
         scanAndHomeContentView.isHidden = destination != .scanAndHome
137
+        settingsContentView.isHidden = destination != .settings
138
+
139
+        let isSettings = destination == .settings
140
+        headerView.isHidden = isSettings
141
+        wavePatternView.isHidden = isSettings
142
+        contentTopBelowHeader.isActive = !isSettings
143
+        contentTopBelowWindow.isActive = isSettings
110 144
 
111 145
         if destination == .scanAndHome {
112 146
             presentPaywall()
@@ -242,31 +276,40 @@ class ViewController: NSViewController {
242 276
         titleLabel.alignment = .center
243 277
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
244 278
 
245
-        let settingsButton = NSButton()
246
-        settingsButton.isBordered = false
247
-        settingsButton.translatesAutoresizingMaskIntoConstraints = false
248
-        if let image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") {
249
-            let config = NSImage.SymbolConfiguration(pointSize: 18, weight: .regular)
250
-            settingsButton.image = image.withSymbolConfiguration(config)
251
-        }
252
-        settingsButton.contentTintColor = AppTheme.textSecondary
253
-
254 279
         header.addSubview(titleLabel)
255
-        header.addSubview(settingsButton)
256 280
 
257 281
         NSLayoutConstraint.activate([
258 282
             titleLabel.centerXAnchor.constraint(equalTo: header.centerXAnchor),
259 283
             titleLabel.centerYAnchor.constraint(equalTo: header.centerYAnchor),
260
-
261
-            settingsButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -28),
262
-            settingsButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
263
-            settingsButton.widthAnchor.constraint(equalToConstant: 32),
264
-            settingsButton.heightAnchor.constraint(equalToConstant: 32),
265 284
         ])
266 285
 
267 286
         return header
268 287
     }
269 288
 
289
+    private func makeSettingsContentView() -> NSView {
290
+        let container = NSView()
291
+        container.translatesAutoresizingMaskIntoConstraints = false
292
+        let settingsView = SettingsView()
293
+        container.addSubview(settingsView)
294
+
295
+        NSLayoutConstraint.activate([
296
+            settingsView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
297
+            settingsView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
298
+            settingsView.topAnchor.constraint(equalTo: container.topAnchor),
299
+            settingsView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
300
+        ])
301
+
302
+        return container
303
+    }
304
+
305
+    private func navigateToSettings() {
306
+        if paywallOverlay != nil {
307
+            dismissPaywall()
308
+        }
309
+        sidebar.select(.settings)
310
+        showDestination(.settings)
311
+    }
312
+
270 313
     private func makeScrollView() -> NSScrollView {
271 314
         let scrollView = NSScrollView()
272 315
         scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -498,3 +541,7 @@ class ViewController: NSViewController {
498 541
 private final class FlippedDocumentView: NSView {
499 542
     override var isFlipped: Bool { true }
500 543
 }
544
+
545
+extension Notification.Name {
546
+    static let showSettings = Notification.Name("showSettings")
547
+}