// // 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 } } }