Bläddra i källkod

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 2 månader sedan
förälder
incheckning
6de812b12d

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

@@ -11,7 +11,7 @@ struct SidebarItem {
     let badge: String?
 }
 
-struct JobListing: Hashable {
+struct JobListing: Hashable, Codable {
     let title: String
     let description: String
 }

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

@@ -0,0 +1,28 @@
+//
+//  SavedJobsStore.swift
+//  App for Indeed
+//
+
+import Foundation
+
+enum SavedJobsStore {
+    private static let defaultsKey = "com.appforindeed.savedJobListings.v1"
+
+    static func load() -> [JobListing] {
+        guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return [] }
+        do {
+            return try JSONDecoder().decode([JobListing].self, from: data)
+        } catch {
+            return []
+        }
+    }
+
+    static func save(_ jobs: [JobListing]) {
+        do {
+            let data = try JSONEncoder().encode(jobs)
+            UserDefaults.standard.set(data, forKey: defaultsKey)
+        } catch {
+            // Best-effort persistence; UI state remains in memory for this session.
+        }
+    }
+}

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

@@ -6,6 +6,11 @@
 import Cocoa
 import QuartzCore
 
+private enum JobListingCardContext {
+    case homeSearchResults
+    case savedJobsPage
+}
+
 final class DashboardView: NSView, NSTextFieldDelegate {
     /// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders).
     private enum Theme {
@@ -55,8 +60,15 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private let jobListingsStack = NSStackView()
     /// Shown when a sidebar item other than Home is selected.
     private let nonHomeHost = NSView()
+    private let nonHomeGenericContainer = NSView()
     private let nonHomeTitleLabel = NSTextField(labelWithString: "")
     private let nonHomeSubtitleLabel = NSTextField(wrappingLabelWithString: "")
+    private let savedJobsPageContainer = NSView()
+    private let savedJobsPageTitleLabel = NSTextField(labelWithString: "Saved Jobs")
+    private let savedJobsPageSubtitleLabel = NSTextField(wrappingLabelWithString: "")
+    private let savedJobsScrollView = NSScrollView()
+    private let savedJobsDocumentView = JobListingsDocumentView()
+    private let savedJobsStack = NSStackView()
 
     private var currentSidebarItems: [SidebarItem] = []
     private var selectedSidebarIndex: Int = 0
@@ -65,7 +77,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     /// Last successful search result set (used when removing a card with the dismiss control).
     private var lastSearchResults: [JobListing] = []
     private var lastNoResultsQuery: String?
-    private var savedJobs: Set<JobListing> = []
+    /// Most recently saved jobs appear first; persisted across launches.
+    private var savedJobOrder: [JobListing] = []
 
     override init(frame frameRect: NSRect) {
         super.init(frame: frameRect)
@@ -94,7 +107,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         }
         configureSidebar()
         catalogJobListings = data.jobListings
-        savedJobs = []
+        savedJobOrder = Self.normalizedSavedJobs(SavedJobsStore.load())
         configureJobListings([], noResultsForQuery: nil)
         updateMainContentVisibility()
     }
@@ -251,12 +264,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }
 
     private func updateJobListingDescriptionWidths() {
-        let containerWidth = jobListingsContainer.bounds.width
+        updateDescriptionColumnWidths(in: jobListingsStack, containerWidth: jobListingsContainer.bounds.width)
+        updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
+    }
+
+    private func updateDescriptionColumnWidths(in stack: NSStackView, containerWidth: CGFloat) {
         guard containerWidth > 1 else { return }
         let buttonStripReserve: CGFloat = 200
         let fallbackTextColumn = max(1, containerWidth - 32 - buttonStripReserve)
         var didChange = false
-        for card in jobListingsStack.arrangedSubviews {
+        for card in stack.arrangedSubviews {
             guard let desc = card.viewWithTag(502) as? NSTextField else { continue }
             let columnWidth: CGFloat
             if let column = desc.superview, column.bounds.width > 1 {
@@ -271,8 +288,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             }
         }
         if didChange {
-            // Wrapping width changed, so card heights need to recompute against the new intrinsic sizes.
-            jobListingsStack.needsLayout = true
+            stack.needsLayout = true
         }
     }
 
@@ -297,14 +313,41 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             return
         }
         for job in jobs {
-            let card = makeJobListingCard(job)
+            let card = makeJobListingCard(job, context: .homeSearchResults)
             jobListingsStack.addArrangedSubview(card)
             // Force every card to span the full row instead of hugging its intrinsic content width.
             card.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
         }
     }
 
-    private func makeJobListingCard(_ job: JobListing) -> NSView {
+    private static func normalizedSavedJobs(_ jobs: [JobListing]) -> [JobListing] {
+        var seen = Set<JobListing>()
+        var out: [JobListing] = []
+        for job in jobs where seen.insert(job).inserted {
+            out.append(job)
+        }
+        return out
+    }
+
+    private func isJobSaved(_ job: JobListing) -> Bool {
+        savedJobOrder.contains(job)
+    }
+
+    private func persistSavedJobs() {
+        SavedJobsStore.save(savedJobOrder)
+    }
+
+    private func applySavedState(_ saved: Bool, for job: JobListing) {
+        if saved {
+            savedJobOrder.removeAll { $0 == job }
+            savedJobOrder.insert(job, at: 0)
+        } else {
+            savedJobOrder.removeAll { $0 == job }
+        }
+        persistSavedJobs()
+    }
+
+    private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView {
         let card = NSView()
         card.translatesAutoresizingMaskIntoConstraints = false
         card.wantsLayer = true
@@ -331,6 +374,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
         let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:)))
         applyButton.jobPayload = job
+        applyButton.cardContext = context
         applyButton.isBordered = false
         applyButton.bezelStyle = .rounded
         applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
@@ -342,20 +386,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
 
-        let savedButton = JobPayloadButton(title: "Saved", target: self, action: #selector(didTapJobSaved(_:)))
+        let savedOn = isJobSaved(job)
+        let savedButton = JobPayloadButton(title: savedOn ? "Saved" : "Save", target: self, action: #selector(didTapJobSaved(_:)))
         savedButton.jobPayload = job
+        savedButton.cardContext = context
         savedButton.setButtonType(.toggle)
         savedButton.isBordered = false
         savedButton.bezelStyle = .rounded
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
         savedButton.focusRingType = .none
-        savedButton.state = savedJobs.contains(job) ? .on : .off
+        savedButton.state = savedOn ? .on : .off
         styleJobSavedButton(savedButton)
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
 
         let dismissButton = JobPayloadButton()
         dismissButton.jobPayload = job
+        dismissButton.cardContext = context
         dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
         dismissButton.imagePosition = .imageOnly
         dismissButton.imageScaling = .scaleProportionallyDown
@@ -365,7 +412,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         dismissButton.contentTintColor = Theme.secondaryText
         dismissButton.target = self
         dismissButton.action = #selector(didTapJobDismiss(_:))
-        dismissButton.toolTip = "Dismiss"
+        dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
         dismissButton.focusRingType = .none
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
 
@@ -446,19 +493,29 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     @objc private func didTapJobSaved(_ sender: NSButton) {
         guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
-        if savedJobs.contains(job) {
-            savedJobs.remove(job)
-        } else {
-            savedJobs.insert(job)
-        }
-        sender.state = savedJobs.contains(job) ? .on : .off
+        let willSave = !isJobSaved(job)
+        applySavedState(willSave, for: job)
+        sender.state = willSave ? .on : .off
+        sender.title = willSave ? "Saved" : "Save"
         styleJobSavedButton(sender)
+        if isSavedJobsSidebarIndex(selectedSidebarIndex) {
+            reloadSavedJobsListings()
+        }
     }
 
     @objc private func didTapJobDismiss(_ sender: NSButton) {
-        guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
-        lastSearchResults.removeAll { $0 == job }
-        configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
+        guard let button = sender as? JobPayloadButton, let job = button.jobPayload else { return }
+        switch button.cardContext {
+        case .homeSearchResults:
+            lastSearchResults.removeAll { $0 == job }
+            configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
+        case .savedJobsPage:
+            applySavedState(false, for: job)
+            reloadSavedJobsListings()
+            if isHomeSidebarIndex(selectedSidebarIndex), !lastSearchResults.isEmpty {
+                configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery, updateLastResults: false)
+            }
+        }
     }
 
     private func configureSearchBar() {
@@ -630,6 +687,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         nonHomeHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
         nonHomeHost.isHidden = true
 
+        nonHomeGenericContainer.translatesAutoresizingMaskIntoConstraints = false
+        savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false
+        nonHomeHost.addSubview(nonHomeGenericContainer)
+        nonHomeHost.addSubview(savedJobsPageContainer)
+
+        NSLayoutConstraint.activate([
+            nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
+            nonHomeGenericContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
+            nonHomeGenericContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
+            nonHomeGenericContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
+
+            savedJobsPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
+            savedJobsPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
+            savedJobsPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
+            savedJobsPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
+        ])
+
         nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
         nonHomeTitleLabel.textColor = Theme.primaryText
         nonHomeTitleLabel.alignment = .center
@@ -641,19 +715,108 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         nonHomeSubtitleLabel.maximumNumberOfLines = 0
         nonHomeSubtitleLabel.stringValue = "This area is not available in the preview build. Use Home to search jobs."
 
-        let stack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel])
-        stack.orientation = .vertical
-        stack.spacing = 10
-        stack.alignment = .centerX
-        stack.translatesAutoresizingMaskIntoConstraints = false
-        nonHomeHost.addSubview(stack)
+        let genericStack = NSStackView(views: [nonHomeTitleLabel, nonHomeSubtitleLabel])
+        genericStack.orientation = .vertical
+        genericStack.spacing = 10
+        genericStack.alignment = .centerX
+        genericStack.translatesAutoresizingMaskIntoConstraints = false
+        nonHomeGenericContainer.addSubview(genericStack)
         NSLayoutConstraint.activate([
-            stack.centerXAnchor.constraint(equalTo: nonHomeHost.centerXAnchor),
-            stack.centerYAnchor.constraint(equalTo: nonHomeHost.centerYAnchor),
-            stack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeHost.leadingAnchor, constant: 32),
-            stack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeHost.trailingAnchor, constant: -32),
+            genericStack.centerXAnchor.constraint(equalTo: nonHomeGenericContainer.centerXAnchor),
+            genericStack.centerYAnchor.constraint(equalTo: nonHomeGenericContainer.centerYAnchor),
+            genericStack.leadingAnchor.constraint(greaterThanOrEqualTo: nonHomeGenericContainer.leadingAnchor, constant: 32),
+            genericStack.trailingAnchor.constraint(lessThanOrEqualTo: nonHomeGenericContainer.trailingAnchor, constant: -32),
             nonHomeSubtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 420)
         ])
+
+        savedJobsPageTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
+        savedJobsPageTitleLabel.textColor = Theme.primaryText
+        savedJobsPageTitleLabel.alignment = .left
+        savedJobsPageTitleLabel.maximumNumberOfLines = 1
+
+        savedJobsPageSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
+        savedJobsPageSubtitleLabel.textColor = Theme.secondaryText
+        savedJobsPageSubtitleLabel.alignment = .left
+        savedJobsPageSubtitleLabel.maximumNumberOfLines = 0
+
+        savedJobsDocumentView.translatesAutoresizingMaskIntoConstraints = false
+        savedJobsStack.orientation = .vertical
+        savedJobsStack.spacing = 14
+        savedJobsStack.alignment = .leading
+        savedJobsStack.distribution = .fill
+        savedJobsStack.translatesAutoresizingMaskIntoConstraints = false
+        savedJobsStack.setContentHuggingPriority(.defaultHigh, for: .vertical)
+        savedJobsStack.setHuggingPriority(.defaultLow, for: .horizontal)
+        savedJobsDocumentView.addSubview(savedJobsStack)
+        NSLayoutConstraint.activate([
+            savedJobsStack.leadingAnchor.constraint(equalTo: savedJobsDocumentView.leadingAnchor),
+            savedJobsStack.trailingAnchor.constraint(equalTo: savedJobsDocumentView.trailingAnchor),
+            savedJobsStack.topAnchor.constraint(equalTo: savedJobsDocumentView.topAnchor),
+            savedJobsStack.bottomAnchor.constraint(equalTo: savedJobsDocumentView.bottomAnchor)
+        ])
+
+        savedJobsScrollView.translatesAutoresizingMaskIntoConstraints = false
+        savedJobsScrollView.hasVerticalScroller = true
+        savedJobsScrollView.hasHorizontalScroller = false
+        savedJobsScrollView.autohidesScrollers = true
+        savedJobsScrollView.drawsBackground = false
+        savedJobsScrollView.borderType = .noBorder
+        savedJobsScrollView.documentView = savedJobsDocumentView
+        savedJobsScrollView.setContentHuggingPriority(.defaultLow, for: .vertical)
+        savedJobsScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+        let savedHeaderStack = NSStackView(views: [savedJobsPageTitleLabel, savedJobsPageSubtitleLabel])
+        savedHeaderStack.orientation = .vertical
+        savedHeaderStack.spacing = 6
+        savedHeaderStack.alignment = .leading
+        savedHeaderStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let savedOuterStack = NSStackView(views: [savedHeaderStack, savedJobsScrollView])
+        savedOuterStack.orientation = .vertical
+        savedOuterStack.spacing = 16
+        savedOuterStack.alignment = .width
+        savedOuterStack.translatesAutoresizingMaskIntoConstraints = false
+        savedJobsPageContainer.addSubview(savedOuterStack)
+        NSLayoutConstraint.activate([
+            savedOuterStack.leadingAnchor.constraint(equalTo: savedJobsPageContainer.leadingAnchor, constant: 32),
+            savedOuterStack.trailingAnchor.constraint(equalTo: savedJobsPageContainer.trailingAnchor, constant: -32),
+            savedOuterStack.topAnchor.constraint(equalTo: savedJobsPageContainer.topAnchor, constant: 8),
+            savedOuterStack.bottomAnchor.constraint(equalTo: savedJobsPageContainer.bottomAnchor),
+
+            savedJobsDocumentView.topAnchor.constraint(equalTo: savedJobsScrollView.contentView.topAnchor),
+            savedJobsDocumentView.leadingAnchor.constraint(equalTo: savedJobsScrollView.contentView.leadingAnchor),
+            savedJobsDocumentView.widthAnchor.constraint(equalTo: savedJobsScrollView.contentView.widthAnchor)
+        ])
+    }
+
+    private func reloadSavedJobsListings() {
+        savedJobsStack.arrangedSubviews.forEach {
+            savedJobsStack.removeArrangedSubview($0)
+            $0.removeFromSuperview()
+        }
+        if savedJobOrder.isEmpty {
+            savedJobsPageSubtitleLabel.stringValue = "Save jobs from Home to see them here."
+            let empty = NSTextField(wrappingLabelWithString: "No saved jobs yet. Search on Home, then tap Save on a listing.")
+            empty.font = .systemFont(ofSize: 14, weight: .regular)
+            empty.textColor = Theme.secondaryText
+            empty.alignment = .left
+            empty.maximumNumberOfLines = 0
+            empty.translatesAutoresizingMaskIntoConstraints = false
+            savedJobsStack.addArrangedSubview(empty)
+            empty.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
+            return
+        }
+        savedJobsPageSubtitleLabel.stringValue = "\(savedJobOrder.count) saved \(savedJobOrder.count == 1 ? "position" : "positions")"
+        for job in savedJobOrder {
+            let card = makeJobListingCard(job, context: .savedJobsPage)
+            savedJobsStack.addArrangedSubview(card)
+            card.widthAnchor.constraint(equalTo: savedJobsStack.widthAnchor).isActive = true
+        }
+    }
+
+    private func isSavedJobsSidebarIndex(_ index: Int) -> Bool {
+        guard index >= 0, index < currentSidebarItems.count else { return false }
+        return currentSidebarItems[index].title == "Saved Jobs"
     }
 
     private func isHomeSidebarIndex(_ index: Int) -> Bool {
@@ -663,10 +826,17 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
     private func updateMainContentVisibility() {
         let home = isHomeSidebarIndex(selectedSidebarIndex)
+        let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
         mainOverlay.isHidden = !home
         nonHomeHost.isHidden = home
+        nonHomeGenericContainer.isHidden = savedJobs
+        savedJobsPageContainer.isHidden = !savedJobs
         if !home, selectedSidebarIndex < currentSidebarItems.count {
-            nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
+            if savedJobs {
+                reloadSavedJobsListings()
+            } else {
+                nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
+            }
         }
     }
 
@@ -950,6 +1120,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
 private final class JobPayloadButton: NSButton {
     var jobPayload: JobListing?
+    var cardContext: JobListingCardContext = .homeSearchResults
 }
 
 /// 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).