No Description

MyProfilePageView.swift 59KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367
  1. //
  2. // MyProfilePageView.swift
  3. // App for Indeed
  4. //
  5. // Light-theme profile editor: card layout, adaptive two-column rows, and
  6. // vertical scrolling when the window is short.
  7. //
  8. import Cocoa
  9. private enum ProfilePagePalette {
  10. static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  11. static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  12. static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  13. static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
  14. static let fieldFill = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
  15. static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
  16. static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
  17. static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
  18. static let destructive = NSColor(srgbRed: 220 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1)
  19. }
  20. /// Keeps profile text left-aligned and LTR so fields do not collapse to a narrow trailing strip under RTL / natural alignment.
  21. private enum ProfileLayoutEnforcement {
  22. static func applyForcedLTRSubtree(from root: NSView) {
  23. var stack: [NSView] = [root]
  24. while let view = stack.popLast() {
  25. applyForcedLTR(to: view)
  26. stack.append(contentsOf: view.subviews)
  27. }
  28. }
  29. static func applyForcedLTR(to view: NSView) {
  30. view.userInterfaceLayoutDirection = .leftToRight
  31. }
  32. static func applyLeftAlignedTextField(_ field: NSTextField) {
  33. applyForcedLTR(to: field)
  34. field.baseWritingDirection = .leftToRight
  35. field.alignment = .left
  36. if let cell = field.cell as? NSTextFieldCell {
  37. cell.alignment = .left
  38. cell.baseWritingDirection = .leftToRight
  39. }
  40. }
  41. static func leftAlignedParagraphStyle() -> NSParagraphStyle {
  42. let p = NSMutableParagraphStyle()
  43. p.alignment = .left
  44. p.baseWritingDirection = .leftToRight
  45. return p
  46. }
  47. }
  48. private extension NSStackView {
  49. /// 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.
  50. func pinAllArrangedSubviewWidthsEqualToStackWidth() {
  51. for subview in arrangedSubviews {
  52. subview.widthAnchor.constraint(equalTo: widthAnchor).isActive = true
  53. }
  54. }
  55. }
  56. /// 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.
  57. private final class ProfileDualFieldRow: NSView {
  58. private let leftView: NSView
  59. private let rightView: NSView
  60. private let spacing: CGFloat
  61. private var horizontalConstraints: [NSLayoutConstraint] = []
  62. private var verticalConstraints: [NSLayoutConstraint] = []
  63. private var isCompact = false
  64. init(left: NSView, right: NSView, spacing: CGFloat = 12) {
  65. self.leftView = left
  66. self.rightView = right
  67. self.spacing = spacing
  68. super.init(frame: .zero)
  69. translatesAutoresizingMaskIntoConstraints = false
  70. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  71. addSubview(leftView)
  72. addSubview(rightView)
  73. leftView.translatesAutoresizingMaskIntoConstraints = false
  74. rightView.translatesAutoresizingMaskIntoConstraints = false
  75. leftView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  76. rightView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  77. leftView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  78. rightView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  79. horizontalConstraints = [
  80. leftView.leftAnchor.constraint(equalTo: leftAnchor),
  81. leftView.topAnchor.constraint(equalTo: topAnchor),
  82. leftView.bottomAnchor.constraint(equalTo: bottomAnchor),
  83. rightView.rightAnchor.constraint(equalTo: rightAnchor),
  84. rightView.topAnchor.constraint(equalTo: topAnchor),
  85. rightView.bottomAnchor.constraint(equalTo: bottomAnchor),
  86. leftView.rightAnchor.constraint(equalTo: rightView.leftAnchor, constant: -spacing),
  87. leftView.widthAnchor.constraint(equalTo: rightView.widthAnchor)
  88. ]
  89. verticalConstraints = [
  90. leftView.leftAnchor.constraint(equalTo: leftAnchor),
  91. leftView.rightAnchor.constraint(equalTo: rightAnchor),
  92. leftView.topAnchor.constraint(equalTo: topAnchor),
  93. rightView.leftAnchor.constraint(equalTo: leftAnchor),
  94. rightView.rightAnchor.constraint(equalTo: rightAnchor),
  95. rightView.topAnchor.constraint(equalTo: leftView.bottomAnchor, constant: spacing),
  96. rightView.bottomAnchor.constraint(equalTo: bottomAnchor)
  97. ]
  98. NSLayoutConstraint.activate(horizontalConstraints)
  99. }
  100. required init?(coder: NSCoder) {
  101. fatalError("init(coder:) has not been implemented")
  102. }
  103. func setCompact(_ compact: Bool) {
  104. guard compact != isCompact else { return }
  105. isCompact = compact
  106. if compact {
  107. NSLayoutConstraint.deactivate(horizontalConstraints)
  108. NSLayoutConstraint.activate(verticalConstraints)
  109. } else {
  110. NSLayoutConstraint.deactivate(verticalConstraints)
  111. NSLayoutConstraint.activate(horizontalConstraints)
  112. }
  113. }
  114. }
  115. final class MyProfilePageView: NSView {
  116. /// Below this form content width, two-column rows stack vertically.
  117. private static let compactFormWidth: CGFloat = 640
  118. private static let horizontalPageInset: CGFloat = 24
  119. /// Inset of form content from the card border (left/right); explicit constraints so fields stay inside the card chrome.
  120. private static let cardContentHorizontalInset: CGFloat = 28
  121. private let scrollView = NSScrollView()
  122. private let documentView = NSView()
  123. private let cardView = NSView()
  124. private let formStack = NSStackView()
  125. private let profileNameField = NSTextField()
  126. private let fullNameField = NSTextField()
  127. private let emailField = NSTextField()
  128. private let phoneField = NSTextField()
  129. private let jobTitleField = NSTextField()
  130. private let addressField = NSTextField()
  131. private let careerField = NSTextField()
  132. private let certificatesField = NSTextField()
  133. private let interestsField = NSTextField()
  134. private let languagesField = NSTextField()
  135. private let referralField = NSTextField()
  136. private let saveButton = ProfilePrimaryButton(title: "Save Profile →", target: nil, action: nil)
  137. private var nameEmailRow: ProfileDualFieldRow!
  138. private var phoneJobRow: ProfileDualFieldRow!
  139. private let workExperienceRowsStack = NSStackView()
  140. private var workExperienceEntries: [WorkExperienceEntryView] = []
  141. private let educationRowsStack = NSStackView()
  142. private var educationEntries: [EducationEntryView] = []
  143. private var lastCompactLayout: Bool?
  144. private var referralHelperLabel: NSTextField?
  145. /// Force left-to-right geometry so profile fields span the full width even when the window uses RTL layout.
  146. override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
  147. get { .leftToRight }
  148. set { super.userInterfaceLayoutDirection = .leftToRight }
  149. }
  150. override init(frame frameRect: NSRect) {
  151. super.init(frame: frameRect)
  152. setup()
  153. }
  154. required init?(coder: NSCoder) {
  155. super.init(coder: coder)
  156. setup()
  157. }
  158. override func viewDidMoveToWindow() {
  159. super.viewDidMoveToWindow()
  160. guard window != nil else { return }
  161. ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
  162. needsLayout = true
  163. }
  164. override func layout() {
  165. super.layout()
  166. if let layer = cardView.layer, layer.shadowOpacity > 0 {
  167. let r = layer.cornerRadius
  168. layer.shadowPath = CGPath(roundedRect: cardView.bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  169. }
  170. updateMultilinePreferredLayoutWidths()
  171. applyResponsiveRowsIfNeeded()
  172. }
  173. /// Wrapping `NSTextField`s report a tiny intrinsic width until `preferredMaxLayoutWidth` tracks the chrome width, which otherwise collapses the stack to a narrow trailing column.
  174. private func updateMultilinePreferredLayoutWidths() {
  175. let horizontalInset: CGFloat = 24
  176. applyPreferredWrapWidth(to: profileNameField, horizontalInset: horizontalInset)
  177. applyPreferredWrapWidth(to: fullNameField, horizontalInset: horizontalInset)
  178. applyPreferredWrapWidth(to: emailField, horizontalInset: horizontalInset)
  179. applyPreferredWrapWidth(to: phoneField, horizontalInset: horizontalInset)
  180. applyPreferredWrapWidth(to: jobTitleField, horizontalInset: horizontalInset)
  181. applyPreferredWrapWidth(to: addressField, horizontalInset: horizontalInset)
  182. applyPreferredWrapWidth(to: referralField, horizontalInset: horizontalInset)
  183. applyPreferredWrapWidth(to: careerField, horizontalInset: horizontalInset)
  184. applyPreferredWrapWidth(to: certificatesField, horizontalInset: horizontalInset)
  185. applyPreferredWrapWidth(to: interestsField, horizontalInset: horizontalInset)
  186. applyPreferredWrapWidth(to: languagesField, horizontalInset: horizontalInset)
  187. if let helper = referralHelperLabel, let stack = helper.superview, stack.bounds.width > 2 {
  188. let w = max(1, stack.bounds.width - 8)
  189. if abs(helper.preferredMaxLayoutWidth - w) > 0.5 {
  190. helper.preferredMaxLayoutWidth = w
  191. }
  192. }
  193. }
  194. private func applyPreferredWrapWidth(to field: NSTextField, horizontalInset: CGFloat) {
  195. guard let wrap = field.superview, wrap.bounds.width > 2 else { return }
  196. let w = max(1, wrap.bounds.width - horizontalInset)
  197. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  198. field.preferredMaxLayoutWidth = w
  199. }
  200. }
  201. private func setup() {
  202. wantsLayer = true
  203. layer?.backgroundColor = ProfilePagePalette.pageBackground.cgColor
  204. userInterfaceLayoutDirection = .leftToRight
  205. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  206. scrollView.translatesAutoresizingMaskIntoConstraints = false
  207. scrollView.userInterfaceLayoutDirection = .leftToRight
  208. ProfileLayoutEnforcement.applyForcedLTR(to: scrollView)
  209. scrollView.hasVerticalScroller = true
  210. scrollView.hasHorizontalScroller = false
  211. scrollView.autohidesScrollers = true
  212. scrollView.drawsBackground = false
  213. scrollView.borderType = .noBorder
  214. scrollView.scrollerStyle = .overlay
  215. scrollView.automaticallyAdjustsContentInsets = false
  216. scrollView.contentView.userInterfaceLayoutDirection = .leftToRight
  217. ProfileLayoutEnforcement.applyForcedLTR(to: scrollView.contentView)
  218. if #available(macOS 10.11, *) {
  219. scrollView.horizontalScrollElasticity = .none
  220. }
  221. documentView.translatesAutoresizingMaskIntoConstraints = false
  222. documentView.userInterfaceLayoutDirection = .leftToRight
  223. ProfileLayoutEnforcement.applyForcedLTR(to: documentView)
  224. cardView.translatesAutoresizingMaskIntoConstraints = false
  225. cardView.wantsLayer = true
  226. cardView.layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  227. cardView.layer?.cornerRadius = 18
  228. cardView.layer?.borderWidth = 1
  229. cardView.layer?.borderColor = ProfilePagePalette.border.cgColor
  230. cardView.layer?.masksToBounds = false
  231. cardView.layer?.shadowColor = NSColor.black.cgColor
  232. cardView.layer?.shadowOpacity = 0.06
  233. cardView.layer?.shadowRadius = 20
  234. cardView.layer?.shadowOffset = CGSize(width: 0, height: 10)
  235. cardView.userInterfaceLayoutDirection = .leftToRight
  236. ProfileLayoutEnforcement.applyForcedLTR(to: cardView)
  237. if #available(macOS 11.0, *) {
  238. cardView.layer?.cornerCurve = .continuous
  239. }
  240. formStack.translatesAutoresizingMaskIntoConstraints = false
  241. formStack.orientation = .vertical
  242. formStack.alignment = .leading
  243. formStack.distribution = .fill
  244. formStack.spacing = 24
  245. formStack.edgeInsets = NSEdgeInsets(top: 32, left: 0, bottom: 32, right: 0)
  246. formStack.userInterfaceLayoutDirection = .leftToRight
  247. ProfileLayoutEnforcement.applyForcedLTR(to: formStack)
  248. formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  249. formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  250. addSubview(scrollView)
  251. scrollView.documentView = documentView
  252. documentView.addSubview(cardView)
  253. cardView.addSubview(formStack)
  254. NSLayoutConstraint.activate([
  255. scrollView.leftAnchor.constraint(equalTo: leftAnchor),
  256. scrollView.rightAnchor.constraint(equalTo: rightAnchor),
  257. scrollView.topAnchor.constraint(equalTo: topAnchor),
  258. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  259. // Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
  260. documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
  261. documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
  262. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  263. documentView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: Self.horizontalPageInset),
  264. // 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.
  265. cardView.leftAnchor.constraint(equalTo: documentView.leftAnchor, constant: Self.horizontalPageInset),
  266. cardView.rightAnchor.constraint(equalTo: documentView.rightAnchor, constant: -Self.horizontalPageInset),
  267. cardView.topAnchor.constraint(equalTo: documentView.topAnchor, constant: Self.horizontalPageInset),
  268. formStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: Self.cardContentHorizontalInset),
  269. formStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -Self.cardContentHorizontalInset),
  270. formStack.topAnchor.constraint(equalTo: cardView.topAnchor),
  271. formStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor)
  272. ])
  273. addFullWidthArrangedSubview(
  274. labeledGroup(title: "Profile Name *", field: profileNameField, placeholder: "Marketing Director Profile")
  275. )
  276. addFullWidthArrangedSubview(sectionHeading("Personal Information"))
  277. let nameGroup = labeledGroup(title: "Full Name *", field: fullNameField, placeholder: "John Doe")
  278. let emailGroup = labeledGroup(title: "Email *", field: emailField, placeholder: "john@example.com")
  279. nameEmailRow = ProfileDualFieldRow(left: nameGroup, right: emailGroup, spacing: 12)
  280. addFullWidthArrangedSubview(nameEmailRow)
  281. let phoneGroup = labeledGroup(title: "Phone", field: phoneField, placeholder: "+1 (555) 123-4567")
  282. let jobGroup = labeledGroup(title: "Job Title *", field: jobTitleField, placeholder: "Software Engineer")
  283. phoneJobRow = ProfileDualFieldRow(left: phoneGroup, right: jobGroup, spacing: 12)
  284. addFullWidthArrangedSubview(phoneJobRow)
  285. addFullWidthArrangedSubview(
  286. labeledGroup(title: "Address", field: addressField, placeholder: "123 Main St, City, State, ZIP")
  287. )
  288. addFullWidthArrangedSubview(careerSummaryBlock())
  289. addFullWidthArrangedSubview(horizontalSeparator())
  290. addFullWidthArrangedSubview(workExperienceSection())
  291. addFullWidthArrangedSubview(horizontalSeparator())
  292. addFullWidthArrangedSubview(educationSection())
  293. addFullWidthArrangedSubview(horizontalSeparator())
  294. addFullWidthArrangedSubview(
  295. multilineProfileBlock(
  296. title: "Certificates / Rewards",
  297. placeholder: "List your certificates and awards...",
  298. field: certificatesField,
  299. minHeight: 100
  300. )
  301. )
  302. addFullWidthArrangedSubview(horizontalSeparator())
  303. addFullWidthArrangedSubview(
  304. multilineProfileBlock(
  305. title: "Interests",
  306. placeholder: "List your interests and hobbies...",
  307. field: interestsField,
  308. minHeight: 100
  309. )
  310. )
  311. addFullWidthArrangedSubview(horizontalSeparator())
  312. addFullWidthArrangedSubview(
  313. multilineProfileBlock(
  314. title: "Languages",
  315. placeholder: "List languages you speak (e.g., English - Native, Spanish - Fluent)...",
  316. field: languagesField,
  317. minHeight: 100
  318. )
  319. )
  320. addFullWidthArrangedSubview(horizontalSeparator())
  321. addFullWidthArrangedSubview(referralBlock())
  322. addFullWidthArrangedSubview(saveButtonHost())
  323. saveButton.target = self
  324. saveButton.action = #selector(didTapSave)
  325. appendWorkExperienceEntry()
  326. appendEducationEntry()
  327. ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
  328. }
  329. private func applyResponsiveRowsIfNeeded() {
  330. let w = cardView.bounds.width
  331. guard w > 1 else { return }
  332. let formWidth = max(0, w - 2 * Self.cardContentHorizontalInset - formStack.edgeInsets.left - formStack.edgeInsets.right)
  333. let compact = formWidth < Self.compactFormWidth
  334. guard compact != lastCompactLayout else { return }
  335. lastCompactLayout = compact
  336. nameEmailRow.setCompact(compact)
  337. phoneJobRow.setCompact(compact)
  338. for entry in workExperienceEntries {
  339. entry.applyCompactLayout(compact)
  340. }
  341. for entry in educationEntries {
  342. entry.applyCompactLayout(compact)
  343. }
  344. }
  345. private func addFullWidthArrangedSubview(_ view: NSView) {
  346. formStack.addArrangedSubview(view)
  347. view.setContentHuggingPriority(.defaultLow, for: .horizontal)
  348. view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  349. view.widthAnchor.constraint(equalTo: formStack.widthAnchor).isActive = true
  350. }
  351. private func sectionHeading(_ text: String) -> NSView {
  352. let label = NSTextField(labelWithString: text)
  353. label.font = .systemFont(ofSize: 15, weight: .semibold)
  354. label.textColor = ProfilePagePalette.primaryText
  355. label.baseWritingDirection = .leftToRight
  356. label.translatesAutoresizingMaskIntoConstraints = false
  357. label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  358. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  359. let spacer = NSView()
  360. spacer.translatesAutoresizingMaskIntoConstraints = false
  361. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  362. let row = NSStackView(views: [label, spacer])
  363. row.orientation = .horizontal
  364. row.alignment = .centerY
  365. row.distribution = .fill
  366. row.spacing = 0
  367. row.userInterfaceLayoutDirection = .leftToRight
  368. ProfileLayoutEnforcement.applyForcedLTR(to: row)
  369. row.translatesAutoresizingMaskIntoConstraints = false
  370. NSLayoutConstraint.activate([
  371. label.leftAnchor.constraint(equalTo: row.leftAnchor)
  372. ])
  373. return row
  374. }
  375. private func labeledGroup(title: String, field: NSTextField, placeholder: String) -> NSView {
  376. let label = NSTextField(labelWithString: title)
  377. label.font = .systemFont(ofSize: 12, weight: .medium)
  378. label.textColor = ProfilePagePalette.secondaryText
  379. label.translatesAutoresizingMaskIntoConstraints = false
  380. label.setContentHuggingPriority(.defaultLow, for: .horizontal)
  381. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  382. styleSingleLineField(field, placeholder: placeholder)
  383. let wrap = roundedFieldChrome(containing: field, minHeight: 40)
  384. let stack = NSStackView(views: [label, wrap])
  385. stack.orientation = .vertical
  386. stack.spacing = 8
  387. // `.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).
  388. stack.alignment = .leading
  389. stack.translatesAutoresizingMaskIntoConstraints = false
  390. stack.userInterfaceLayoutDirection = .leftToRight
  391. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  392. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  393. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  394. wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  395. NSLayoutConstraint.activate([
  396. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  397. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  398. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  399. ])
  400. return stack
  401. }
  402. private func styleSingleLineField(_ field: NSTextField, placeholder: String) {
  403. field.translatesAutoresizingMaskIntoConstraints = false
  404. field.isBordered = false
  405. field.drawsBackground = false
  406. field.focusRingType = .none
  407. field.font = .systemFont(ofSize: 14, weight: .regular)
  408. field.textColor = ProfilePagePalette.primaryText
  409. field.setContentHuggingPriority(.defaultLow, for: .horizontal)
  410. field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  411. let paragraph = ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  412. field.placeholderAttributedString = NSAttributedString(
  413. string: placeholder,
  414. attributes: [
  415. .foregroundColor: ProfilePagePalette.secondaryText,
  416. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  417. .paragraphStyle: paragraph
  418. ]
  419. )
  420. field.cell?.usesSingleLineMode = true
  421. field.cell?.wraps = false
  422. field.cell?.isScrollable = true
  423. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  424. }
  425. private func roundedFieldChrome(containing field: NSTextField, minHeight: CGFloat) -> NSView {
  426. let wrap = NSView()
  427. wrap.translatesAutoresizingMaskIntoConstraints = false
  428. wrap.wantsLayer = true
  429. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  430. wrap.layer?.cornerRadius = 10
  431. wrap.layer?.borderWidth = 1
  432. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  433. if #available(macOS 11.0, *) {
  434. wrap.layer?.cornerCurve = .continuous
  435. }
  436. wrap.addSubview(field)
  437. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  438. NSLayoutConstraint.activate([
  439. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  440. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  441. field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
  442. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  443. ])
  444. return wrap
  445. }
  446. private func careerSummaryBlock() -> NSView {
  447. let label = NSTextField(labelWithString: "Career Summary")
  448. label.font = .systemFont(ofSize: 12, weight: .medium)
  449. label.textColor = ProfilePagePalette.secondaryText
  450. label.translatesAutoresizingMaskIntoConstraints = false
  451. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  452. careerField.translatesAutoresizingMaskIntoConstraints = false
  453. careerField.isEditable = true
  454. careerField.isSelectable = true
  455. careerField.isBordered = false
  456. careerField.drawsBackground = false
  457. careerField.focusRingType = .none
  458. careerField.font = .systemFont(ofSize: 14, weight: .regular)
  459. careerField.textColor = ProfilePagePalette.primaryText
  460. careerField.maximumNumberOfLines = 0
  461. careerField.cell?.wraps = true
  462. careerField.cell?.isScrollable = false
  463. careerField.cell?.usesSingleLineMode = false
  464. careerField.stringValue = ""
  465. careerField.setContentHuggingPriority(.defaultLow, for: .horizontal)
  466. careerField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  467. careerField.placeholderAttributedString = NSAttributedString(
  468. string: "Brief overview of your professional background and key achievements...",
  469. attributes: [
  470. .foregroundColor: ProfilePagePalette.secondaryText,
  471. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  472. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  473. ]
  474. )
  475. ProfileLayoutEnforcement.applyLeftAlignedTextField(careerField)
  476. let wrap = NSView()
  477. wrap.translatesAutoresizingMaskIntoConstraints = false
  478. wrap.wantsLayer = true
  479. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  480. wrap.layer?.cornerRadius = 10
  481. wrap.layer?.borderWidth = 1
  482. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  483. if #available(macOS 11.0, *) {
  484. wrap.layer?.cornerCurve = .continuous
  485. }
  486. wrap.addSubview(careerField)
  487. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  488. NSLayoutConstraint.activate([
  489. careerField.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  490. careerField.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  491. careerField.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  492. careerField.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  493. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: 168)
  494. ])
  495. let stack = NSStackView(views: [label, wrap])
  496. stack.orientation = .vertical
  497. stack.spacing = 8
  498. stack.alignment = .leading
  499. stack.translatesAutoresizingMaskIntoConstraints = false
  500. stack.userInterfaceLayoutDirection = .leftToRight
  501. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  502. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  503. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  504. NSLayoutConstraint.activate([
  505. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  506. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  507. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  508. ])
  509. return stack
  510. }
  511. private func multilineProfileBlock(title: String, placeholder: String, field: NSTextField, minHeight: CGFloat) -> NSView {
  512. let label = NSTextField(labelWithString: title)
  513. label.font = .systemFont(ofSize: 12, weight: .medium)
  514. label.textColor = ProfilePagePalette.secondaryText
  515. label.translatesAutoresizingMaskIntoConstraints = false
  516. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  517. field.translatesAutoresizingMaskIntoConstraints = false
  518. field.isEditable = true
  519. field.isSelectable = true
  520. field.isBordered = false
  521. field.drawsBackground = false
  522. field.focusRingType = .none
  523. field.font = .systemFont(ofSize: 14, weight: .regular)
  524. field.textColor = ProfilePagePalette.primaryText
  525. field.maximumNumberOfLines = 0
  526. field.cell?.wraps = true
  527. field.cell?.isScrollable = false
  528. field.cell?.usesSingleLineMode = false
  529. field.stringValue = ""
  530. field.setContentHuggingPriority(.defaultLow, for: .horizontal)
  531. field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  532. field.placeholderAttributedString = NSAttributedString(
  533. string: placeholder,
  534. attributes: [
  535. .foregroundColor: ProfilePagePalette.secondaryText,
  536. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  537. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  538. ]
  539. )
  540. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  541. let wrap = NSView()
  542. wrap.translatesAutoresizingMaskIntoConstraints = false
  543. wrap.wantsLayer = true
  544. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  545. wrap.layer?.cornerRadius = 10
  546. wrap.layer?.borderWidth = 1
  547. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  548. if #available(macOS 11.0, *) {
  549. wrap.layer?.cornerCurve = .continuous
  550. }
  551. wrap.addSubview(field)
  552. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  553. NSLayoutConstraint.activate([
  554. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  555. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  556. field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  557. field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  558. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  559. ])
  560. let stack = NSStackView(views: [label, wrap])
  561. stack.orientation = .vertical
  562. stack.spacing = 8
  563. stack.alignment = .leading
  564. stack.translatesAutoresizingMaskIntoConstraints = false
  565. stack.userInterfaceLayoutDirection = .leftToRight
  566. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  567. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  568. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  569. NSLayoutConstraint.activate([
  570. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  571. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  572. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  573. ])
  574. return stack
  575. }
  576. private func referralBlock() -> NSView {
  577. let label = NSTextField(labelWithString: "Referral (Optional)")
  578. label.font = .systemFont(ofSize: 12, weight: .medium)
  579. label.textColor = ProfilePagePalette.secondaryText
  580. label.translatesAutoresizingMaskIntoConstraints = false
  581. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  582. styleSingleLineField(referralField, placeholder: "Referred by (Company/Person Name)")
  583. let wrap = roundedFieldChrome(containing: referralField, minHeight: 40)
  584. let helper = NSTextField(wrappingLabelWithString: "If someone referred you for this job, enter their name or company here")
  585. helper.font = .systemFont(ofSize: 11, weight: .regular)
  586. helper.textColor = ProfilePagePalette.secondaryText
  587. helper.translatesAutoresizingMaskIntoConstraints = false
  588. ProfileLayoutEnforcement.applyLeftAlignedTextField(helper)
  589. referralHelperLabel = helper
  590. let stack = NSStackView(views: [label, wrap, helper])
  591. stack.orientation = .vertical
  592. stack.spacing = 8
  593. stack.alignment = .leading
  594. stack.translatesAutoresizingMaskIntoConstraints = false
  595. stack.userInterfaceLayoutDirection = .leftToRight
  596. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  597. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  598. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  599. if #available(macOS 10.11, *) {
  600. stack.setCustomSpacing(6, after: wrap)
  601. }
  602. NSLayoutConstraint.activate([
  603. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  604. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  605. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor),
  606. helper.leftAnchor.constraint(equalTo: stack.leftAnchor),
  607. helper.widthAnchor.constraint(equalTo: stack.widthAnchor)
  608. ])
  609. return stack
  610. }
  611. private func horizontalSeparator() -> NSView {
  612. let box = NSBox()
  613. box.boxType = .separator
  614. box.translatesAutoresizingMaskIntoConstraints = false
  615. return box
  616. }
  617. private func workExperienceSection() -> NSView {
  618. let title = NSTextField(labelWithString: "Work Experience")
  619. title.font = .systemFont(ofSize: 15, weight: .semibold)
  620. title.textColor = ProfilePagePalette.primaryText
  621. title.translatesAutoresizingMaskIntoConstraints = false
  622. title.setContentHuggingPriority(.defaultLow, for: .horizontal)
  623. ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
  624. let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddWorkExperience))
  625. addButton.translatesAutoresizingMaskIntoConstraints = false
  626. addButton.bezelStyle = .rounded
  627. addButton.isBordered = true
  628. addButton.font = .systemFont(ofSize: 12, weight: .medium)
  629. addButton.controlSize = .regular
  630. addButton.setContentHuggingPriority(.required, for: .horizontal)
  631. let spacer = NSView()
  632. spacer.translatesAutoresizingMaskIntoConstraints = false
  633. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  634. let headerRow = NSStackView(views: [title, spacer, addButton])
  635. headerRow.orientation = .horizontal
  636. headerRow.alignment = .centerY
  637. headerRow.distribution = .fill
  638. headerRow.spacing = 12
  639. headerRow.userInterfaceLayoutDirection = .leftToRight
  640. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  641. headerRow.translatesAutoresizingMaskIntoConstraints = false
  642. workExperienceRowsStack.translatesAutoresizingMaskIntoConstraints = false
  643. workExperienceRowsStack.orientation = .vertical
  644. workExperienceRowsStack.spacing = 20
  645. workExperienceRowsStack.alignment = .leading
  646. workExperienceRowsStack.userInterfaceLayoutDirection = .leftToRight
  647. ProfileLayoutEnforcement.applyForcedLTR(to: workExperienceRowsStack)
  648. let outer = NSStackView(views: [headerRow, workExperienceRowsStack])
  649. outer.orientation = .vertical
  650. outer.spacing = 16
  651. outer.alignment = .leading
  652. outer.translatesAutoresizingMaskIntoConstraints = false
  653. outer.userInterfaceLayoutDirection = .leftToRight
  654. ProfileLayoutEnforcement.applyForcedLTR(to: outer)
  655. outer.pinAllArrangedSubviewWidthsEqualToStackWidth()
  656. return outer
  657. }
  658. private func educationSection() -> NSView {
  659. let title = NSTextField(labelWithString: "Education")
  660. title.font = .systemFont(ofSize: 15, weight: .semibold)
  661. title.textColor = ProfilePagePalette.primaryText
  662. title.translatesAutoresizingMaskIntoConstraints = false
  663. title.setContentHuggingPriority(.defaultLow, for: .horizontal)
  664. ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
  665. let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddEducation))
  666. addButton.translatesAutoresizingMaskIntoConstraints = false
  667. addButton.bezelStyle = .rounded
  668. addButton.isBordered = true
  669. addButton.font = .systemFont(ofSize: 12, weight: .medium)
  670. addButton.setContentHuggingPriority(.required, for: .horizontal)
  671. let spacer = NSView()
  672. spacer.translatesAutoresizingMaskIntoConstraints = false
  673. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  674. let headerRow = NSStackView(views: [title, spacer, addButton])
  675. headerRow.orientation = .horizontal
  676. headerRow.alignment = .centerY
  677. headerRow.distribution = .fill
  678. headerRow.spacing = 12
  679. headerRow.userInterfaceLayoutDirection = .leftToRight
  680. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  681. headerRow.translatesAutoresizingMaskIntoConstraints = false
  682. educationRowsStack.translatesAutoresizingMaskIntoConstraints = false
  683. educationRowsStack.orientation = .vertical
  684. educationRowsStack.spacing = 16
  685. educationRowsStack.alignment = .leading
  686. educationRowsStack.userInterfaceLayoutDirection = .leftToRight
  687. ProfileLayoutEnforcement.applyForcedLTR(to: educationRowsStack)
  688. let outer = NSStackView(views: [headerRow, educationRowsStack])
  689. outer.orientation = .vertical
  690. outer.spacing = 16
  691. outer.alignment = .leading
  692. outer.translatesAutoresizingMaskIntoConstraints = false
  693. outer.userInterfaceLayoutDirection = .leftToRight
  694. ProfileLayoutEnforcement.applyForcedLTR(to: outer)
  695. outer.pinAllArrangedSubviewWidthsEqualToStackWidth()
  696. return outer
  697. }
  698. private func appendWorkExperienceEntry() {
  699. let entry = WorkExperienceEntryView()
  700. entry.translatesAutoresizingMaskIntoConstraints = false
  701. if let compact = lastCompactLayout {
  702. entry.applyCompactLayout(compact)
  703. }
  704. entry.onDelete = { [weak self, weak entry] in
  705. guard let self, let entry else { return }
  706. self.removeWorkExperienceEntry(entry)
  707. }
  708. workExperienceEntries.append(entry)
  709. workExperienceRowsStack.addArrangedSubview(entry)
  710. entry.widthAnchor.constraint(equalTo: workExperienceRowsStack.widthAnchor).isActive = true
  711. renumberWorkExperienceEntries()
  712. refreshWorkExperienceDeleteButtons()
  713. }
  714. private func removeWorkExperienceEntry(_ entry: WorkExperienceEntryView) {
  715. guard workExperienceEntries.count > 1 else { return }
  716. workExperienceEntries.removeAll { $0 === entry }
  717. workExperienceRowsStack.removeArrangedSubview(entry)
  718. entry.removeFromSuperview()
  719. renumberWorkExperienceEntries()
  720. refreshWorkExperienceDeleteButtons()
  721. }
  722. private func renumberWorkExperienceEntries() {
  723. for (i, entry) in workExperienceEntries.enumerated() {
  724. entry.setExperienceIndex(i + 1)
  725. }
  726. }
  727. private func refreshWorkExperienceDeleteButtons() {
  728. let hide = workExperienceEntries.count <= 1
  729. for entry in workExperienceEntries {
  730. entry.setDeleteHidden(hide)
  731. }
  732. }
  733. private func appendEducationEntry() {
  734. let entry = EducationEntryView()
  735. entry.translatesAutoresizingMaskIntoConstraints = false
  736. if let compact = lastCompactLayout {
  737. entry.applyCompactLayout(compact)
  738. }
  739. entry.onDelete = { [weak self, weak entry] in
  740. guard let self, let entry else { return }
  741. self.removeEducationEntry(entry)
  742. }
  743. educationEntries.append(entry)
  744. educationRowsStack.addArrangedSubview(entry)
  745. entry.widthAnchor.constraint(equalTo: educationRowsStack.widthAnchor).isActive = true
  746. renumberEducationEntries()
  747. refreshEducationDeleteButtons()
  748. }
  749. private func removeEducationEntry(_ entry: EducationEntryView) {
  750. guard educationEntries.count > 1 else { return }
  751. educationEntries.removeAll { $0 === entry }
  752. educationRowsStack.removeArrangedSubview(entry)
  753. entry.removeFromSuperview()
  754. renumberEducationEntries()
  755. refreshEducationDeleteButtons()
  756. }
  757. private func renumberEducationEntries() {
  758. for (i, entry) in educationEntries.enumerated() {
  759. entry.setEducationIndex(i + 1)
  760. }
  761. }
  762. private func refreshEducationDeleteButtons() {
  763. let hide = educationEntries.count <= 1
  764. for entry in educationEntries {
  765. entry.setDeleteHidden(hide)
  766. }
  767. }
  768. @objc private func didTapAddWorkExperience() {
  769. appendWorkExperienceEntry()
  770. }
  771. @objc private func didTapAddEducation() {
  772. appendEducationEntry()
  773. }
  774. private func saveButtonHost() -> NSView {
  775. saveButton.translatesAutoresizingMaskIntoConstraints = false
  776. let host = NSView()
  777. host.translatesAutoresizingMaskIntoConstraints = false
  778. host.userInterfaceLayoutDirection = .leftToRight
  779. ProfileLayoutEnforcement.applyForcedLTR(to: host)
  780. host.addSubview(saveButton)
  781. NSLayoutConstraint.activate([
  782. saveButton.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  783. saveButton.topAnchor.constraint(equalTo: host.topAnchor),
  784. saveButton.bottomAnchor.constraint(equalTo: host.bottomAnchor),
  785. saveButton.heightAnchor.constraint(equalToConstant: 48),
  786. saveButton.trailingAnchor.constraint(lessThanOrEqualTo: host.trailingAnchor)
  787. ])
  788. return host
  789. }
  790. @objc private func didTapSave() {
  791. // UI shell only; wire persistence when profiles are stored.
  792. }
  793. }
  794. // MARK: - Work experience & education rows
  795. private final class WorkExperienceEntryView: NSView {
  796. var onDelete: (() -> Void)?
  797. private let subtitleLabel = NSTextField(labelWithString: "Experience 1")
  798. private let deleteButton = NSButton()
  799. private let jobTitleField = NSTextField()
  800. private let companyField = NSTextField()
  801. private let durationField = NSTextField()
  802. private let descriptionField = NSTextField()
  803. private var jobCompanyRow: ProfileDualFieldRow!
  804. override init(frame frameRect: NSRect) {
  805. super.init(frame: frameRect)
  806. configure()
  807. }
  808. required init?(coder: NSCoder) {
  809. fatalError("init(coder:) has not been implemented")
  810. }
  811. func setExperienceIndex(_ index: Int) {
  812. subtitleLabel.stringValue = "Experience \(index)"
  813. }
  814. func setDeleteHidden(_ hidden: Bool) {
  815. deleteButton.isHidden = hidden
  816. }
  817. func applyCompactLayout(_ compact: Bool) {
  818. jobCompanyRow.setCompact(compact)
  819. }
  820. private func configure() {
  821. userInterfaceLayoutDirection = .leftToRight
  822. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  823. wantsLayer = true
  824. layer?.cornerRadius = 14
  825. layer?.borderWidth = 1
  826. layer?.borderColor = ProfilePagePalette.border.cgColor
  827. layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  828. layer?.masksToBounds = false
  829. layer?.shadowColor = NSColor.black.cgColor
  830. layer?.shadowOpacity = 0.05
  831. layer?.shadowRadius = 12
  832. layer?.shadowOffset = CGSize(width: 0, height: 6)
  833. if #available(macOS 11.0, *) {
  834. layer?.cornerCurve = .continuous
  835. }
  836. subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
  837. subtitleLabel.textColor = ProfilePagePalette.secondaryText
  838. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  839. ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
  840. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  841. deleteButton.isBordered = false
  842. deleteButton.bezelStyle = .regularSquare
  843. deleteButton.focusRingType = .none
  844. deleteButton.contentTintColor = ProfilePagePalette.destructive
  845. deleteButton.target = self
  846. deleteButton.action = #selector(didTapDelete)
  847. if #available(macOS 11.0, *) {
  848. deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove experience")
  849. deleteButton.imagePosition = .imageOnly
  850. } else {
  851. deleteButton.title = "Remove"
  852. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  853. }
  854. let headerSpacer = NSView()
  855. headerSpacer.translatesAutoresizingMaskIntoConstraints = false
  856. headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  857. let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
  858. headerRow.orientation = .horizontal
  859. headerRow.alignment = .centerY
  860. headerRow.distribution = .fill
  861. headerRow.spacing = 8
  862. headerRow.userInterfaceLayoutDirection = .leftToRight
  863. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  864. headerRow.translatesAutoresizingMaskIntoConstraints = false
  865. let jobGroup = Self.labeledFieldStack(title: "Job Title *", field: jobTitleField, placeholder: "e.g., Software Engineer")
  866. let companyGroup = Self.labeledFieldStack(title: "Company Name *", field: companyField, placeholder: "e.g., Google")
  867. jobCompanyRow = ProfileDualFieldRow(left: jobGroup, right: companyGroup, spacing: 12)
  868. let durationGroup = Self.labeledFieldStack(title: "Duration *", field: durationField, placeholder: "e.g., Jan 2020 - Present")
  869. let descriptionGroup = Self.multilineLabeledStack(
  870. title: "Description",
  871. field: descriptionField,
  872. placeholder: "Describe your responsibilities and achievements...",
  873. minHeight: 120
  874. )
  875. let inner = NSStackView(views: [headerRow, jobCompanyRow, durationGroup, descriptionGroup])
  876. inner.orientation = .vertical
  877. inner.spacing = 16
  878. inner.alignment = .leading
  879. inner.translatesAutoresizingMaskIntoConstraints = false
  880. inner.edgeInsets = NSEdgeInsets(top: 16, left: 18, bottom: 16, right: 18)
  881. inner.userInterfaceLayoutDirection = .leftToRight
  882. ProfileLayoutEnforcement.applyForcedLTR(to: inner)
  883. inner.pinAllArrangedSubviewWidthsEqualToStackWidth()
  884. addSubview(inner)
  885. NSLayoutConstraint.activate([
  886. inner.leftAnchor.constraint(equalTo: leftAnchor),
  887. inner.rightAnchor.constraint(equalTo: rightAnchor),
  888. inner.topAnchor.constraint(equalTo: topAnchor),
  889. inner.bottomAnchor.constraint(equalTo: bottomAnchor)
  890. ])
  891. }
  892. override func layout() {
  893. super.layout()
  894. for field in [jobTitleField, companyField, durationField, descriptionField] {
  895. if let wrap = field.superview, wrap.bounds.width > 2 {
  896. let w = max(1, wrap.bounds.width - 24)
  897. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  898. field.preferredMaxLayoutWidth = w
  899. }
  900. }
  901. }
  902. guard let layer = layer, layer.shadowOpacity > 0 else { return }
  903. let r = layer.cornerRadius
  904. layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  905. }
  906. @objc private func didTapDelete() {
  907. onDelete?()
  908. }
  909. fileprivate static func labeledFieldStack(title: String, field: NSTextField, placeholder: String) -> NSView {
  910. let label = NSTextField(labelWithString: title)
  911. label.font = .systemFont(ofSize: 12, weight: .medium)
  912. label.textColor = ProfilePagePalette.secondaryText
  913. label.translatesAutoresizingMaskIntoConstraints = false
  914. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  915. styleSingleLineField(field, placeholder: placeholder)
  916. let wrap = roundedChrome(around: field, minHeight: 40)
  917. let stack = NSStackView(views: [label, wrap])
  918. stack.orientation = .vertical
  919. stack.spacing = 8
  920. stack.alignment = .leading
  921. stack.translatesAutoresizingMaskIntoConstraints = false
  922. stack.userInterfaceLayoutDirection = .leftToRight
  923. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  924. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  925. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  926. wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  927. NSLayoutConstraint.activate([
  928. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  929. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  930. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  931. ])
  932. return stack
  933. }
  934. private static func multilineLabeledStack(title: String, field: NSTextField, placeholder: String, minHeight: CGFloat) -> NSView {
  935. let label = NSTextField(labelWithString: title)
  936. label.font = .systemFont(ofSize: 12, weight: .medium)
  937. label.textColor = ProfilePagePalette.secondaryText
  938. label.translatesAutoresizingMaskIntoConstraints = false
  939. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  940. field.translatesAutoresizingMaskIntoConstraints = false
  941. field.isEditable = true
  942. field.isSelectable = true
  943. field.isBordered = false
  944. field.drawsBackground = false
  945. field.focusRingType = .none
  946. field.font = .systemFont(ofSize: 14, weight: .regular)
  947. field.textColor = ProfilePagePalette.primaryText
  948. field.maximumNumberOfLines = 0
  949. field.cell?.wraps = true
  950. field.cell?.isScrollable = false
  951. field.cell?.usesSingleLineMode = false
  952. field.placeholderAttributedString = NSAttributedString(
  953. string: placeholder,
  954. attributes: [
  955. .foregroundColor: ProfilePagePalette.secondaryText,
  956. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  957. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  958. ]
  959. )
  960. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  961. let wrap = NSView()
  962. wrap.translatesAutoresizingMaskIntoConstraints = false
  963. wrap.wantsLayer = true
  964. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  965. wrap.layer?.cornerRadius = 10
  966. wrap.layer?.borderWidth = 1
  967. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  968. if #available(macOS 11.0, *) {
  969. wrap.layer?.cornerCurve = .continuous
  970. }
  971. wrap.addSubview(field)
  972. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  973. NSLayoutConstraint.activate([
  974. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  975. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  976. field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  977. field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  978. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  979. ])
  980. let stack = NSStackView(views: [label, wrap])
  981. stack.orientation = .vertical
  982. stack.spacing = 8
  983. stack.alignment = .leading
  984. stack.translatesAutoresizingMaskIntoConstraints = false
  985. stack.userInterfaceLayoutDirection = .leftToRight
  986. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  987. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  988. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  989. NSLayoutConstraint.activate([
  990. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  991. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  992. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  993. ])
  994. return stack
  995. }
  996. private static func styleSingleLineField(_ field: NSTextField, placeholder: String) {
  997. field.translatesAutoresizingMaskIntoConstraints = false
  998. field.isBordered = false
  999. field.drawsBackground = false
  1000. field.focusRingType = .none
  1001. field.font = .systemFont(ofSize: 14, weight: .regular)
  1002. field.textColor = ProfilePagePalette.primaryText
  1003. field.placeholderAttributedString = NSAttributedString(
  1004. string: placeholder,
  1005. attributes: [
  1006. .foregroundColor: ProfilePagePalette.secondaryText,
  1007. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  1008. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  1009. ]
  1010. )
  1011. field.cell?.usesSingleLineMode = true
  1012. field.cell?.wraps = false
  1013. field.cell?.isScrollable = true
  1014. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  1015. }
  1016. private static func roundedChrome(around field: NSTextField, minHeight: CGFloat) -> NSView {
  1017. let wrap = NSView()
  1018. wrap.translatesAutoresizingMaskIntoConstraints = false
  1019. wrap.wantsLayer = true
  1020. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  1021. wrap.layer?.cornerRadius = 10
  1022. wrap.layer?.borderWidth = 1
  1023. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  1024. if #available(macOS 11.0, *) {
  1025. wrap.layer?.cornerCurve = .continuous
  1026. }
  1027. wrap.addSubview(field)
  1028. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  1029. NSLayoutConstraint.activate([
  1030. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  1031. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  1032. field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
  1033. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  1034. ])
  1035. return wrap
  1036. }
  1037. }
  1038. private final class EducationEntryView: NSView {
  1039. var onDelete: (() -> Void)?
  1040. private let subtitleLabel = NSTextField(labelWithString: "Education 1")
  1041. private let deleteButton = NSButton()
  1042. private let degreeField = NSTextField()
  1043. private let institutionField = NSTextField()
  1044. private let yearField = NSTextField()
  1045. private var degreeInstitutionRow: ProfileDualFieldRow!
  1046. override init(frame frameRect: NSRect) {
  1047. super.init(frame: frameRect)
  1048. configure()
  1049. }
  1050. required init?(coder: NSCoder) {
  1051. fatalError("init(coder:) has not been implemented")
  1052. }
  1053. func setEducationIndex(_ index: Int) {
  1054. subtitleLabel.stringValue = "Education \(index)"
  1055. }
  1056. func setDeleteHidden(_ hidden: Bool) {
  1057. deleteButton.isHidden = hidden
  1058. }
  1059. func applyCompactLayout(_ compact: Bool) {
  1060. degreeInstitutionRow.setCompact(compact)
  1061. }
  1062. private func configure() {
  1063. userInterfaceLayoutDirection = .leftToRight
  1064. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  1065. wantsLayer = true
  1066. layer?.cornerRadius = 14
  1067. layer?.borderWidth = 1
  1068. layer?.borderColor = ProfilePagePalette.border.cgColor
  1069. layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  1070. layer?.masksToBounds = false
  1071. layer?.shadowColor = NSColor.black.cgColor
  1072. layer?.shadowOpacity = 0.04
  1073. layer?.shadowRadius = 10
  1074. layer?.shadowOffset = CGSize(width: 0, height: 5)
  1075. if #available(macOS 11.0, *) {
  1076. layer?.cornerCurve = .continuous
  1077. }
  1078. subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
  1079. subtitleLabel.textColor = ProfilePagePalette.secondaryText
  1080. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  1081. ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
  1082. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  1083. deleteButton.isBordered = false
  1084. deleteButton.bezelStyle = .regularSquare
  1085. deleteButton.focusRingType = .none
  1086. deleteButton.contentTintColor = ProfilePagePalette.destructive
  1087. deleteButton.target = self
  1088. deleteButton.action = #selector(didTapDelete)
  1089. if #available(macOS 11.0, *) {
  1090. deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove education")
  1091. deleteButton.imagePosition = .imageOnly
  1092. } else {
  1093. deleteButton.title = "Remove"
  1094. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  1095. }
  1096. let headerSpacer = NSView()
  1097. headerSpacer.translatesAutoresizingMaskIntoConstraints = false
  1098. headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1099. let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
  1100. headerRow.orientation = .horizontal
  1101. headerRow.alignment = .centerY
  1102. headerRow.distribution = .fill
  1103. headerRow.spacing = 8
  1104. headerRow.userInterfaceLayoutDirection = .leftToRight
  1105. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  1106. headerRow.translatesAutoresizingMaskIntoConstraints = false
  1107. let degreeGroup = WorkExperienceEntryView.labeledFieldStack(
  1108. title: "Degree / program *",
  1109. field: degreeField,
  1110. placeholder: "e.g., BSc Computer Science"
  1111. )
  1112. let institutionGroup = WorkExperienceEntryView.labeledFieldStack(
  1113. title: "Institution *",
  1114. field: institutionField,
  1115. placeholder: "e.g., MIT"
  1116. )
  1117. degreeInstitutionRow = ProfileDualFieldRow(left: degreeGroup, right: institutionGroup, spacing: 12)
  1118. let yearGroup = WorkExperienceEntryView.labeledFieldStack(
  1119. title: "Year *",
  1120. field: yearField,
  1121. placeholder: "e.g., 2020"
  1122. )
  1123. let inner = NSStackView(views: [headerRow, degreeInstitutionRow, yearGroup])
  1124. inner.orientation = .vertical
  1125. inner.spacing = 14
  1126. inner.alignment = .leading
  1127. inner.translatesAutoresizingMaskIntoConstraints = false
  1128. inner.edgeInsets = NSEdgeInsets(top: 14, left: 18, bottom: 14, right: 18)
  1129. inner.userInterfaceLayoutDirection = .leftToRight
  1130. ProfileLayoutEnforcement.applyForcedLTR(to: inner)
  1131. inner.pinAllArrangedSubviewWidthsEqualToStackWidth()
  1132. addSubview(inner)
  1133. NSLayoutConstraint.activate([
  1134. inner.leftAnchor.constraint(equalTo: leftAnchor),
  1135. inner.rightAnchor.constraint(equalTo: rightAnchor),
  1136. inner.topAnchor.constraint(equalTo: topAnchor),
  1137. inner.bottomAnchor.constraint(equalTo: bottomAnchor)
  1138. ])
  1139. }
  1140. override func layout() {
  1141. super.layout()
  1142. for field in [degreeField, institutionField, yearField] {
  1143. if let wrap = field.superview, wrap.bounds.width > 2 {
  1144. let w = max(1, wrap.bounds.width - 24)
  1145. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  1146. field.preferredMaxLayoutWidth = w
  1147. }
  1148. }
  1149. }
  1150. guard let layer = layer, layer.shadowOpacity > 0 else { return }
  1151. let r = layer.cornerRadius
  1152. layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  1153. }
  1154. @objc private func didTapDelete() {
  1155. onDelete?()
  1156. }
  1157. }
  1158. // MARK: - Primary CTA
  1159. private final class ProfilePrimaryButton: NSButton {
  1160. private var trackingArea: NSTrackingArea?
  1161. private var didPushCursor = false
  1162. override init(frame frameRect: NSRect) {
  1163. super.init(frame: frameRect)
  1164. commonInit()
  1165. }
  1166. required init?(coder: NSCoder) {
  1167. super.init(coder: coder)
  1168. commonInit()
  1169. }
  1170. convenience init(title: String, target: AnyObject?, action: Selector?) {
  1171. self.init(frame: .zero)
  1172. self.title = title
  1173. self.target = target
  1174. self.action = action
  1175. }
  1176. private func commonInit() {
  1177. bezelStyle = .rounded
  1178. isBordered = false
  1179. font = .systemFont(ofSize: 15, weight: .semibold)
  1180. contentTintColor = .white
  1181. wantsLayer = true
  1182. layer?.cornerRadius = 12
  1183. if #available(macOS 11.0, *) {
  1184. layer?.cornerCurve = .continuous
  1185. }
  1186. layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
  1187. }
  1188. override func updateTrackingAreas() {
  1189. super.updateTrackingAreas()
  1190. if let trackingArea { removeTrackingArea(trackingArea) }
  1191. let area = NSTrackingArea(
  1192. rect: bounds,
  1193. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  1194. owner: self,
  1195. userInfo: nil
  1196. )
  1197. addTrackingArea(area)
  1198. trackingArea = area
  1199. }
  1200. override func mouseEntered(with event: NSEvent) {
  1201. super.mouseEntered(with: event)
  1202. layer?.backgroundColor = ProfilePagePalette.brandBlueHover.cgColor
  1203. if !didPushCursor {
  1204. NSCursor.pointingHand.push()
  1205. didPushCursor = true
  1206. }
  1207. }
  1208. override func mouseExited(with event: NSEvent) {
  1209. super.mouseExited(with: event)
  1210. layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
  1211. if didPushCursor {
  1212. NSCursor.pop()
  1213. didPushCursor = false
  1214. }
  1215. }
  1216. override func viewWillMove(toWindow newWindow: NSWindow?) {
  1217. super.viewWillMove(toWindow: newWindow)
  1218. if newWindow == nil, didPushCursor {
  1219. NSCursor.pop()
  1220. didPushCursor = false
  1221. }
  1222. }
  1223. }