Sfoglia il codice sorgente

Add photo preview screen with back, edit, and print actions.

Show selected photos in the main content area before printing so users can review, edit in Preview, or go back without changing the app window size.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 ore fa
parent
commit
c62cf5ec49

+ 13 - 4
smart_printer/DocumentPickerService.swift

@@ -56,7 +56,7 @@ enum DocumentPickerService {
56 56
         let completion: (NSApplication.ModalResponse) -> Void = { response in
57 57
             defer { activeDelegate = nil }
58 58
             guard response == .OK else { return }
59
-            handleSelection(panel.urls, kind: kind)
59
+            handleSelection(panel.urls, kind: kind, from: window ?? NSApp.keyWindow)
60 60
         }
61 61
 
62 62
         if let window = window ?? NSApp.keyWindow {
@@ -105,7 +105,7 @@ enum DocumentPickerService {
105 105
         return UTType(filenameExtension: ext)
106 106
     }
107 107
 
108
-    private static func handleSelection(_ urls: [URL], kind: DocumentPickerKind) {
108
+    private static func handleSelection(_ urls: [URL], kind: DocumentPickerKind, from window: NSWindow?) {
109 109
         let matched: [URL]
110 110
         switch kind {
111 111
         case .photos:
@@ -122,8 +122,17 @@ enum DocumentPickerService {
122 122
         }
123 123
 
124 124
         switch kind {
125
-        case .photos, .files:
126
-            PrintService.print(urls: matched)
125
+        case .photos:
126
+            PhotoPreviewService.present(urls: matched, from: window)
127
+        case .files:
128
+            let photos = matched.filter { isPhoto($0) }
129
+            let documents = matched.filter { isDocument($0) }
130
+            if !photos.isEmpty {
131
+                PhotoPreviewService.present(urls: photos, from: window)
132
+            }
133
+            if !documents.isEmpty {
134
+                PrintService.print(urls: documents)
135
+            }
127 136
         case .importFile:
128 137
             let added = ImportedFilesStore.add(urls: matched)
129 138
             showImportSuccessAlert(count: added > 0 ? added : matched.count)

+ 357 - 0
smart_printer/PhotoPreviewView.swift

@@ -0,0 +1,357 @@
1
+import Cocoa
2
+
3
+// MARK: - Photo Preview Service
4
+
5
+enum PhotoPreviewService {
6
+    static func present(urls: [URL], from window: NSWindow?) {
7
+        guard !urls.isEmpty else { return }
8
+        let hostWindow = window ?? NSApp.keyWindow
9
+        guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
10
+        viewController.presentPhotoPreview(urls: urls)
11
+    }
12
+}
13
+
14
+// MARK: - Overlay
15
+
16
+final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
17
+    var onDismiss: (() -> Void)?
18
+
19
+    private let urls: [URL]
20
+    private var currentIndex = 0
21
+    private var securityAccess: [URL: Bool] = [:]
22
+
23
+    private let backButton = PhotoToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
24
+    private let editButton = PhotoToolbarButton(symbolName: "pencil", accessibilityLabel: "Edit")
25
+    private let titleLabel = NSTextField(labelWithString: "Print a photo")
26
+    private let counterLabel = NSTextField(labelWithString: "")
27
+    private let imageContainer = NSView()
28
+    private let imageView = NSImageView()
29
+    private let printButton = PhotoPrintButton()
30
+
31
+    init(urls: [URL]) {
32
+        self.urls = urls
33
+        super.init(frame: .zero)
34
+        translatesAutoresizingMaskIntoConstraints = false
35
+        setup()
36
+        refreshAppearance()
37
+        showPhoto(at: 0)
38
+        NotificationCenter.default.addObserver(
39
+            self,
40
+            selector: #selector(appearanceDidChange),
41
+            name: .appearanceDidChange,
42
+            object: nil
43
+        )
44
+    }
45
+
46
+    deinit {
47
+        NotificationCenter.default.removeObserver(self)
48
+    }
49
+
50
+    @objc private func appearanceDidChange() {
51
+        refreshAppearance()
52
+    }
53
+
54
+    @available(*, unavailable)
55
+    required init?(coder: NSCoder) { nil }
56
+
57
+    func refreshAppearance() {
58
+        layer?.backgroundColor = AppTheme.background.cgColor
59
+        titleLabel.textColor = AppTheme.textPrimary
60
+        counterLabel.textColor = AppTheme.textSecondary
61
+        imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
62
+        imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
63
+        backButton.refreshAppearance()
64
+        editButton.refreshAppearance()
65
+        printButton.refreshAppearance()
66
+    }
67
+
68
+    func present(in parent: NSView) {
69
+        guard superview == nil else { return }
70
+        parent.addSubview(self)
71
+        NSLayoutConstraint.activate([
72
+            leadingAnchor.constraint(equalTo: parent.leadingAnchor),
73
+            trailingAnchor.constraint(equalTo: parent.trailingAnchor),
74
+            topAnchor.constraint(equalTo: parent.topAnchor),
75
+            bottomAnchor.constraint(equalTo: parent.bottomAnchor),
76
+        ])
77
+        alphaValue = 0
78
+        NSAnimationContext.runAnimationGroup { context in
79
+            context.duration = 0.2
80
+            animator().alphaValue = 1
81
+        }
82
+    }
83
+
84
+    func dismiss(animated: Bool = true) {
85
+        stopSecurityAccess()
86
+        let remove = { [weak self] in
87
+            self?.removeFromSuperview()
88
+            self?.onDismiss?()
89
+        }
90
+        guard animated else {
91
+            remove()
92
+            return
93
+        }
94
+        NSAnimationContext.runAnimationGroup({ context in
95
+            context.duration = 0.15
96
+            animator().alphaValue = 0
97
+        }, completionHandler: remove)
98
+    }
99
+
100
+    private func setup() {
101
+        wantsLayer = true
102
+
103
+        titleLabel.font = AppTheme.semiboldFont(size: 18)
104
+        titleLabel.alignment = .center
105
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
106
+
107
+        counterLabel.font = AppTheme.regularFont(size: 13)
108
+        counterLabel.alignment = .center
109
+        counterLabel.translatesAutoresizingMaskIntoConstraints = false
110
+        counterLabel.isHidden = urls.count <= 1
111
+
112
+        imageContainer.wantsLayer = true
113
+        imageContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
114
+        imageContainer.layer?.borderWidth = 1.5
115
+        imageContainer.applyCardShadow()
116
+        imageContainer.translatesAutoresizingMaskIntoConstraints = false
117
+        imageContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
118
+        imageContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
119
+        imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
120
+        imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
121
+
122
+        imageView.imageScaling = .scaleProportionallyUpOrDown
123
+        imageView.imageAlignment = .alignCenter
124
+        imageView.translatesAutoresizingMaskIntoConstraints = false
125
+        imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
126
+        imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
127
+        imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
128
+        imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
129
+
130
+        backButton.translatesAutoresizingMaskIntoConstraints = false
131
+        editButton.translatesAutoresizingMaskIntoConstraints = false
132
+        printButton.translatesAutoresizingMaskIntoConstraints = false
133
+
134
+        backButton.onClick = { [weak self] in self?.dismiss() }
135
+        editButton.onClick = { [weak self] in self?.editCurrentPhoto() }
136
+        printButton.onClick = { [weak self] in self?.printCurrentPhoto() }
137
+
138
+        addSubview(backButton)
139
+        addSubview(titleLabel)
140
+        addSubview(editButton)
141
+        addSubview(counterLabel)
142
+        addSubview(imageContainer)
143
+        imageContainer.addSubview(imageView)
144
+        addSubview(printButton)
145
+
146
+        NSLayoutConstraint.activate([
147
+            backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
148
+            backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
149
+            backButton.widthAnchor.constraint(equalToConstant: 40),
150
+            backButton.heightAnchor.constraint(equalToConstant: 40),
151
+
152
+            editButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24),
153
+            editButton.topAnchor.constraint(equalTo: backButton.topAnchor),
154
+            editButton.widthAnchor.constraint(equalToConstant: 40),
155
+            editButton.heightAnchor.constraint(equalToConstant: 40),
156
+
157
+            titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
158
+            titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
159
+
160
+            counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
161
+            counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
162
+
163
+            imageContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
164
+            imageContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
165
+            imageContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
166
+            imageContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
167
+
168
+            imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
169
+            imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
170
+            imageView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: 12),
171
+            imageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor, constant: -12),
172
+
173
+            printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
174
+            printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
175
+            printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
176
+        ])
177
+    }
178
+
179
+    private func showPhoto(at index: Int) {
180
+        guard urls.indices.contains(index) else { return }
181
+        currentIndex = index
182
+        let url = urls[index]
183
+
184
+        if securityAccess[url] != true {
185
+            securityAccess[url] = url.startAccessingSecurityScopedResource()
186
+        }
187
+
188
+        imageView.image = NSImage(contentsOf: url)
189
+
190
+        if urls.count > 1 {
191
+            counterLabel.stringValue = "\(index + 1) of \(urls.count)"
192
+            counterLabel.isHidden = false
193
+        } else {
194
+            counterLabel.isHidden = true
195
+        }
196
+    }
197
+
198
+    private func currentURL() -> URL? {
199
+        guard urls.indices.contains(currentIndex) else { return nil }
200
+        return urls[currentIndex]
201
+    }
202
+
203
+    private func editCurrentPhoto() {
204
+        guard let url = currentURL() else { return }
205
+        NSWorkspace.shared.open(url)
206
+    }
207
+
208
+    private func printCurrentPhoto() {
209
+        guard let url = currentURL() else { return }
210
+        PrintService.print(urls: [url])
211
+
212
+        if currentIndex < urls.count - 1 {
213
+            showPhoto(at: currentIndex + 1)
214
+        } else {
215
+            dismiss()
216
+        }
217
+    }
218
+
219
+    private func stopSecurityAccess() {
220
+        for (url, accessed) in securityAccess where accessed {
221
+            url.stopAccessingSecurityScopedResource()
222
+        }
223
+        securityAccess.removeAll()
224
+    }
225
+}
226
+
227
+// MARK: - Toolbar Button
228
+
229
+private final class PhotoToolbarButton: NSControl, AppearanceRefreshable {
230
+    var onClick: (() -> Void)?
231
+
232
+    private let symbolName: String
233
+    private let iconView = NSImageView()
234
+
235
+    init(symbolName: String, accessibilityLabel: String) {
236
+        self.symbolName = symbolName
237
+        super.init(frame: .zero)
238
+        toolTip = accessibilityLabel
239
+
240
+        wantsLayer = true
241
+        layer?.cornerRadius = 12
242
+
243
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
244
+            let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
245
+            iconView.image = image.withSymbolConfiguration(config)
246
+        }
247
+        iconView.translatesAutoresizingMaskIntoConstraints = false
248
+
249
+        addSubview(iconView)
250
+        NSLayoutConstraint.activate([
251
+            iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
252
+            iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
253
+        ])
254
+    }
255
+
256
+    @available(*, unavailable)
257
+    required init?(coder: NSCoder) { nil }
258
+
259
+    func refreshAppearance() {
260
+        layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
261
+        layer?.borderColor = AppTheme.paywallBorder.cgColor
262
+        layer?.borderWidth = 1.5
263
+        iconView.contentTintColor = AppTheme.textPrimary
264
+    }
265
+
266
+    override func mouseUp(with event: NSEvent) {
267
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
268
+        onClick?()
269
+    }
270
+
271
+    override func resetCursorRects() {
272
+        addCursorRect(bounds, cursor: .pointingHand)
273
+    }
274
+}
275
+
276
+// MARK: - Print Button
277
+
278
+private final class PhotoPrintButton: NSControl, AppearanceRefreshable {
279
+    var onClick: (() -> Void)?
280
+
281
+    private let titleLabel = NSTextField(labelWithString: "Print Photo")
282
+    private let iconView = NSImageView()
283
+    private var hoverTracker: HoverTracker?
284
+    private var isHovered = false
285
+
286
+    init() {
287
+        super.init(frame: .zero)
288
+        wantsLayer = true
289
+        layer?.cornerRadius = 22
290
+
291
+        titleLabel.font = AppTheme.semiboldFont(size: 16)
292
+        titleLabel.textColor = .white
293
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
294
+
295
+        if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
296
+            let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
297
+            iconView.image = image.withSymbolConfiguration(config)
298
+        }
299
+        iconView.contentTintColor = .white
300
+        iconView.translatesAutoresizingMaskIntoConstraints = false
301
+
302
+        addSubview(titleLabel)
303
+        addSubview(iconView)
304
+
305
+        NSLayoutConstraint.activate([
306
+            heightAnchor.constraint(equalToConstant: 52),
307
+
308
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
309
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
310
+
311
+            iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
312
+            iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
313
+            iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
314
+            iconView.widthAnchor.constraint(equalToConstant: 20),
315
+            iconView.heightAnchor.constraint(equalToConstant: 20),
316
+        ])
317
+
318
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
319
+            self?.setHovered(hovering)
320
+        }
321
+        refreshAppearance()
322
+    }
323
+
324
+    @available(*, unavailable)
325
+    required init?(coder: NSCoder) { nil }
326
+
327
+    func refreshAppearance() {
328
+        let color = isHovered
329
+            ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
330
+            : AppTheme.blue
331
+        layer?.backgroundColor = color.cgColor
332
+        titleLabel.textColor = .white
333
+        iconView.contentTintColor = .white
334
+    }
335
+
336
+    private func setHovered(_ hovering: Bool) {
337
+        isHovered = hovering
338
+        animateHover {
339
+            let color = hovering
340
+                ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
341
+                : AppTheme.blue
342
+            layer?.backgroundColor = color.cgColor
343
+            layer?.transform = hovering
344
+                ? CATransform3DMakeScale(1.03, 1.03, 1)
345
+                : CATransform3DIdentity
346
+        }
347
+    }
348
+
349
+    override func mouseUp(with event: NSEvent) {
350
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
351
+        onClick?()
352
+    }
353
+
354
+    override func resetCursorRects() {
355
+        addCursorRect(bounds, cursor: .pointingHand)
356
+    }
357
+}

+ 13 - 0
smart_printer/ViewController.swift

@@ -15,6 +15,7 @@ class ViewController: NSViewController {
15 15
     private var settingsContentView: NSView!
16 16
     private var contentContainer: NSView!
17 17
     private var paywallOverlay: PaywallOverlayView?
18
+    private var photoPreviewOverlay: PhotoPreviewOverlayView?
18 19
 
19 20
     private var headerView: NSView!
20 21
     private var contentTopBelowHeader: NSLayoutConstraint!
@@ -76,6 +77,18 @@ class ViewController: NSViewController {
76 77
         sidebar?.refreshAppearance()
77 78
         view.refreshAppearanceRecursively()
78 79
         paywallOverlay?.refreshAppearance()
80
+        photoPreviewOverlay?.refreshAppearance()
81
+    }
82
+
83
+    func presentPhotoPreview(urls: [URL]) {
84
+        photoPreviewOverlay?.dismiss(animated: false)
85
+
86
+        let overlay = PhotoPreviewOverlayView(urls: urls)
87
+        overlay.onDismiss = { [weak self] in
88
+            self?.photoPreviewOverlay = nil
89
+        }
90
+        photoPreviewOverlay = overlay
91
+        overlay.present(in: mainContentView)
79 92
     }
80 93
 
81 94
     private func setupLayout() {