| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- //
- // ProfilesListPageView.swift
- // App for Indeed
- //
- import Cocoa
- private enum ProfilesListPalette {
- static var brandBlue: NSColor { AppDashboardTheme.brandBlue }
- static var brandBlueHover: NSColor { AppDashboardTheme.brandBlueHover }
- static var pageBackground: NSColor { AppDashboardTheme.pageBackground }
- static var cardBackground: NSColor { AppDashboardTheme.cardBackground }
- static var primaryText: NSColor { AppDashboardTheme.primaryText }
- static var secondaryText: NSColor { AppDashboardTheme.secondaryText }
- static var border: NSColor { AppDashboardTheme.border }
- static var destructive: NSColor { AppDashboardTheme.profileDestructive }
- static var ctaText: NSColor { AppDashboardTheme.proCTAText }
- }
- /// 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 titleLabel = NSTextField(labelWithString: "Profiles")
- private let subtitleLabel = NSTextField(wrappingLabelWithString: "")
- private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
- private let addButton = ProfilesPrimaryButton(title: "Add new profile", target: nil, action: nil)
- private let pendingFlowLabel = NSTextField(wrappingLabelWithString: "")
- private var appearanceObserver: NSObjectProtocol?
- /// 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()
- appearanceObserver = NotificationCenter.default.addObserver(
- forName: AppAppearanceManager.didChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- self?.applyCurrentAppearance()
- }
- applyCurrentAppearance()
- }
- deinit {
- if let appearanceObserver {
- NotificationCenter.default.removeObserver(appearanceObserver)
- }
- }
- required init?(coder: NSCoder) {
- super.init(coder: coder)
- setup()
- }
- override func viewDidChangeEffectiveAppearance() {
- super.viewDidChangeEffectiveAppearance()
- applyCurrentAppearance()
- }
- func applyCurrentAppearance() {
- layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
- titleLabel.textColor = ProfilesListPalette.primaryText
- subtitleLabel.textColor = ProfilesListPalette.secondaryText
- emptyStateLabel.textColor = ProfilesListPalette.secondaryText
- pendingFlowLabel.textColor = ProfilesListPalette.brandBlue
- addButton.applyCurrentAppearance()
- for case let row as ProfileListRowView in contentStack.arrangedSubviews {
- row.applyCurrentAppearance()
- }
- }
- 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
- titleLabel.font = .systemFont(ofSize: 22, weight: .semibold)
- subtitleLabel.stringValue = "Create and manage CV profiles. Each profile stores your details on this Mac."
- subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular)
- subtitleLabel.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: [titleLabel, subtitleLabel])
- 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 nameLabel = NSTextField(labelWithString: "")
- private let detailLabel = NSTextField(wrappingLabelWithString: "")
- 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
- }
- nameLabel.stringValue = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
- nameLabel.font = .systemFont(ofSize: 15, weight: .semibold)
- let detailParts = [profile.personal.fullName, profile.personal.email].filter { !$0.isEmpty }
- detailLabel.stringValue = detailParts.isEmpty ? "No contact details yet" : detailParts.joined(separator: " · ")
- detailLabel.font = .systemFont(ofSize: 12, weight: .regular)
- detailLabel.maximumNumberOfLines = 2
- let textStack = NSStackView(views: [nameLabel, detailLabel])
- 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)
- }
- func applyCurrentAppearance() {
- layer?.borderColor = ProfilesListPalette.border.cgColor
- layer?.backgroundColor = ProfilesListPalette.cardBackground.cgColor
- nameLabel.textColor = ProfilesListPalette.primaryText
- detailLabel.textColor = ProfilesListPalette.secondaryText
- buildCVButton.contentTintColor = ProfilesListPalette.brandBlue
- deleteButton.contentTintColor = ProfilesListPalette.destructive
- }
- }
- // 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 = ProfilesListPalette.ctaText
- focusRingType = .none
- wantsLayer = true
- layer?.cornerRadius = 8
- applyCurrentAppearance()
- }
- func applyCurrentAppearance() {
- contentTintColor = ProfilesListPalette.ctaText
- layer?.backgroundColor = (isHovering ? ProfilesListPalette.brandBlueHover : ProfilesListPalette.brandBlue).cgColor
- }
- private var isHovering = false
- 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)
- isHovering = true
- applyCurrentAppearance()
- if !didPushCursor {
- NSCursor.pointingHand.push()
- didPushCursor = true
- }
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- isHovering = false
- applyCurrentAppearance()
- if didPushCursor {
- NSCursor.pop()
- didPushCursor = false
- }
- }
- override func viewWillMove(toWindow newWindow: NSWindow?) {
- super.viewWillMove(toWindow: newWindow)
- if newWindow == nil, didPushCursor {
- NSCursor.pop()
- didPushCursor = false
- }
- }
- }
|