| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- //
- // ProfilesListPageView.swift
- // App for Indeed
- //
- import Cocoa
- private enum ProfilesListPalette {
- static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
- static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
- static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
- static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
- static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
- static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
- static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
- static let destructive = NSColor(srgbRed: 220 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1)
- }
- /// Document view for the profiles `NSScrollView`; flipped coordinates keep short content aligned to the top of the clip (avoids a large empty band above the content on macOS).
- private final class ProfilesListDocumentView: NSView {
- override var isFlipped: Bool { true }
- }
- /// Hub for saved job profiles: list, add, edit (opens editor elsewhere), delete.
- final class ProfilesListPageView: NSView {
- var onAddProfile: (() -> Void)?
- var onEditProfile: ((UUID) -> Void)?
- var onDeleteProfile: ((UUID) -> Void)?
- /// Fired when the user taps **Build CV** on a row while a CV Maker template is pending.
- var onBuildCVWithProfile: ((UUID) -> Void)?
- private let scrollView = NSScrollView()
- private let documentView = ProfilesListDocumentView()
- private let contentStack = NSStackView()
- private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
- private let addButton = ProfilesPrimaryButton(title: "Add new profile", target: nil, action: nil)
- private let pendingFlowLabel = NSTextField(wrappingLabelWithString: "")
- /// Non-`nil` after **Use Template & Select Profile** until the dashboard clears the flow (e.g. leaving Profile).
- private var pendingCVTemplateDisplayName: String?
- override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
- get { .leftToRight }
- set { super.userInterfaceLayoutDirection = .leftToRight }
- }
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- setup()
- }
- required init?(coder: NSCoder) {
- super.init(coder: coder)
- setup()
- }
- func reloadFromStore() {
- for row in contentStack.arrangedSubviews {
- contentStack.removeArrangedSubview(row)
- row.removeFromSuperview()
- }
- let profiles = SavedProfilesStore.loadAll()
- emptyStateLabel.isHidden = !profiles.isEmpty
- let showBuildCV = pendingCVTemplateDisplayName != nil
- for profile in profiles {
- let row = ProfileListRowView(profile: profile, showBuildCV: showBuildCV)
- row.translatesAutoresizingMaskIntoConstraints = false
- row.onEdit = { [weak self] id in self?.onEditProfile?(id) }
- row.onDelete = { [weak self] id in self?.onDeleteProfile?(id) }
- row.onBuildCV = { [weak self] id in self?.onBuildCVWithProfile?(id) }
- contentStack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- }
- }
- /// Shows the CV Maker hand-off banner and per-profile **Build CV** actions, or clears them when `nil`.
- func setPendingCVTemplateDisplayName(_ name: String?) {
- pendingCVTemplateDisplayName = name
- if let name, !name.isEmpty {
- pendingFlowLabel.stringValue = "You chose the “\(name)” template. Tap Build CV on a profile to preview your résumé with that layout."
- pendingFlowLabel.isHidden = false
- } else {
- pendingFlowLabel.isHidden = true
- }
- reloadFromStore()
- }
- private func setup() {
- wantsLayer = true
- layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
- userInterfaceLayoutDirection = .leftToRight
- let title = NSTextField(labelWithString: "Profiles")
- title.font = .systemFont(ofSize: 22, weight: .semibold)
- title.textColor = ProfilesListPalette.primaryText
- let subtitle = NSTextField(wrappingLabelWithString: "Create and manage CV profiles. Each profile stores your details on this Mac.")
- subtitle.font = .systemFont(ofSize: 13, weight: .regular)
- subtitle.textColor = ProfilesListPalette.secondaryText
- subtitle.maximumNumberOfLines = 0
- emptyStateLabel.stringValue = "No profiles yet. Tap “Add new profile” to create your first one."
- emptyStateLabel.font = .systemFont(ofSize: 13, weight: .regular)
- emptyStateLabel.textColor = ProfilesListPalette.secondaryText
- emptyStateLabel.isHidden = true
- addButton.target = self
- addButton.action = #selector(didTapAdd)
- addButton.translatesAutoresizingMaskIntoConstraints = false
- addButton.setContentHuggingPriority(.required, for: .horizontal)
- addButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- NSLayoutConstraint.activate([
- addButton.heightAnchor.constraint(equalToConstant: 32),
- addButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76)
- ])
- let titleSubtitleStack = NSStackView(views: [title, subtitle])
- titleSubtitleStack.orientation = .vertical
- titleSubtitleStack.alignment = .leading
- titleSubtitleStack.spacing = 10
- titleSubtitleStack.translatesAutoresizingMaskIntoConstraints = false
- pendingFlowLabel.font = .systemFont(ofSize: 13, weight: .medium)
- pendingFlowLabel.textColor = ProfilesListPalette.brandBlue
- pendingFlowLabel.maximumNumberOfLines = 0
- pendingFlowLabel.isHidden = true
- let pageHeaderStack = NSStackView(views: [titleSubtitleStack, pendingFlowLabel])
- pageHeaderStack.orientation = .vertical
- pageHeaderStack.alignment = .leading
- pageHeaderStack.spacing = 12
- pageHeaderStack.translatesAutoresizingMaskIntoConstraints = false
- let footerStack = NSStackView(views: [emptyStateLabel, addButton])
- footerStack.orientation = .vertical
- footerStack.alignment = .leading
- footerStack.spacing = 10
- footerStack.translatesAutoresizingMaskIntoConstraints = false
- footerStack.setCustomSpacing(16, after: emptyStateLabel)
- contentStack.orientation = .vertical
- contentStack.alignment = .leading
- contentStack.spacing = 12
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- documentView.translatesAutoresizingMaskIntoConstraints = false
- documentView.addSubview(pageHeaderStack)
- documentView.addSubview(contentStack)
- documentView.addSubview(footerStack)
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.drawsBackground = false
- scrollView.hasVerticalScroller = true
- scrollView.hasHorizontalScroller = false
- scrollView.autohidesScrollers = true
- scrollView.borderType = .noBorder
- scrollView.documentView = documentView
- addSubview(scrollView)
- NSLayoutConstraint.activate([
- scrollView.leftAnchor.constraint(equalTo: leftAnchor),
- scrollView.rightAnchor.constraint(equalTo: rightAnchor),
- scrollView.topAnchor.constraint(equalTo: topAnchor),
- scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
- documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
- documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
- documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
- documentView.bottomAnchor.constraint(equalTo: footerStack.bottomAnchor, constant: 32),
- pageHeaderStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
- pageHeaderStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
- pageHeaderStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
- contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
- contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
- contentStack.topAnchor.constraint(equalTo: pageHeaderStack.bottomAnchor, constant: 24),
- footerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
- footerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
- footerStack.topAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 24),
- pageHeaderStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
- contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
- footerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
- ])
- reloadFromStore()
- }
- @objc private func didTapAdd() {
- onAddProfile?()
- }
- }
- // MARK: - Row
- private final class ProfileListRowView: NSView {
- var onEdit: ((UUID) -> Void)?
- var onDelete: ((UUID) -> Void)?
- var onBuildCV: ((UUID) -> Void)?
- private let profileID: UUID
- private let buildCVButton = NSButton(title: "Build CV", target: nil, action: nil)
- private let editButton = NSButton(title: "Edit", target: nil, action: nil)
- private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
- init(profile: SavedProfile, showBuildCV: Bool) {
- self.profileID = profile.id
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 14
- layer?.borderWidth = 1
- layer?.borderColor = ProfilesListPalette.border.cgColor
- layer?.backgroundColor = ProfilesListPalette.cardBackground.cgColor
- if #available(macOS 11.0, *) {
- layer?.cornerCurve = .continuous
- }
- let name = NSTextField(labelWithString: profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName)
- name.font = .systemFont(ofSize: 15, weight: .semibold)
- name.textColor = ProfilesListPalette.primaryText
- let detailParts = [profile.personal.fullName, profile.personal.email].filter { !$0.isEmpty }
- let detail = NSTextField(wrappingLabelWithString: detailParts.isEmpty ? "No contact details yet" : detailParts.joined(separator: " · "))
- detail.font = .systemFont(ofSize: 12, weight: .regular)
- detail.textColor = ProfilesListPalette.secondaryText
- detail.maximumNumberOfLines = 2
- let textStack = NSStackView(views: [name, detail])
- textStack.orientation = .vertical
- textStack.alignment = .leading
- textStack.spacing = 4
- textStack.translatesAutoresizingMaskIntoConstraints = false
- buildCVButton.translatesAutoresizingMaskIntoConstraints = false
- buildCVButton.bezelStyle = .rounded
- buildCVButton.isBordered = true
- buildCVButton.font = .systemFont(ofSize: 12, weight: .semibold)
- buildCVButton.contentTintColor = ProfilesListPalette.brandBlue
- buildCVButton.target = self
- buildCVButton.action = #selector(didTapBuildCV)
- buildCVButton.isHidden = !showBuildCV
- editButton.translatesAutoresizingMaskIntoConstraints = false
- editButton.bezelStyle = .rounded
- editButton.isBordered = true
- editButton.font = .systemFont(ofSize: 12, weight: .medium)
- editButton.target = self
- editButton.action = #selector(didTapEdit)
- deleteButton.translatesAutoresizingMaskIntoConstraints = false
- deleteButton.bezelStyle = .rounded
- deleteButton.isBordered = false
- deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
- deleteButton.contentTintColor = ProfilesListPalette.destructive
- deleteButton.target = self
- deleteButton.action = #selector(didTapDelete)
- let spacer = NSView()
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let actionViews: [NSView] = showBuildCV ? [buildCVButton, editButton, deleteButton] : [editButton, deleteButton]
- let actions = NSStackView(views: actionViews)
- actions.orientation = .horizontal
- actions.spacing = 8
- actions.alignment = .centerY
- actions.translatesAutoresizingMaskIntoConstraints = false
- let row = NSStackView(views: [textStack, spacer, actions])
- row.orientation = .horizontal
- row.alignment = .top
- row.spacing = 16
- row.translatesAutoresizingMaskIntoConstraints = false
- addSubview(row)
- NSLayoutConstraint.activate([
- row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
- row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
- row.topAnchor.constraint(equalTo: topAnchor, constant: 16),
- row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
- ])
- }
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- @objc private func didTapEdit() {
- onEdit?(profileID)
- }
- @objc private func didTapBuildCV() {
- onBuildCV?(profileID)
- }
- @objc private func didTapDelete() {
- onDelete?(profileID)
- }
- }
- // MARK: - Primary CTA (matches job cards’ Apply: 13pt semibold, 32pt tall, 8pt corners)
- private final class ProfilesPrimaryButton: NSButton {
- private static let horizontalOutset: CGFloat = 20
- private var trackingArea: NSTrackingArea?
- private var didPushCursor = false
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- commonInit()
- }
- required init?(coder: NSCoder) {
- super.init(coder: coder)
- commonInit()
- }
- convenience init(title: String, target: AnyObject?, action: Selector?) {
- self.init(frame: .zero)
- self.title = title
- self.target = target
- self.action = action
- }
- private func commonInit() {
- translatesAutoresizingMaskIntoConstraints = false
- bezelStyle = .rounded
- isBordered = false
- font = .systemFont(ofSize: 13, weight: .semibold)
- contentTintColor = .white
- focusRingType = .none
- wantsLayer = true
- layer?.cornerRadius = 8
- layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
- }
- override var intrinsicContentSize: NSSize {
- let base = super.intrinsicContentSize
- guard base.width != NSView.noIntrinsicMetric, base.width >= 1 else { return base }
- return NSSize(width: base.width + Self.horizontalOutset, height: base.height)
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let trackingArea { removeTrackingArea(trackingArea) }
- let area = NSTrackingArea(
- rect: bounds,
- options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- addTrackingArea(area)
- trackingArea = area
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- layer?.backgroundColor = ProfilesListPalette.brandBlueHover.cgColor
- if !didPushCursor {
- NSCursor.pointingHand.push()
- didPushCursor = true
- }
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
- if didPushCursor {
- NSCursor.pop()
- didPushCursor = false
- }
- }
- override func viewWillMove(toWindow newWindow: NSWindow?) {
- super.viewWillMove(toWindow: newWindow)
- if newWindow == nil, didPushCursor {
- NSCursor.pop()
- didPushCursor = false
- }
- }
- }
|