ソースを参照

Persist saved jobs and add Saved Jobs page

Make JobListing Codable for JSON storage. Add SavedJobsStore using
UserDefaults. Dashboard loads saved order on launch, shows a dedicated
Saved Jobs sidebar view, and refactors listing card layout for both
home results and saved list.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 週間 前
コミット
6de812b12d
共有3 個のファイルを変更した231 個の追加32 個の削除を含む
  1. 1 1
      App for Indeed/Models/DashboardModels.swift
  2. 28 0
      App for Indeed/Models/SavedJobsStore.swift
  3. 202 31
      App for Indeed/Views/DashboardView.swift

+ 1 - 1
App for Indeed/Models/DashboardModels.swift

@@ -11,7 +11,7 @@ struct SidebarItem {
11 11
     let badge: String?
12 12
 }
13 13
 
14
-struct JobListing: Hashable {
14
+struct JobListing: Hashable, Codable {
15 15
     let title: String
16 16
     let description: String
17 17
 }

+ 28 - 0
App for Indeed/Models/SavedJobsStore.swift

@@ -0,0 +1,28 @@
1
+//
2
+//  SavedJobsStore.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Foundation
7
+
8
+enum SavedJobsStore {
9
+    private static let defaultsKey = "com.appforindeed.savedJobListings.v1"
10
+
11
+    static func load() -> [JobListing] {
12
+        guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return [] }
13
+        do {
14
+            return try JSONDecoder().decode([JobListing].self, from: data)
15
+        } catch {
16
+            return []
17
+        }
18
+    }
19
+
20
+    static func save(_ jobs: [JobListing]) {
21
+        do {
22
+            let data = try JSONEncoder().encode(jobs)
23
+            UserDefaults.standard.set(data, forKey: defaultsKey)
24
+        } catch {
25
+            // Best-effort persistence; UI state remains in memory for this session.
26
+        }
27
+    }
28
+}

+ 202 - 31
App for Indeed/Views/DashboardView.swift

@@ -6,6 +6,11 @@
6 6
 import Cocoa
7 7
 import QuartzCore
8 8
 
9
+private enum JobListingCardContext {
10
+    case homeSearchResults
11
+    case savedJobsPage
12
+}
13
+
9 14
 final class DashboardView: NSView, NSTextFieldDelegate {
10 15
     /// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders).
11 16
     private enum Theme {
@@ -55,8 +60,15 @@ final class DashboardView: NSView, NSTextFieldDelegate {
55 60
     private let jobListingsStack = NSStackView()
56 61
     /// Shown when a sidebar item other than Home is selected.
57 62
     private let nonHomeHost = NSView()
63
+    private let nonHomeGenericContainer = NSView()
58 64
     private let nonHomeTitleLabel = NSTextField(labelWithString: "")
59 65
     private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "")
66
+    private let savedJobsPageContainer = NSView()
67
+    private let savedJobsPageTitleLabel = NSTextField(labelWithString: "Saved Jobs")
68
+    private let savedJobsPageSubtitleLabel = NSTextField(wrappingLabelWithString: "")
69
+    private let savedJobsScrollView = NSScrollView()
70
+    private let savedJobsDocumentView = JobListingsDocumentView()
71
+    private let savedJobsStack = NSStackView()
60 72
 
61 73
     private var currentSidebarItems: [SidebarItem] = []
62 74
     private var selectedSidebarIndex: Int = 0
@@ -65,7 +77,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
65 77
     /// Last successful search result set (used when removing a card with the dismiss control).
66 78
     private var lastSearchResults: [JobListing] = []
67 79
     private var lastNoResultsQuery: String?
68
-    private var savedJobs: Set<JobListing> = []
80
+    /// Most recently saved jobs appear first; persisted across launches.
81
+    private var savedJobOrder: [JobListing] = []
69 82
 
70 83
     override init(frame frameRect: NSRect) {
71 84
         super.init(frame: frameRect)
@@ -94,7 +107,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
94 107
         }
95 108
         configureSidebar()
96 109
         catalogJobListings = data.jobListings
97
-        savedJobs = []
110
+        savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
98 111
         configureJobListings([], noResultsForQuery: nil)
99 112
         updateMainContentVisibility()
100 113
     }
@@ -251,12 +264,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
251 264
     }
252 265
 
253 266
     private func updateJobListingDescriptionWidths() {
254
-        let containerWidth = jobListingsContainer.bounds.width
267
+        updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
268
+        updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
269
+    }
270
+
271
+    private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
255 272
         guard containerWidth > 1 else { return }
256 273
         let buttonStripReserve: CGFloat = 200
257 274
         let fallbackTextColumn = max(1, containerWidth - 32 - buttonStripReserve)
258 275
         var didChange = false
259
-        for card in jobListingsStack.arrangedSubviews {
276
+        for card in stack.arrangedSubviews {
260 277
             guard let desc = card.viewWithTag(502) as? NSTextField else { continue }
261 278
             let columnWidth: CGFloat
262 279
             if let column = desc.superview, column.bounds.width > 1 {
@@ -271,8 +288,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
271 288
             }
272 289
         }
273 290
         if didChange {
274
-            // Wrapping width changed, so card heights need to recompute against the new intrinsic sizes.
275
-            jobListingsStack.needsLayout = true
291
+            stack.needsLayout = true
276 292
         }
277 293
     }
278 294
 
@@ -297,14 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
297 313
             return
298 314
         }
299 315
         for job in jobs {
300
-            let card = makeJobListingCard(job)
316
+            let card = makeJobListingCard(job, context: .homeSearchResults)
301 317
             jobListingsStack.addArrangedSubview(card)
302 318
             // Force every card to span the full row instead of hugging its intrinsic content width.
303 319
             card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
304 320
         }
305 321
     }
306 322
 
307
-    private func makeJobListingCard(_ job: JobListing) -> NSView {
323
+    private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
324
+        var seen = Set<JobListing>()
325
+        var out: [JobListing] = []
326
+        for job in jobs where seen.insert(job).inserted {
327
+            out.append(job)
328
+        }
329
+        return out
330
+    }
331
+
332
+    private func isJobSaved(_ job: JobListing) -> Bool {
333
+        savedJobOrder.contains(job)
334
+    }
335
+
336
+    private func persistSavedJobs() {
337
+        SavedJobsStore.save(savedJobOrder)
338
+    }
339
+
340
+    private func applySavedState(_ saved: Bool, for job: JobListing) {
341
+        if saved {
342
+            savedJobOrder.removeAll { $0 == job }
343
+            savedJobOrder.insert(job, at: 0)
344
+        } else {
345
+            savedJobOrder.removeAll { $0 == job }
346
+        }
347
+        persistSavedJobs()
348
+    }
349
+
350
+    private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView {
308 351
         let card = NSView()
309 352
         card.translatesAutoresizingMaskIntoConstraints = false
310 353
         card.wantsLayer = true
@@ -331,6 +374,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
331 374
 
332 375
         let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:)))
333 376
         applyButton.jobPayload = job
377
+        applyButton.cardContext = context
334 378
         applyButton.isBordered = false
335 379
         applyButton.bezelStyle = .rounded
336 380
         applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
@@ -342,20 +386,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
342 386
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
343 387
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
344 388
 
345
-        let savedButton = JobPayloadButton(title: "Saved", target: self, action: #selector(didTapJobSaved(_:)))
389
+        let savedOn = isJobSaved(job)
390
+        let savedButton = JobPayloadButton(title: savedOn ? "Saved" : "Save", target: self, action: #selector(didTapJobSaved(_:)))
346 391
         savedButton.jobPayload = job
392
+        savedButton.cardContext = context
347 393
         savedButton.setButtonType(.toggle)
348 394
         savedButton.isBordered = false
349 395
         savedButton.bezelStyle = .rounded
350 396
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
351 397
         savedButton.focusRingType = .none
352
-        savedButton.state = savedJobs.contains(job) ? .on : .off
398
+        savedButton.state = savedOn ? .on : .off
353 399
         styleJobSavedButton(savedButton)
354 400
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
355 401
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
356 402
 
357 403
         let dismissButton = JobPayloadButton()
358 404
         dismissButton.jobPayload = job
405
+        dismissButton.cardContext = context
359 406
         dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
360 407
         dismissButton.imagePosition = .imageOnly
361 408
         dismissButton.imageScaling = .scaleProportionallyDown
@@ -365,7 +412,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
365 412
         dismissButton.contentTintColor = Theme.secondaryText
366 413
         dismissButton.target = self
367 414
         dismissButton.action = #selector(didTapJobDismiss(_:))
368
-        dismissButton.toolTip = "Dismiss"
415
+        dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
369 416
         dismissButton.focusRingType = .none
370 417
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
371 418
 
@@ -446,19 +493,29 @@ final class DashboardView: NSView, NSTextFieldDelegate {
446 493
 
447 494
     @objc private func didTapJobSaved(_ sender: NSButton) {
448 495
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
449
-        if savedJobs.contains(job) {
450
-            savedJobs.remove(job)
451
-        } else {
452
-            savedJobs.insert(job)
453
-        }
454
-        sender.state = savedJobs.contains(job) ? .on : .off
496
+        let willSave = !isJobSaved(job)
497
+        applySavedState(willSave, for: job)
498
+        sender.state = willSave ? .on : .off
499
+        sender.title = willSave ? "Saved" : "Save"
455 500
         styleJobSavedButton(sender)
501
+        if isSavedJobsSidebarIndex(selectedSidebarIndex) {
502
+            reloadSavedJobsListings()
503
+        }
456 504
     }
457 505
 
458 506
     @objc private func didTapJobDismiss(_ sender: NSButton) {
459
-        guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
460
-        lastSearchResults.removeAll { $0 == job }
461
-        configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
507
+        guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
508
+        switch button.cardContext {
509
+        case .homeSearchResults:
510
+            lastSearchResults.removeAll { $0 == job }
511
+            configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
512
+        case .savedJobsPage:
513
+            applySavedState(false, for: job)
514
+            reloadSavedJobsListings()
515
+            if isHomeSidebarIndex(selectedSidebarIndex), !lastSearchResults.isEmpty {
516
+                configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery, updateLastResults: false)
517
+            }
518
+        }
462 519
     }
463 520
 
464 521
     private func configureSearchBar() {
@@ -630,6 +687,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
630 687
         nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
631 688
         nonHomeHost.isHidden = true
632 689
 
690
+        nonHomeGenericContainer.translatesAutoresizingMaskIntoConstraints = false
691
+        savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false
692
+        nonHomeHost.addSubview(nonHomeGenericContainer)
693
+        nonHomeHost.addSubview(savedJobsPageContainer)
694
+
695
+        NSLayoutConstraint.activate([
696
+            nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
697
+            nonHomeGenericContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
698
+            nonHomeGenericContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
699
+            nonHomeGenericContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
700
+
701
+            savedJobsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
702
+            savedJobsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
703
+            savedJobsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
704
+            savedJobsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
705
+        ])
706
+
633 707
         nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
634 708
         nonHomeTitleLabel.textColor = Theme.primaryText
635 709
         nonHomeTitleLabel.alignment = .center
@@ -641,19 +715,108 @@ final class DashboardView: NSView, NSTextFieldDelegate {
641 715
         nonHomeSubtitleLabel.maximumNumberOfLines = 0
642 716
         nonHomeSubtitleLabel.stringValue = "This area is not available in the preview build. Use Home to search jobs."
643 717
 
644
-        let stack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel])
645
-        stack.orientation = .vertical
646
-        stack.spacing = 10
647
-        stack.alignment = .centerX
648
-        stack.translatesAutoresizingMaskIntoConstraints = false
649
-        nonHomeHost.addSubview(stack)
718
+        let genericStack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel])
719
+        genericStack.orientation = .vertical
720
+        genericStack.spacing = 10
721
+        genericStack.alignment = .centerX
722
+        genericStack.translatesAutoresizingMaskIntoConstraints = false
723
+        nonHomeGenericContainer.addSubview(genericStack)
650 724
         NSLayoutConstraint.activate([
651
-            stack.centerXAnchor.constraint(equalTo: nonHomeHost.centerXAnchor),
652
-            stack.centerYAnchor.constraint(equalTo: nonHomeHost.centerYAnchor),
653
-            stack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeHost.leadingAnchor, constant: 32),
654
-            stack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeHost.trailingAnchor, constant: -32),
725
+            genericStack.centerXAnchor.constraint(equalTo: nonHomeGenericContainer.centerXAnchor),
726
+            genericStack.centerYAnchor.constraint(equalTo: nonHomeGenericContainer.centerYAnchor),
727
+            genericStack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeGenericContainer.leadingAnchor, constant: 32),
728
+            genericStack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeGenericContainer.trailingAnchor, constant: -32),
655 729
             nonHomeSubtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 420)
656 730
         ])
731
+
732
+        savedJobsPageTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
733
+        savedJobsPageTitleLabel.textColor = Theme.primaryText
734
+        savedJobsPageTitleLabel.alignment = .left
735
+        savedJobsPageTitleLabel.maximumNumberOfLines = 1
736
+
737
+        savedJobsPageSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
738
+        savedJobsPageSubtitleLabel.textColor = Theme.secondaryText
739
+        savedJobsPageSubtitleLabel.alignment = .left
740
+        savedJobsPageSubtitleLabel.maximumNumberOfLines = 0
741
+
742
+        savedJobsDocumentView.translatesAutoresizingMaskIntoConstraints = false
743
+        savedJobsStack.orientation = .vertical
744
+        savedJobsStack.spacing = 14
745
+        savedJobsStack.alignment = .leading
746
+        savedJobsStack.distribution = .fill
747
+        savedJobsStack.translatesAutoresizingMaskIntoConstraints = false
748
+        savedJobsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
749
+        savedJobsStack.setHuggingPriority(.defaultLow, for: .horizontal)
750
+        savedJobsDocumentView.addSubview(savedJobsStack)
751
+        NSLayoutConstraint.activate([
752
+            savedJobsStack.leadingAnchor.constraint(equalTo: savedJobsDocumentView.leadingAnchor),
753
+            savedJobsStack.trailingAnchor.constraint(equalTo: savedJobsDocumentView.trailingAnchor),
754
+            savedJobsStack.topAnchor.constraint(equalTo: savedJobsDocumentView.topAnchor),
755
+            savedJobsStack.bottomAnchor.constraint(equalTo: savedJobsDocumentView.bottomAnchor)
756
+        ])
757
+
758
+        savedJobsScrollView.translatesAutoresizingMaskIntoConstraints = false
759
+        savedJobsScrollView.hasVerticalScroller = true
760
+        savedJobsScrollView.hasHorizontalScroller = false
761
+        savedJobsScrollView.autohidesScrollers = true
762
+        savedJobsScrollView.drawsBackground = false
763
+        savedJobsScrollView.borderType = .noBorder
764
+        savedJobsScrollView.documentView = savedJobsDocumentView
765
+        savedJobsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
766
+        savedJobsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
767
+
768
+        let savedHeaderStack = NSStackView(views: [savedJobsPageTitleLabel, savedJobsPageSubtitleLabel])
769
+        savedHeaderStack.orientation = .vertical
770
+        savedHeaderStack.spacing = 6
771
+        savedHeaderStack.alignment = .leading
772
+        savedHeaderStack.translatesAutoresizingMaskIntoConstraints = false
773
+
774
+        let savedOuterStack = NSStackView(views: [savedHeaderStack, savedJobsScrollView])
775
+        savedOuterStack.orientation = .vertical
776
+        savedOuterStack.spacing = 16
777
+        savedOuterStack.alignment = .width
778
+        savedOuterStack.translatesAutoresizingMaskIntoConstraints = false
779
+        savedJobsPageContainer.addSubview(savedOuterStack)
780
+        NSLayoutConstraint.activate([
781
+            savedOuterStack.leadingAnchor.constraint(equalTo: savedJobsPageContainer.leadingAnchor, constant: 32),
782
+            savedOuterStack.trailingAnchor.constraint(equalTo: savedJobsPageContainer.trailingAnchor, constant: -32),
783
+            savedOuterStack.topAnchor.constraint(equalTo: savedJobsPageContainer.topAnchor, constant: 8),
784
+            savedOuterStack.bottomAnchor.constraint(equalTo: savedJobsPageContainer.bottomAnchor),
785
+
786
+            savedJobsDocumentView.topAnchor.constraint(equalTo: savedJobsScrollView.contentView.topAnchor),
787
+            savedJobsDocumentView.leadingAnchor.constraint(equalTo: savedJobsScrollView.contentView.leadingAnchor),
788
+            savedJobsDocumentView.widthAnchor.constraint(equalTo: savedJobsScrollView.contentView.widthAnchor)
789
+        ])
790
+    }
791
+
792
+    private func reloadSavedJobsListings() {
793
+        savedJobsStack.arrangedSubviews.forEach {
794
+            savedJobsStack.removeArrangedSubview($0)
795
+            $0.removeFromSuperview()
796
+        }
797
+        if savedJobOrder.isEmpty {
798
+            savedJobsPageSubtitleLabel.stringValue = "Save jobs from Home to see them here."
799
+            let empty = NSTextField(wrappingLabelWithString: "No saved jobs yet. Search on Home, then tap Save on a listing.")
800
+            empty.font = .systemFont(ofSize: 14, weight: .regular)
801
+            empty.textColor = Theme.secondaryText
802
+            empty.alignment = .left
803
+            empty.maximumNumberOfLines = 0
804
+            empty.translatesAutoresizingMaskIntoConstraints = false
805
+            savedJobsStack.addArrangedSubview(empty)
806
+            empty.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
807
+            return
808
+        }
809
+        savedJobsPageSubtitleLabel.stringValue = "\(savedJobOrder.count) saved \(savedJobOrder.count == 1 ? "position" : "positions")"
810
+        for job in savedJobOrder {
811
+            let card = makeJobListingCard(job, context: .savedJobsPage)
812
+            savedJobsStack.addArrangedSubview(card)
813
+            card.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
814
+        }
815
+    }
816
+
817
+    private func isSavedJobsSidebarIndex(_ index: Int) -> Bool {
818
+        guard index >= 0, index < currentSidebarItems.count else { return false }
819
+        return currentSidebarItems[index].title == "Saved Jobs"
657 820
     }
658 821
 
659 822
     private func isHomeSidebarIndex(_ index: Int) -> Bool {
@@ -663,10 +826,17 @@ final class DashboardView: NSView, NSTextFieldDelegate {
663 826
 
664 827
     private func updateMainContentVisibility() {
665 828
         let home = isHomeSidebarIndex(selectedSidebarIndex)
829
+        let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
666 830
         mainOverlay.isHidden = !home
667 831
         nonHomeHost.isHidden = home
832
+        nonHomeGenericContainer.isHidden = savedJobs
833
+        savedJobsPageContainer.isHidden = !savedJobs
668 834
         if !home, selectedSidebarIndex < currentSidebarItems.count {
669
-            nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
835
+            if savedJobs {
836
+                reloadSavedJobsListings()
837
+            } else {
838
+                nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
839
+            }
670 840
         }
671 841
     }
672 842
 
@@ -950,6 +1120,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
950 1120
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
951 1121
 private final class JobPayloadButton: NSButton {
952 1122
     var jobPayload: JobListing?
1123
+    var cardContext: JobListingCardContext = .homeSearchResults
953 1124
 }
954 1125
 
955 1126
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).