| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699 |
- //
- // MyProfilePageView.swift
- // App for Indeed
- //
- // Profile editor: card layout, adaptive two-column rows, and vertical scrolling
- // when the window is short. Follows the active dashboard light / dark appearance.
- //
- import Cocoa
- private enum ProfilePagePalette {
- 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 fieldFill: NSColor { AppDashboardTheme.profileFieldFill }
- 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 }
- }
- private enum ProfileThemeAppearance {
- static func refreshFormSubtree(_ root: NSView) {
- for view in root.profileSubviewsRecursive() {
- if let field = view as? NSTextField {
- if field.isEditable {
- field.textColor = ProfilePagePalette.primaryText
- if let placeholder = field.placeholderAttributedString?.string, !placeholder.isEmpty {
- field.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: ProfilePagePalette.secondaryText,
- .font: field.font ?? NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
- ]
- )
- }
- } else if let font = field.font {
- if font.pointSize >= 15 {
- field.textColor = ProfilePagePalette.primaryText
- } else {
- field.textColor = ProfilePagePalette.secondaryText
- }
- }
- }
- guard view.wantsLayer, let layer = view.layer else { continue }
- if layer.cornerRadius == 10, layer.borderWidth == 1 {
- layer.backgroundColor = ProfilePagePalette.fieldFill.cgColor
- layer.borderColor = ProfilePagePalette.border.cgColor
- } else if layer.cornerRadius == 14, layer.borderWidth == 1 {
- layer.backgroundColor = ProfilePagePalette.cardBackground.cgColor
- layer.borderColor = ProfilePagePalette.border.cgColor
- }
- }
- }
- }
- private extension NSView {
- func profileSubviewsRecursive() -> [NSView] {
- var result: [NSView] = []
- var stack: [NSView] = [self]
- while let view = stack.popLast() {
- result.append(view)
- stack.append(contentsOf: view.subviews)
- }
- return result
- }
- }
- /// Keeps profile text left-aligned and LTR so fields do not collapse to a narrow trailing strip under RTL / natural alignment.
- private enum ProfileLayoutEnforcement {
- static func applyForcedLTRSubtree(from root: NSView) {
- var stack: [NSView] = [root]
- while let view = stack.popLast() {
- applyForcedLTR(to: view)
- stack.append(contentsOf: view.subviews)
- }
- }
- static func applyForcedLTR(to view: NSView) {
- view.userInterfaceLayoutDirection = .leftToRight
- }
- static func applyLeftAlignedTextField(_ field: NSTextField) {
- applyForcedLTR(to: field)
- field.baseWritingDirection = .leftToRight
- field.alignment = .left
- if let cell = field.cell as? NSTextFieldCell {
- cell.alignment = .left
- cell.baseWritingDirection = .leftToRight
- }
- }
- static func leftAlignedParagraphStyle() -> NSParagraphStyle {
- let p = NSMutableParagraphStyle()
- p.alignment = .left
- p.baseWritingDirection = .leftToRight
- return p
- }
- }
- private extension NSStackView {
- /// For vertical stacks using `.leading` alignment (geometric left under mixed RTL), pin each arranged subview’s width to the stack so labels/fields stay full-width.
- func pinAllArrangedSubviewWidthsEqualToStackWidth() {
- for subview in arrangedSubviews {
- subview.widthAnchor.constraint(equalTo: widthAnchor).isActive = true
- }
- }
- }
- /// Two fields side‑by‑side with a true 50/50 split, or stacked full‑width when compact. Avoids `NSStackView` collapsing paired columns to a narrow strip on the trailing edge.
- private final class ProfileDualFieldRow: NSView {
- private let leftView: NSView
- private let rightView: NSView
- private let spacing: CGFloat
- private var horizontalConstraints: [NSLayoutConstraint] = []
- private var verticalConstraints: [NSLayoutConstraint] = []
- private var isCompact = false
- init(left: NSView, right: NSView, spacing: CGFloat = 12) {
- self.leftView = left
- self.rightView = right
- self.spacing = spacing
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyForcedLTR(to: self)
- addSubview(leftView)
- addSubview(rightView)
- leftView.translatesAutoresizingMaskIntoConstraints = false
- rightView.translatesAutoresizingMaskIntoConstraints = false
- leftView.setContentHuggingPriority(.defaultLow, for: .horizontal)
- rightView.setContentHuggingPriority(.defaultLow, for: .horizontal)
- leftView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- rightView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- horizontalConstraints = [
- leftView.leftAnchor.constraint(equalTo: leftAnchor),
- leftView.topAnchor.constraint(equalTo: topAnchor),
- leftView.bottomAnchor.constraint(equalTo: bottomAnchor),
- rightView.rightAnchor.constraint(equalTo: rightAnchor),
- rightView.topAnchor.constraint(equalTo: topAnchor),
- rightView.bottomAnchor.constraint(equalTo: bottomAnchor),
- leftView.rightAnchor.constraint(equalTo: rightView.leftAnchor, constant: -spacing),
- leftView.widthAnchor.constraint(equalTo: rightView.widthAnchor)
- ]
- verticalConstraints = [
- leftView.leftAnchor.constraint(equalTo: leftAnchor),
- leftView.rightAnchor.constraint(equalTo: rightAnchor),
- leftView.topAnchor.constraint(equalTo: topAnchor),
- rightView.leftAnchor.constraint(equalTo: leftAnchor),
- rightView.rightAnchor.constraint(equalTo: rightAnchor),
- rightView.topAnchor.constraint(equalTo: leftView.bottomAnchor, constant: spacing),
- rightView.bottomAnchor.constraint(equalTo: bottomAnchor)
- ]
- NSLayoutConstraint.activate(horizontalConstraints)
- }
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- func setCompact(_ compact: Bool) {
- guard compact != isCompact else { return }
- isCompact = compact
- if compact {
- NSLayoutConstraint.deactivate(horizontalConstraints)
- NSLayoutConstraint.activate(verticalConstraints)
- } else {
- NSLayoutConstraint.deactivate(verticalConstraints)
- NSLayoutConstraint.activate(horizontalConstraints)
- }
- }
- }
- final class MyProfilePageView: NSView {
- /// Below this form content width, two-column rows stack vertically.
- private static let compactFormWidth: CGFloat = 640
- private static let horizontalPageInset: CGFloat = 24
- /// Inset of form content from the card border (left/right); explicit constraints so fields stay inside the card chrome.
- private static let cardContentHorizontalInset: CGFloat = 28
- private let scrollView = NSScrollView()
- private let documentView = NSView()
- private let cardView = NSView()
- private let formStack = NSStackView()
- private let profileNameField = NSTextField()
- private let fullNameField = NSTextField()
- private let emailField = NSTextField()
- private let phoneField = NSTextField()
- private let jobTitleField = NSTextField()
- private let addressField = NSTextField()
- private let careerField = NSTextField()
- private let certificatesField = NSTextField()
- private let interestsField = NSTextField()
- private let languagesField = NSTextField()
- private let referralField = NSTextField()
- private let saveButton = ProfilePrimaryButton(title: "Save Profile →", target: nil, action: nil)
- private var nameEmailRow: ProfileDualFieldRow!
- private var phoneJobRow: ProfileDualFieldRow!
- private let topChrome = NSView()
- private let backButton = NSButton(title: "← All profiles", target: nil, action: nil)
- private let contextLabel = NSTextField(labelWithString: "")
- private var editingProfileID: UUID?
- /// Called from the back control and after a successful save (returns to the profiles list).
- var onDismiss: (() -> Void)?
- private let workExperienceRowsStack = NSStackView()
- private var workExperienceEntries: [WorkExperienceEntryView] = []
- private let educationRowsStack = NSStackView()
- private var educationEntries: [EducationEntryView] = []
- private var lastCompactLayout: Bool?
- private var referralHelperLabel: NSTextField?
- private var appearanceObserver: NSObjectProtocol?
- /// Force left-to-right geometry so profile fields span the full width even when the window uses RTL layout.
- 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 = ProfilePagePalette.pageBackground.cgColor
- cardView.layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
- cardView.layer?.borderColor = ProfilePagePalette.border.cgColor
- backButton.contentTintColor = ProfilePagePalette.brandBlue
- contextLabel.textColor = ProfilePagePalette.primaryText
- ProfileThemeAppearance.refreshFormSubtree(formStack)
- for entry in workExperienceEntries { entry.applyCurrentAppearance() }
- for entry in educationEntries { entry.applyCurrentAppearance() }
- saveButton.applyCurrentAppearance()
- }
- override func viewDidMoveToWindow() {
- super.viewDidMoveToWindow()
- guard window != nil else { return }
- ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
- needsLayout = true
- }
- override func layout() {
- super.layout()
- if let layer = cardView.layer, layer.shadowOpacity > 0 {
- let r = layer.cornerRadius
- layer.shadowPath = CGPath(roundedRect: cardView.bounds, cornerWidth: r, cornerHeight: r, transform: nil)
- }
- updateMultilinePreferredLayoutWidths()
- applyResponsiveRowsIfNeeded()
- }
- /// Wrapping `NSTextField`s report a tiny intrinsic width until `preferredMaxLayoutWidth` tracks the chrome width, which otherwise collapses the stack to a narrow trailing column.
- private func updateMultilinePreferredLayoutWidths() {
- let horizontalInset: CGFloat = 24
- applyPreferredWrapWidth(to: profileNameField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: fullNameField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: emailField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: phoneField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: jobTitleField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: addressField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: referralField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: careerField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: certificatesField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: interestsField, horizontalInset: horizontalInset)
- applyPreferredWrapWidth(to: languagesField, horizontalInset: horizontalInset)
- if let helper = referralHelperLabel, let stack = helper.superview, stack.bounds.width > 2 {
- let w = max(1, stack.bounds.width - 8)
- if abs(helper.preferredMaxLayoutWidth - w) > 0.5 {
- helper.preferredMaxLayoutWidth = w
- }
- }
- }
- private func applyPreferredWrapWidth(to field: NSTextField, horizontalInset: CGFloat) {
- guard let wrap = field.superview, wrap.bounds.width > 2 else { return }
- let w = max(1, wrap.bounds.width - horizontalInset)
- if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
- field.preferredMaxLayoutWidth = w
- }
- }
- private func setup() {
- wantsLayer = true
- layer?.backgroundColor = ProfilePagePalette.pageBackground.cgColor
- userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: self)
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: scrollView)
- scrollView.hasVerticalScroller = true
- scrollView.hasHorizontalScroller = false
- scrollView.autohidesScrollers = true
- scrollView.drawsBackground = false
- scrollView.borderType = .noBorder
- scrollView.scrollerStyle = .overlay
- scrollView.automaticallyAdjustsContentInsets = false
- scrollView.contentView.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: scrollView.contentView)
- if #available(macOS 10.11, *) {
- scrollView.horizontalScrollElasticity = .none
- }
- documentView.translatesAutoresizingMaskIntoConstraints = false
- documentView.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: documentView)
- cardView.translatesAutoresizingMaskIntoConstraints = false
- cardView.wantsLayer = true
- cardView.layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
- cardView.layer?.cornerRadius = 18
- cardView.layer?.borderWidth = 1
- cardView.layer?.borderColor = ProfilePagePalette.border.cgColor
- cardView.layer?.masksToBounds = false
- cardView.layer?.shadowColor = NSColor.black.cgColor
- cardView.layer?.shadowOpacity = 0.06
- cardView.layer?.shadowRadius = 20
- cardView.layer?.shadowOffset = CGSize(width: 0, height: 10)
- cardView.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: cardView)
- if #available(macOS 11.0, *) {
- cardView.layer?.cornerCurve = .continuous
- }
- formStack.translatesAutoresizingMaskIntoConstraints = false
- formStack.orientation = .vertical
- formStack.alignment = .leading
- formStack.distribution = .fill
- formStack.spacing = 24
- formStack.edgeInsets = NSEdgeInsets(top: 32, left: 0, bottom: 32, right: 0)
- formStack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: formStack)
- formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- topChrome.translatesAutoresizingMaskIntoConstraints = false
- topChrome.userInterfaceLayoutDirection = .leftToRight
- backButton.translatesAutoresizingMaskIntoConstraints = false
- backButton.bezelStyle = .rounded
- backButton.isBordered = false
- backButton.font = .systemFont(ofSize: 13, weight: .medium)
- backButton.contentTintColor = ProfilePagePalette.brandBlue
- backButton.target = self
- backButton.action = #selector(didTapBack)
- contextLabel.translatesAutoresizingMaskIntoConstraints = false
- contextLabel.font = .systemFont(ofSize: 15, weight: .semibold)
- contextLabel.textColor = ProfilePagePalette.primaryText
- contextLabel.stringValue = "New profile"
- contextLabel.backgroundColor = .clear
- contextLabel.isBordered = false
- contextLabel.isEditable = false
- contextLabel.isSelectable = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(contextLabel)
- topChrome.addSubview(backButton)
- topChrome.addSubview(contextLabel)
- NSLayoutConstraint.activate([
- backButton.leadingAnchor.constraint(equalTo: topChrome.leadingAnchor, constant: 4),
- backButton.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
- contextLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 10),
- contextLabel.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
- contextLabel.trailingAnchor.constraint(lessThanOrEqualTo: topChrome.trailingAnchor, constant: -8)
- ])
- addSubview(topChrome)
- addSubview(scrollView)
- scrollView.documentView = documentView
- documentView.addSubview(cardView)
- cardView.addSubview(formStack)
- NSLayoutConstraint.activate([
- topChrome.leftAnchor.constraint(equalTo: leftAnchor),
- topChrome.rightAnchor.constraint(equalTo: rightAnchor),
- topChrome.topAnchor.constraint(equalTo: topAnchor, constant: 4),
- topChrome.heightAnchor.constraint(equalToConstant: 40),
- scrollView.leftAnchor.constraint(equalTo: leftAnchor),
- scrollView.rightAnchor.constraint(equalTo: rightAnchor),
- scrollView.topAnchor.constraint(equalTo: topChrome.bottomAnchor, constant: 2),
- scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
- // Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
- 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: cardView.bottomAnchor, constant: Self.horizontalPageInset),
- // Pin both edges so the card always spans the clip width minus insets; a separate width equal to a large constant can conflict with the clip and slide the card to the trailing edge.
- cardView.leftAnchor.constraint(equalTo: documentView.leftAnchor, constant: Self.horizontalPageInset),
- cardView.rightAnchor.constraint(equalTo: documentView.rightAnchor, constant: -Self.horizontalPageInset),
- cardView.topAnchor.constraint(equalTo: documentView.topAnchor, constant: Self.horizontalPageInset),
- formStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: Self.cardContentHorizontalInset),
- formStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -Self.cardContentHorizontalInset),
- formStack.topAnchor.constraint(equalTo: cardView.topAnchor),
- formStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor)
- ])
- addFullWidthArrangedSubview(
- labeledGroup(title: "Profile Name *", field: profileNameField, placeholder: "Marketing Director Profile")
- )
- addFullWidthArrangedSubview(sectionHeading("Personal Information"))
- let nameGroup = labeledGroup(title: "Full Name *", field: fullNameField, placeholder: "John Doe")
- let emailGroup = labeledGroup(title: "Email *", field: emailField, placeholder: "john@example.com")
- nameEmailRow = ProfileDualFieldRow(left: nameGroup, right: emailGroup, spacing: 12)
- addFullWidthArrangedSubview(nameEmailRow)
- let phoneGroup = labeledGroup(title: "Phone", field: phoneField, placeholder: "+1 (555) 123-4567")
- let jobGroup = labeledGroup(title: "Job Title *", field: jobTitleField, placeholder: "Software Engineer")
- phoneJobRow = ProfileDualFieldRow(left: phoneGroup, right: jobGroup, spacing: 12)
- addFullWidthArrangedSubview(phoneJobRow)
- addFullWidthArrangedSubview(
- labeledGroup(title: "Address", field: addressField, placeholder: "123 Main St, City, State, ZIP")
- )
- addFullWidthArrangedSubview(careerSummaryBlock())
- addFullWidthArrangedSubview(horizontalSeparator())
- addFullWidthArrangedSubview(workExperienceSection())
- addFullWidthArrangedSubview(horizontalSeparator())
- addFullWidthArrangedSubview(educationSection())
- addFullWidthArrangedSubview(horizontalSeparator())
- addFullWidthArrangedSubview(
- multilineProfileBlock(
- title: "Certificates / Rewards",
- placeholder: "List your certificates and awards...",
- field: certificatesField,
- minHeight: 100
- )
- )
- addFullWidthArrangedSubview(horizontalSeparator())
- addFullWidthArrangedSubview(
- multilineProfileBlock(
- title: "Interests",
- placeholder: "List your interests and hobbies...",
- field: interestsField,
- minHeight: 100
- )
- )
- addFullWidthArrangedSubview(horizontalSeparator())
- addFullWidthArrangedSubview(
- multilineProfileBlock(
- title: "Languages",
- placeholder: "List languages you speak (e.g., English - Native, Spanish - Fluent)...",
- field: languagesField,
- minHeight: 100
- )
- )
- addFullWidthArrangedSubview(horizontalSeparator())
- addFullWidthArrangedSubview(referralBlock())
- addFullWidthArrangedSubview(saveButtonHost())
- saveButton.target = self
- saveButton.action = #selector(didTapSave)
- appendWorkExperienceEntry()
- appendEducationEntry()
- ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
- }
- func prepareNewProfile() {
- editingProfileID = nil
- contextLabel.stringValue = "New profile"
- applyForm(
- from: SavedProfile(
- id: UUID(),
- profileDisplayName: "",
- personal: .empty,
- careerSummary: "",
- workExperiences: [.empty],
- educations: [.empty],
- certificates: "",
- interests: "",
- languages: "",
- referral: ""
- )
- )
- }
- func loadSavedProfile(_ profile: SavedProfile) {
- editingProfileID = profile.id
- contextLabel.stringValue = "Edit profile"
- applyForm(from: profile)
- }
- private func applyForm(from profile: SavedProfile) {
- profileNameField.stringValue = profile.profileDisplayName
- applyPersonalInformation(profile.personal)
- careerField.stringValue = profile.careerSummary
- certificatesField.stringValue = profile.certificates
- interestsField.stringValue = profile.interests
- languagesField.stringValue = profile.languages
- referralField.stringValue = profile.referral
- let workCount = max(1, profile.workExperiences.count)
- syncWorkExperienceRowCount(to: workCount)
- if profile.workExperiences.isEmpty {
- workExperienceEntries[0].applyPayload(.empty)
- } else {
- for (i, payload) in profile.workExperiences.enumerated() where i < workExperienceEntries.count {
- workExperienceEntries[i].applyPayload(payload)
- }
- }
- let eduCount = max(1, profile.educations.count)
- syncEducationRowCount(to: eduCount)
- if profile.educations.isEmpty {
- educationEntries[0].applyPayload(.empty)
- } else {
- for (i, payload) in profile.educations.enumerated() where i < educationEntries.count {
- educationEntries[i].applyPayload(payload)
- }
- }
- lastCompactLayout = nil
- needsLayout = true
- }
- private func syncWorkExperienceRowCount(to target: Int) {
- let n = max(1, target)
- while workExperienceEntries.count < n {
- appendWorkExperienceEntry()
- }
- while workExperienceEntries.count > n {
- guard let last = workExperienceEntries.last, workExperienceEntries.count > 1 else { break }
- removeWorkExperienceEntry(last)
- }
- }
- private func syncEducationRowCount(to target: Int) {
- let n = max(1, target)
- while educationEntries.count < n {
- appendEducationEntry()
- }
- while educationEntries.count > n {
- guard let last = educationEntries.last, educationEntries.count > 1 else { break }
- removeEducationEntry(last)
- }
- }
- private func captureSavedProfileForSave() -> SavedProfile {
- let id = editingProfileID ?? UUID()
- return SavedProfile(
- id: id,
- profileDisplayName: profileNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- personal: collectPersonalInformationFromFields(),
- careerSummary: careerField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- workExperiences: workExperienceEntries.map { $0.capturePayload() },
- educations: educationEntries.map { $0.capturePayload() },
- certificates: certificatesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- interests: interestsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- languages: languagesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- referral: referralField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- )
- }
- @objc private func didTapBack() {
- onDismiss?()
- }
- private func applyPersonalInformation(_ info: PersonalInformation) {
- fullNameField.stringValue = info.fullName
- emailField.stringValue = info.email
- phoneField.stringValue = info.phone
- jobTitleField.stringValue = info.jobTitle
- addressField.stringValue = info.address
- }
- private func collectPersonalInformationFromFields() -> PersonalInformation {
- PersonalInformation(
- fullName: fullNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- email: emailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- phone: phoneField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- address: addressField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- )
- }
- private func applyResponsiveRowsIfNeeded() {
- let w = cardView.bounds.width
- guard w > 1 else { return }
- let formWidth = max(0, w - 2 * Self.cardContentHorizontalInset - formStack.edgeInsets.left - formStack.edgeInsets.right)
- let compact = formWidth < Self.compactFormWidth
- guard compact != lastCompactLayout else { return }
- lastCompactLayout = compact
- nameEmailRow.setCompact(compact)
- phoneJobRow.setCompact(compact)
- for entry in workExperienceEntries {
- entry.applyCompactLayout(compact)
- }
- for entry in educationEntries {
- entry.applyCompactLayout(compact)
- }
- }
- private func addFullWidthArrangedSubview(_ view: NSView) {
- formStack.addArrangedSubview(view)
- view.setContentHuggingPriority(.defaultLow, for: .horizontal)
- view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- view.widthAnchor.constraint(equalTo: formStack.widthAnchor).isActive = true
- }
- private func sectionHeading(_ text: String) -> NSView {
- let label = NSTextField(labelWithString: text)
- label.font = .systemFont(ofSize: 15, weight: .semibold)
- label.textColor = ProfilePagePalette.primaryText
- label.baseWritingDirection = .leftToRight
- label.translatesAutoresizingMaskIntoConstraints = false
- label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let row = NSStackView(views: [label, spacer])
- row.orientation = .horizontal
- row.alignment = .centerY
- row.distribution = .fill
- row.spacing = 0
- row.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: row)
- row.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- label.leftAnchor.constraint(equalTo: row.leftAnchor)
- ])
- return row
- }
- private func labeledGroup(title: String, field: NSTextField, placeholder: String) -> NSView {
- let label = NSTextField(labelWithString: title)
- label.font = .systemFont(ofSize: 12, weight: .medium)
- label.textColor = ProfilePagePalette.secondaryText
- label.translatesAutoresizingMaskIntoConstraints = false
- label.setContentHuggingPriority(.defaultLow, for: .horizontal)
- ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
- styleSingleLineField(field, placeholder: placeholder)
- let wrap = roundedFieldChrome(containing: field, minHeight: 40)
- let stack = NSStackView(views: [label, wrap])
- stack.orientation = .vertical
- stack.spacing = 8
- // `.leading` keeps rows on the geometric left; explicit widths keep labels/fields full-width (`.width` alone can still hug the trailing edge under RTL-style layout).
- stack.alignment = .leading
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: stack)
- stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- NSLayoutConstraint.activate([
- label.leftAnchor.constraint(equalTo: stack.leftAnchor),
- label.widthAnchor.constraint(equalTo: stack.widthAnchor),
- wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
- ])
- return stack
- }
- private func styleSingleLineField(_ field: NSTextField, placeholder: String) {
- field.translatesAutoresizingMaskIntoConstraints = false
- field.isBordered = false
- field.drawsBackground = false
- field.focusRingType = .none
- field.font = .systemFont(ofSize: 14, weight: .regular)
- field.textColor = ProfilePagePalette.primaryText
- field.setContentHuggingPriority(.defaultLow, for: .horizontal)
- field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- let paragraph = ProfileLayoutEnforcement.leftAlignedParagraphStyle()
- field.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: ProfilePagePalette.secondaryText,
- .font: NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: paragraph
- ]
- )
- field.cell?.usesSingleLineMode = true
- field.cell?.wraps = false
- field.cell?.isScrollable = true
- ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
- }
- private func roundedFieldChrome(containing field: NSTextField, minHeight: CGFloat) -> NSView {
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.wantsLayer = true
- wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
- wrap.layer?.cornerRadius = 10
- wrap.layer?.borderWidth = 1
- wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
- if #available(macOS 11.0, *) {
- wrap.layer?.cornerCurve = .continuous
- }
- wrap.addSubview(field)
- ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
- NSLayoutConstraint.activate([
- field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
- field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
- field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
- wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
- ])
- return wrap
- }
- private func careerSummaryBlock() -> NSView {
- let label = NSTextField(labelWithString: "Career Summary")
- label.font = .systemFont(ofSize: 12, weight: .medium)
- label.textColor = ProfilePagePalette.secondaryText
- label.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
- careerField.translatesAutoresizingMaskIntoConstraints = false
- careerField.isEditable = true
- careerField.isSelectable = true
- careerField.isBordered = false
- careerField.drawsBackground = false
- careerField.focusRingType = .none
- careerField.font = .systemFont(ofSize: 14, weight: .regular)
- careerField.textColor = ProfilePagePalette.primaryText
- careerField.maximumNumberOfLines = 0
- careerField.cell?.wraps = true
- careerField.cell?.isScrollable = false
- careerField.cell?.usesSingleLineMode = false
- careerField.stringValue = ""
- careerField.setContentHuggingPriority(.defaultLow, for: .horizontal)
- careerField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- careerField.placeholderAttributedString = NSAttributedString(
- string: "Brief overview of your professional background and key achievements...",
- attributes: [
- .foregroundColor: ProfilePagePalette.secondaryText,
- .font: NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
- ]
- )
- ProfileLayoutEnforcement.applyLeftAlignedTextField(careerField)
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.wantsLayer = true
- wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
- wrap.layer?.cornerRadius = 10
- wrap.layer?.borderWidth = 1
- wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
- if #available(macOS 11.0, *) {
- wrap.layer?.cornerCurve = .continuous
- }
- wrap.addSubview(careerField)
- ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
- NSLayoutConstraint.activate([
- careerField.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
- careerField.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
- careerField.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
- careerField.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
- wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: 168)
- ])
- let stack = NSStackView(views: [label, wrap])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: stack)
- stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
- NSLayoutConstraint.activate([
- label.leftAnchor.constraint(equalTo: stack.leftAnchor),
- label.widthAnchor.constraint(equalTo: stack.widthAnchor),
- wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
- ])
- return stack
- }
- private func multilineProfileBlock(title: String, placeholder: String, field: NSTextField, minHeight: CGFloat) -> NSView {
- let label = NSTextField(labelWithString: title)
- label.font = .systemFont(ofSize: 12, weight: .medium)
- label.textColor = ProfilePagePalette.secondaryText
- label.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
- field.translatesAutoresizingMaskIntoConstraints = false
- field.isEditable = true
- field.isSelectable = true
- field.isBordered = false
- field.drawsBackground = false
- field.focusRingType = .none
- field.font = .systemFont(ofSize: 14, weight: .regular)
- field.textColor = ProfilePagePalette.primaryText
- field.maximumNumberOfLines = 0
- field.cell?.wraps = true
- field.cell?.isScrollable = false
- field.cell?.usesSingleLineMode = false
- field.stringValue = ""
- field.setContentHuggingPriority(.defaultLow, for: .horizontal)
- field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- field.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: ProfilePagePalette.secondaryText,
- .font: NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
- ]
- )
- ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.wantsLayer = true
- wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
- wrap.layer?.cornerRadius = 10
- wrap.layer?.borderWidth = 1
- wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
- if #available(macOS 11.0, *) {
- wrap.layer?.cornerCurve = .continuous
- }
- wrap.addSubview(field)
- ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
- NSLayoutConstraint.activate([
- field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
- field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
- field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
- field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
- wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
- ])
- let stack = NSStackView(views: [label, wrap])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: stack)
- stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
- NSLayoutConstraint.activate([
- label.leftAnchor.constraint(equalTo: stack.leftAnchor),
- label.widthAnchor.constraint(equalTo: stack.widthAnchor),
- wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
- ])
- return stack
- }
- private func referralBlock() -> NSView {
- let label = NSTextField(labelWithString: "Referral (Optional)")
- label.font = .systemFont(ofSize: 12, weight: .medium)
- label.textColor = ProfilePagePalette.secondaryText
- label.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
- styleSingleLineField(referralField, placeholder: "Referred by (Company/Person Name)")
- let wrap = roundedFieldChrome(containing: referralField, minHeight: 40)
- let helper = NSTextField(wrappingLabelWithString: "If someone referred you for this job, enter their name or company here")
- helper.font = .systemFont(ofSize: 11, weight: .regular)
- helper.textColor = ProfilePagePalette.secondaryText
- helper.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(helper)
- referralHelperLabel = helper
- let stack = NSStackView(views: [label, wrap, helper])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: stack)
- stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
- if #available(macOS 10.11, *) {
- stack.setCustomSpacing(6, after: wrap)
- }
- NSLayoutConstraint.activate([
- label.leftAnchor.constraint(equalTo: stack.leftAnchor),
- label.widthAnchor.constraint(equalTo: stack.widthAnchor),
- wrap.widthAnchor.constraint(equalTo: stack.widthAnchor),
- helper.leftAnchor.constraint(equalTo: stack.leftAnchor),
- helper.widthAnchor.constraint(equalTo: stack.widthAnchor)
- ])
- return stack
- }
- private func horizontalSeparator() -> NSView {
- let box = NSBox()
- box.boxType = .separator
- box.translatesAutoresizingMaskIntoConstraints = false
- return box
- }
- private func workExperienceSection() -> NSView {
- let title = NSTextField(labelWithString: "Work Experience")
- title.font = .systemFont(ofSize: 15, weight: .semibold)
- title.textColor = ProfilePagePalette.primaryText
- title.translatesAutoresizingMaskIntoConstraints = false
- title.setContentHuggingPriority(.defaultLow, for: .horizontal)
- ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
- let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddWorkExperience))
- addButton.translatesAutoresizingMaskIntoConstraints = false
- addButton.bezelStyle = .rounded
- addButton.isBordered = true
- addButton.font = .systemFont(ofSize: 12, weight: .medium)
- addButton.controlSize = .regular
- addButton.setContentHuggingPriority(.required, for: .horizontal)
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let headerRow = NSStackView(views: [title, spacer, addButton])
- headerRow.orientation = .horizontal
- headerRow.alignment = .centerY
- headerRow.distribution = .fill
- headerRow.spacing = 12
- headerRow.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
- headerRow.translatesAutoresizingMaskIntoConstraints = false
- workExperienceRowsStack.translatesAutoresizingMaskIntoConstraints = false
- workExperienceRowsStack.orientation = .vertical
- workExperienceRowsStack.spacing = 20
- workExperienceRowsStack.alignment = .leading
- workExperienceRowsStack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: workExperienceRowsStack)
- let outer = NSStackView(views: [headerRow, workExperienceRowsStack])
- outer.orientation = .vertical
- outer.spacing = 16
- outer.alignment = .leading
- outer.translatesAutoresizingMaskIntoConstraints = false
- outer.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: outer)
- outer.pinAllArrangedSubviewWidthsEqualToStackWidth()
- return outer
- }
- private func educationSection() -> NSView {
- let title = NSTextField(labelWithString: "Education")
- title.font = .systemFont(ofSize: 15, weight: .semibold)
- title.textColor = ProfilePagePalette.primaryText
- title.translatesAutoresizingMaskIntoConstraints = false
- title.setContentHuggingPriority(.defaultLow, for: .horizontal)
- ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
- let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddEducation))
- addButton.translatesAutoresizingMaskIntoConstraints = false
- addButton.bezelStyle = .rounded
- addButton.isBordered = true
- addButton.font = .systemFont(ofSize: 12, weight: .medium)
- addButton.setContentHuggingPriority(.required, for: .horizontal)
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let headerRow = NSStackView(views: [title, spacer, addButton])
- headerRow.orientation = .horizontal
- headerRow.alignment = .centerY
- headerRow.distribution = .fill
- headerRow.spacing = 12
- headerRow.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
- headerRow.translatesAutoresizingMaskIntoConstraints = false
- educationRowsStack.translatesAutoresizingMaskIntoConstraints = false
- educationRowsStack.orientation = .vertical
- educationRowsStack.spacing = 16
- educationRowsStack.alignment = .leading
- educationRowsStack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: educationRowsStack)
- let outer = NSStackView(views: [headerRow, educationRowsStack])
- outer.orientation = .vertical
- outer.spacing = 16
- outer.alignment = .leading
- outer.translatesAutoresizingMaskIntoConstraints = false
- outer.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: outer)
- outer.pinAllArrangedSubviewWidthsEqualToStackWidth()
- return outer
- }
- private func appendWorkExperienceEntry() {
- let entry = WorkExperienceEntryView()
- entry.translatesAutoresizingMaskIntoConstraints = false
- if let compact = lastCompactLayout {
- entry.applyCompactLayout(compact)
- }
- entry.onDelete = { [weak self, weak entry] in
- guard let self, let entry else { return }
- self.removeWorkExperienceEntry(entry)
- }
- workExperienceEntries.append(entry)
- workExperienceRowsStack.addArrangedSubview(entry)
- entry.widthAnchor.constraint(equalTo: workExperienceRowsStack.widthAnchor).isActive = true
- renumberWorkExperienceEntries()
- refreshWorkExperienceDeleteButtons()
- }
- private func removeWorkExperienceEntry(_ entry: WorkExperienceEntryView) {
- guard workExperienceEntries.count > 1 else { return }
- workExperienceEntries.removeAll { $0 === entry }
- workExperienceRowsStack.removeArrangedSubview(entry)
- entry.removeFromSuperview()
- renumberWorkExperienceEntries()
- refreshWorkExperienceDeleteButtons()
- }
- private func renumberWorkExperienceEntries() {
- for (i, entry) in workExperienceEntries.enumerated() {
- entry.setExperienceIndex(i + 1)
- }
- }
- private func refreshWorkExperienceDeleteButtons() {
- let hide = workExperienceEntries.count <= 1
- for entry in workExperienceEntries {
- entry.setDeleteHidden(hide)
- }
- }
- private func appendEducationEntry() {
- let entry = EducationEntryView()
- entry.translatesAutoresizingMaskIntoConstraints = false
- if let compact = lastCompactLayout {
- entry.applyCompactLayout(compact)
- }
- entry.onDelete = { [weak self, weak entry] in
- guard let self, let entry else { return }
- self.removeEducationEntry(entry)
- }
- educationEntries.append(entry)
- educationRowsStack.addArrangedSubview(entry)
- entry.widthAnchor.constraint(equalTo: educationRowsStack.widthAnchor).isActive = true
- renumberEducationEntries()
- refreshEducationDeleteButtons()
- }
- private func removeEducationEntry(_ entry: EducationEntryView) {
- guard educationEntries.count > 1 else { return }
- educationEntries.removeAll { $0 === entry }
- educationRowsStack.removeArrangedSubview(entry)
- entry.removeFromSuperview()
- renumberEducationEntries()
- refreshEducationDeleteButtons()
- }
- private func renumberEducationEntries() {
- for (i, entry) in educationEntries.enumerated() {
- entry.setEducationIndex(i + 1)
- }
- }
- private func refreshEducationDeleteButtons() {
- let hide = educationEntries.count <= 1
- for entry in educationEntries {
- entry.setDeleteHidden(hide)
- }
- }
- @objc private func didTapAddWorkExperience() {
- appendWorkExperienceEntry()
- }
- @objc private func didTapAddEducation() {
- appendEducationEntry()
- }
- private func saveButtonHost() -> NSView {
- saveButton.translatesAutoresizingMaskIntoConstraints = false
- saveButton.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let host = NSView()
- host.translatesAutoresizingMaskIntoConstraints = false
- host.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: host)
- host.addSubview(saveButton)
- let preferredWidth = saveButton.widthAnchor.constraint(equalToConstant: 292)
- preferredWidth.priority = .defaultHigh
- NSLayoutConstraint.activate([
- saveButton.centerXAnchor.constraint(equalTo: host.centerXAnchor),
- saveButton.topAnchor.constraint(equalTo: host.topAnchor),
- saveButton.bottomAnchor.constraint(equalTo: host.bottomAnchor),
- saveButton.heightAnchor.constraint(equalToConstant: 54),
- preferredWidth,
- saveButton.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor)
- ])
- return host
- }
- @objc private func didTapSave() {
- window?.makeFirstResponder(nil)
- let profile = captureSavedProfileForSave()
- var missing: [String] = []
- if profile.profileDisplayName.isEmpty { missing.append("Profile name") }
- if profile.personal.fullName.isEmpty { missing.append("Full Name") }
- if profile.personal.email.isEmpty { missing.append("Email") }
- if profile.personal.jobTitle.isEmpty { missing.append("Job Title") }
- guard missing.isEmpty else {
- let alert = NSAlert()
- alert.messageText = "Complete required fields"
- alert.informativeText = "Please fill in: " + missing.joined(separator: ", ") + "."
- alert.alertStyle = .informational
- alert.addButton(withTitle: "OK")
- if let window = window {
- alert.beginSheetModal(for: window) { _ in }
- } else {
- alert.runModal()
- }
- return
- }
- SavedProfilesStore.upsert(profile)
- editingProfileID = profile.id
- onDismiss?()
- }
- }
- // MARK: - Work experience & education rows
- private enum ProfileEntryCardLayout {
- /// Horizontal inset of fields inside each work/education entry card (matches main profile form).
- static let horizontalInset: CGFloat = 28
- }
- private final class WorkExperienceEntryView: NSView {
- var onDelete: (() -> Void)?
- private let subtitleLabel = NSTextField(labelWithString: "Experience 1")
- private let deleteButton = NSButton()
- private let jobTitleField = NSTextField()
- private let companyField = NSTextField()
- private let durationField = NSTextField()
- private let descriptionField = NSTextField()
- private var jobCompanyRow: ProfileDualFieldRow!
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- configure()
- }
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- func setExperienceIndex(_ index: Int) {
- subtitleLabel.stringValue = "Experience \(index)"
- }
- func setDeleteHidden(_ hidden: Bool) {
- deleteButton.isHidden = hidden
- }
- func applyCompactLayout(_ compact: Bool) {
- jobCompanyRow.setCompact(compact)
- }
- func capturePayload() -> WorkExperiencePayload {
- WorkExperiencePayload(
- jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- company: companyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- duration: durationField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- description: descriptionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- )
- }
- func applyPayload(_ payload: WorkExperiencePayload) {
- jobTitleField.stringValue = payload.jobTitle
- companyField.stringValue = payload.company
- durationField.stringValue = payload.duration
- descriptionField.stringValue = payload.description
- }
- private func configure() {
- userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: self)
- wantsLayer = true
- layer?.cornerRadius = 14
- layer?.borderWidth = 1
- layer?.borderColor = ProfilePagePalette.border.cgColor
- layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
- layer?.masksToBounds = false
- layer?.shadowColor = NSColor.black.cgColor
- layer?.shadowOpacity = 0.05
- layer?.shadowRadius = 12
- layer?.shadowOffset = CGSize(width: 0, height: 6)
- if #available(macOS 11.0, *) {
- layer?.cornerCurve = .continuous
- }
- subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
- subtitleLabel.textColor = ProfilePagePalette.secondaryText
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
- deleteButton.translatesAutoresizingMaskIntoConstraints = false
- deleteButton.isBordered = false
- deleteButton.bezelStyle = .regularSquare
- deleteButton.focusRingType = .none
- deleteButton.contentTintColor = ProfilePagePalette.destructive
- deleteButton.target = self
- deleteButton.action = #selector(didTapDelete)
- if #available(macOS 11.0, *) {
- deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove experience")
- deleteButton.imagePosition = .imageOnly
- } else {
- deleteButton.title = "Remove"
- deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
- }
- let headerSpacer = NSView()
- headerSpacer.translatesAutoresizingMaskIntoConstraints = false
- headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
- headerRow.orientation = .horizontal
- headerRow.alignment = .centerY
- headerRow.distribution = .fill
- headerRow.spacing = 8
- headerRow.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
- headerRow.translatesAutoresizingMaskIntoConstraints = false
- let jobGroup = Self.labeledFieldStack(title: "Job Title *", field: jobTitleField, placeholder: "e.g., Software Engineer")
- let companyGroup = Self.labeledFieldStack(title: "Company Name *", field: companyField, placeholder: "e.g., Google")
- jobCompanyRow = ProfileDualFieldRow(left: jobGroup, right: companyGroup, spacing: 12)
- let durationGroup = Self.labeledFieldStack(title: "Duration *", field: durationField, placeholder: "e.g., Jan 2020 - Present")
- let descriptionGroup = Self.multilineLabeledStack(
- title: "Description",
- field: descriptionField,
- placeholder: "Describe your responsibilities and achievements...",
- minHeight: 120
- )
- let inner = NSStackView(views: [headerRow, jobCompanyRow, durationGroup, descriptionGroup])
- inner.orientation = .vertical
- inner.spacing = 16
- inner.alignment = .leading
- inner.translatesAutoresizingMaskIntoConstraints = false
- inner.edgeInsets = NSEdgeInsets()
- inner.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: inner)
- inner.pinAllArrangedSubviewWidthsEqualToStackWidth()
- addSubview(inner)
- NSLayoutConstraint.activate([
- inner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ProfileEntryCardLayout.horizontalInset),
- inner.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ProfileEntryCardLayout.horizontalInset),
- inner.topAnchor.constraint(equalTo: topAnchor, constant: 16),
- inner.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
- ])
- }
- override func layout() {
- super.layout()
- for field in [jobTitleField, companyField, durationField, descriptionField] {
- if let wrap = field.superview, wrap.bounds.width > 2 {
- let w = max(1, wrap.bounds.width - 24)
- if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
- field.preferredMaxLayoutWidth = w
- }
- }
- }
- guard let layer = layer, layer.shadowOpacity > 0 else { return }
- let r = layer.cornerRadius
- layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
- }
- @objc private func didTapDelete() {
- onDelete?()
- }
- func applyCurrentAppearance() {
- layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
- layer?.borderColor = ProfilePagePalette.border.cgColor
- subtitleLabel.textColor = ProfilePagePalette.secondaryText
- deleteButton.contentTintColor = ProfilePagePalette.destructive
- ProfileThemeAppearance.refreshFormSubtree(self)
- }
- fileprivate static func labeledFieldStack(title: String, field: NSTextField, placeholder: String) -> NSView {
- let label = NSTextField(labelWithString: title)
- label.font = .systemFont(ofSize: 12, weight: .medium)
- label.textColor = ProfilePagePalette.secondaryText
- label.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
- styleSingleLineField(field, placeholder: placeholder)
- let wrap = roundedChrome(around: field, minHeight: 40)
- let stack = NSStackView(views: [label, wrap])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: stack)
- stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- NSLayoutConstraint.activate([
- label.leftAnchor.constraint(equalTo: stack.leftAnchor),
- label.widthAnchor.constraint(equalTo: stack.widthAnchor),
- wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
- ])
- return stack
- }
- private static func multilineLabeledStack(title: String, field: NSTextField, placeholder: String, minHeight: CGFloat) -> NSView {
- let label = NSTextField(labelWithString: title)
- label.font = .systemFont(ofSize: 12, weight: .medium)
- label.textColor = ProfilePagePalette.secondaryText
- label.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
- field.translatesAutoresizingMaskIntoConstraints = false
- field.isEditable = true
- field.isSelectable = true
- field.isBordered = false
- field.drawsBackground = false
- field.focusRingType = .none
- field.font = .systemFont(ofSize: 14, weight: .regular)
- field.textColor = ProfilePagePalette.primaryText
- field.maximumNumberOfLines = 0
- field.cell?.wraps = true
- field.cell?.isScrollable = false
- field.cell?.usesSingleLineMode = false
- field.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: ProfilePagePalette.secondaryText,
- .font: NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
- ]
- )
- ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.wantsLayer = true
- wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
- wrap.layer?.cornerRadius = 10
- wrap.layer?.borderWidth = 1
- wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
- if #available(macOS 11.0, *) {
- wrap.layer?.cornerCurve = .continuous
- }
- wrap.addSubview(field)
- ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
- NSLayoutConstraint.activate([
- field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
- field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
- field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
- field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
- wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
- ])
- let stack = NSStackView(views: [label, wrap])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: stack)
- stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
- wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
- NSLayoutConstraint.activate([
- label.leftAnchor.constraint(equalTo: stack.leftAnchor),
- label.widthAnchor.constraint(equalTo: stack.widthAnchor),
- wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
- ])
- return stack
- }
- private static func styleSingleLineField(_ field: NSTextField, placeholder: String) {
- field.translatesAutoresizingMaskIntoConstraints = false
- field.isBordered = false
- field.drawsBackground = false
- field.focusRingType = .none
- field.font = .systemFont(ofSize: 14, weight: .regular)
- field.textColor = ProfilePagePalette.primaryText
- field.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: ProfilePagePalette.secondaryText,
- .font: NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
- ]
- )
- field.cell?.usesSingleLineMode = true
- field.cell?.wraps = false
- field.cell?.isScrollable = true
- ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
- }
- private static func roundedChrome(around field: NSTextField, minHeight: CGFloat) -> NSView {
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.wantsLayer = true
- wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
- wrap.layer?.cornerRadius = 10
- wrap.layer?.borderWidth = 1
- wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
- if #available(macOS 11.0, *) {
- wrap.layer?.cornerCurve = .continuous
- }
- wrap.addSubview(field)
- ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
- NSLayoutConstraint.activate([
- field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
- field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
- field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
- wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
- ])
- return wrap
- }
- }
- private final class EducationEntryView: NSView {
- var onDelete: (() -> Void)?
- private let subtitleLabel = NSTextField(labelWithString: "Education 1")
- private let deleteButton = NSButton()
- private let degreeField = NSTextField()
- private let institutionField = NSTextField()
- private let yearField = NSTextField()
- private var degreeInstitutionRow: ProfileDualFieldRow!
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- configure()
- }
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- func setEducationIndex(_ index: Int) {
- subtitleLabel.stringValue = "Education \(index)"
- }
- func setDeleteHidden(_ hidden: Bool) {
- deleteButton.isHidden = hidden
- }
- func applyCompactLayout(_ compact: Bool) {
- degreeInstitutionRow.setCompact(compact)
- }
- func capturePayload() -> EducationPayload {
- EducationPayload(
- degree: degreeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- institution: institutionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
- year: yearField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- )
- }
- func applyPayload(_ payload: EducationPayload) {
- degreeField.stringValue = payload.degree
- institutionField.stringValue = payload.institution
- yearField.stringValue = payload.year
- }
- private func configure() {
- userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: self)
- wantsLayer = true
- layer?.cornerRadius = 14
- layer?.borderWidth = 1
- layer?.borderColor = ProfilePagePalette.border.cgColor
- layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
- layer?.masksToBounds = false
- layer?.shadowColor = NSColor.black.cgColor
- layer?.shadowOpacity = 0.04
- layer?.shadowRadius = 10
- layer?.shadowOffset = CGSize(width: 0, height: 5)
- if #available(macOS 11.0, *) {
- layer?.cornerCurve = .continuous
- }
- subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
- subtitleLabel.textColor = ProfilePagePalette.secondaryText
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
- deleteButton.translatesAutoresizingMaskIntoConstraints = false
- deleteButton.isBordered = false
- deleteButton.bezelStyle = .regularSquare
- deleteButton.focusRingType = .none
- deleteButton.contentTintColor = ProfilePagePalette.destructive
- deleteButton.target = self
- deleteButton.action = #selector(didTapDelete)
- if #available(macOS 11.0, *) {
- deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove education")
- deleteButton.imagePosition = .imageOnly
- } else {
- deleteButton.title = "Remove"
- deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
- }
- let headerSpacer = NSView()
- headerSpacer.translatesAutoresizingMaskIntoConstraints = false
- headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
- headerRow.orientation = .horizontal
- headerRow.alignment = .centerY
- headerRow.distribution = .fill
- headerRow.spacing = 8
- headerRow.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
- headerRow.translatesAutoresizingMaskIntoConstraints = false
- let degreeGroup = WorkExperienceEntryView.labeledFieldStack(
- title: "Degree / program *",
- field: degreeField,
- placeholder: "e.g., BSc Computer Science"
- )
- let institutionGroup = WorkExperienceEntryView.labeledFieldStack(
- title: "Institution *",
- field: institutionField,
- placeholder: "e.g., MIT"
- )
- degreeInstitutionRow = ProfileDualFieldRow(left: degreeGroup, right: institutionGroup, spacing: 12)
- let yearGroup = WorkExperienceEntryView.labeledFieldStack(
- title: "Year *",
- field: yearField,
- placeholder: "e.g., 2020"
- )
- let inner = NSStackView(views: [headerRow, degreeInstitutionRow, yearGroup])
- inner.orientation = .vertical
- inner.spacing = 14
- inner.alignment = .leading
- inner.translatesAutoresizingMaskIntoConstraints = false
- inner.edgeInsets = NSEdgeInsets()
- inner.userInterfaceLayoutDirection = .leftToRight
- ProfileLayoutEnforcement.applyForcedLTR(to: inner)
- inner.pinAllArrangedSubviewWidthsEqualToStackWidth()
- addSubview(inner)
- NSLayoutConstraint.activate([
- inner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ProfileEntryCardLayout.horizontalInset),
- inner.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ProfileEntryCardLayout.horizontalInset),
- inner.topAnchor.constraint(equalTo: topAnchor, constant: 14),
- inner.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14)
- ])
- }
- override func layout() {
- super.layout()
- for field in [degreeField, institutionField, yearField] {
- if let wrap = field.superview, wrap.bounds.width > 2 {
- let w = max(1, wrap.bounds.width - 24)
- if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
- field.preferredMaxLayoutWidth = w
- }
- }
- }
- guard let layer = layer, layer.shadowOpacity > 0 else { return }
- let r = layer.cornerRadius
- layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
- }
- @objc private func didTapDelete() {
- onDelete?()
- }
- func applyCurrentAppearance() {
- layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
- layer?.borderColor = ProfilePagePalette.border.cgColor
- subtitleLabel.textColor = ProfilePagePalette.secondaryText
- deleteButton.contentTintColor = ProfilePagePalette.destructive
- ProfileThemeAppearance.refreshFormSubtree(self)
- }
- }
- // MARK: - Primary CTA
- private final class ProfilePrimaryButton: NSButton {
- private var trackingArea: NSTrackingArea?
- private var didPushCursor = false
- private var isHovering = 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() {
- bezelStyle = .rounded
- isBordered = false
- font = .systemFont(ofSize: 16, weight: .semibold)
- contentTintColor = ProfilePagePalette.ctaText
- wantsLayer = true
- layer?.cornerRadius = 14
- if #available(macOS 11.0, *) {
- layer?.cornerCurve = .continuous
- }
- applyCurrentAppearance()
- }
- func applyCurrentAppearance() {
- contentTintColor = ProfilePagePalette.ctaText
- layer?.backgroundColor = (isHovering ? ProfilePagePalette.brandBlueHover : ProfilePagePalette.brandBlue).cgColor
- }
- 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 {
- isHovering = false
- if didPushCursor {
- NSCursor.pop()
- didPushCursor = false
- }
- }
- }
- }
|