Bez popisu

ProfilesListPageView.swift 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. //
  2. // ProfilesListPageView.swift
  3. // App for Indeed
  4. //
  5. import Cocoa
  6. private enum ProfilesListPalette {
  7. static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  8. static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  9. static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  10. static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
  11. static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
  12. static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
  13. static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
  14. static let destructive = NSColor(srgbRed: 220 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1)
  15. }
  16. /// Document view for the profiles `NSScrollView`; flipped coordinates keep short content aligned to the top of the clip (avoids a large empty band above the content on macOS).
  17. private final class ProfilesListDocumentView: NSView {
  18. override var isFlipped: Bool { true }
  19. }
  20. /// Hub for saved job profiles: list, add, edit (opens editor elsewhere), delete.
  21. final class ProfilesListPageView: NSView {
  22. var onAddProfile: (() -> Void)?
  23. var onEditProfile: ((UUID) -> Void)?
  24. var onDeleteProfile: ((UUID) -> Void)?
  25. /// Fired when the user taps **Build CV** on a row while a CV Maker template is pending.
  26. var onBuildCVWithProfile: ((UUID) -> Void)?
  27. private let scrollView = NSScrollView()
  28. private let documentView = ProfilesListDocumentView()
  29. private let contentStack = NSStackView()
  30. private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
  31. private let addButton = ProfilesPrimaryButton(title: "Add new profile", target: nil, action: nil)
  32. private let pendingFlowLabel = NSTextField(wrappingLabelWithString: "")
  33. /// Non-`nil` after **Use Template & Select Profile** until the dashboard clears the flow (e.g. leaving Profile).
  34. private var pendingCVTemplateDisplayName: String?
  35. override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
  36. get { .leftToRight }
  37. set { super.userInterfaceLayoutDirection = .leftToRight }
  38. }
  39. override init(frame frameRect: NSRect) {
  40. super.init(frame: frameRect)
  41. setup()
  42. }
  43. required init?(coder: NSCoder) {
  44. super.init(coder: coder)
  45. setup()
  46. }
  47. func reloadFromStore() {
  48. for row in contentStack.arrangedSubviews {
  49. contentStack.removeArrangedSubview(row)
  50. row.removeFromSuperview()
  51. }
  52. let profiles = SavedProfilesStore.loadAll()
  53. emptyStateLabel.isHidden = !profiles.isEmpty
  54. let showBuildCV = pendingCVTemplateDisplayName != nil
  55. for profile in profiles {
  56. let row = ProfileListRowView(profile: profile, showBuildCV: showBuildCV)
  57. row.translatesAutoresizingMaskIntoConstraints = false
  58. row.onEdit = { [weak self] id in self?.onEditProfile?(id) }
  59. row.onDelete = { [weak self] id in self?.onDeleteProfile?(id) }
  60. row.onBuildCV = { [weak self] id in self?.onBuildCVWithProfile?(id) }
  61. contentStack.addArrangedSubview(row)
  62. row.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  63. }
  64. }
  65. /// Shows the CV Maker hand-off banner and per-profile **Build CV** actions, or clears them when `nil`.
  66. func setPendingCVTemplateDisplayName(_ name: String?) {
  67. pendingCVTemplateDisplayName = name
  68. if let name, !name.isEmpty {
  69. pendingFlowLabel.stringValue = "You chose the “\(name)” template. Tap Build CV on a profile to preview your résumé with that layout."
  70. pendingFlowLabel.isHidden = false
  71. } else {
  72. pendingFlowLabel.isHidden = true
  73. }
  74. reloadFromStore()
  75. }
  76. private func setup() {
  77. wantsLayer = true
  78. layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
  79. userInterfaceLayoutDirection = .leftToRight
  80. let title = NSTextField(labelWithString: "Profiles")
  81. title.font = .systemFont(ofSize: 22, weight: .semibold)
  82. title.textColor = ProfilesListPalette.primaryText
  83. let subtitle = NSTextField(wrappingLabelWithString: "Create and manage CV profiles. Each profile stores your details on this Mac.")
  84. subtitle.font = .systemFont(ofSize: 13, weight: .regular)
  85. subtitle.textColor = ProfilesListPalette.secondaryText
  86. subtitle.maximumNumberOfLines = 0
  87. emptyStateLabel.stringValue = "No profiles yet. Tap “Add new profile” to create your first one."
  88. emptyStateLabel.font = .systemFont(ofSize: 13, weight: .regular)
  89. emptyStateLabel.textColor = ProfilesListPalette.secondaryText
  90. emptyStateLabel.isHidden = true
  91. addButton.target = self
  92. addButton.action = #selector(didTapAdd)
  93. addButton.translatesAutoresizingMaskIntoConstraints = false
  94. addButton.setContentHuggingPriority(.required, for: .horizontal)
  95. addButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  96. NSLayoutConstraint.activate([
  97. addButton.heightAnchor.constraint(equalToConstant: 32),
  98. addButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76)
  99. ])
  100. let titleSubtitleStack = NSStackView(views: [title, subtitle])
  101. titleSubtitleStack.orientation = .vertical
  102. titleSubtitleStack.alignment = .leading
  103. titleSubtitleStack.spacing = 10
  104. titleSubtitleStack.translatesAutoresizingMaskIntoConstraints = false
  105. pendingFlowLabel.font = .systemFont(ofSize: 13, weight: .medium)
  106. pendingFlowLabel.textColor = ProfilesListPalette.brandBlue
  107. pendingFlowLabel.maximumNumberOfLines = 0
  108. pendingFlowLabel.isHidden = true
  109. let pageHeaderStack = NSStackView(views: [titleSubtitleStack, pendingFlowLabel])
  110. pageHeaderStack.orientation = .vertical
  111. pageHeaderStack.alignment = .leading
  112. pageHeaderStack.spacing = 12
  113. pageHeaderStack.translatesAutoresizingMaskIntoConstraints = false
  114. let footerStack = NSStackView(views: [emptyStateLabel, addButton])
  115. footerStack.orientation = .vertical
  116. footerStack.alignment = .leading
  117. footerStack.spacing = 10
  118. footerStack.translatesAutoresizingMaskIntoConstraints = false
  119. footerStack.setCustomSpacing(16, after: emptyStateLabel)
  120. contentStack.orientation = .vertical
  121. contentStack.alignment = .leading
  122. contentStack.spacing = 12
  123. contentStack.translatesAutoresizingMaskIntoConstraints = false
  124. documentView.translatesAutoresizingMaskIntoConstraints = false
  125. documentView.addSubview(pageHeaderStack)
  126. documentView.addSubview(contentStack)
  127. documentView.addSubview(footerStack)
  128. scrollView.translatesAutoresizingMaskIntoConstraints = false
  129. scrollView.drawsBackground = false
  130. scrollView.hasVerticalScroller = true
  131. scrollView.hasHorizontalScroller = false
  132. scrollView.autohidesScrollers = true
  133. scrollView.borderType = .noBorder
  134. scrollView.documentView = documentView
  135. addSubview(scrollView)
  136. NSLayoutConstraint.activate([
  137. scrollView.leftAnchor.constraint(equalTo: leftAnchor),
  138. scrollView.rightAnchor.constraint(equalTo: rightAnchor),
  139. scrollView.topAnchor.constraint(equalTo: topAnchor),
  140. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  141. documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
  142. documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
  143. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  144. documentView.bottomAnchor.constraint(equalTo: footerStack.bottomAnchor, constant: 32),
  145. pageHeaderStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  146. pageHeaderStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  147. pageHeaderStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
  148. contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  149. contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  150. contentStack.topAnchor.constraint(equalTo: pageHeaderStack.bottomAnchor, constant: 24),
  151. footerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  152. footerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  153. footerStack.topAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 24),
  154. pageHeaderStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
  155. contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
  156. footerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
  157. ])
  158. reloadFromStore()
  159. }
  160. @objc private func didTapAdd() {
  161. onAddProfile?()
  162. }
  163. }
  164. // MARK: - Row
  165. private final class ProfileListRowView: NSView {
  166. var onEdit: ((UUID) -> Void)?
  167. var onDelete: ((UUID) -> Void)?
  168. var onBuildCV: ((UUID) -> Void)?
  169. private let profileID: UUID
  170. private let buildCVButton = NSButton(title: "Build CV", target: nil, action: nil)
  171. private let editButton = NSButton(title: "Edit", target: nil, action: nil)
  172. private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
  173. init(profile: SavedProfile, showBuildCV: Bool) {
  174. self.profileID = profile.id
  175. super.init(frame: .zero)
  176. translatesAutoresizingMaskIntoConstraints = false
  177. wantsLayer = true
  178. layer?.cornerRadius = 14
  179. layer?.borderWidth = 1
  180. layer?.borderColor = ProfilesListPalette.border.cgColor
  181. layer?.backgroundColor = ProfilesListPalette.cardBackground.cgColor
  182. if #available(macOS 11.0, *) {
  183. layer?.cornerCurve = .continuous
  184. }
  185. let name = NSTextField(labelWithString: profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName)
  186. name.font = .systemFont(ofSize: 15, weight: .semibold)
  187. name.textColor = ProfilesListPalette.primaryText
  188. let detailParts = [profile.personal.fullName, profile.personal.email].filter { !$0.isEmpty }
  189. let detail = NSTextField(wrappingLabelWithString: detailParts.isEmpty ? "No contact details yet" : detailParts.joined(separator: " · "))
  190. detail.font = .systemFont(ofSize: 12, weight: .regular)
  191. detail.textColor = ProfilesListPalette.secondaryText
  192. detail.maximumNumberOfLines = 2
  193. let textStack = NSStackView(views: [name, detail])
  194. textStack.orientation = .vertical
  195. textStack.alignment = .leading
  196. textStack.spacing = 4
  197. textStack.translatesAutoresizingMaskIntoConstraints = false
  198. buildCVButton.translatesAutoresizingMaskIntoConstraints = false
  199. buildCVButton.bezelStyle = .rounded
  200. buildCVButton.isBordered = true
  201. buildCVButton.font = .systemFont(ofSize: 12, weight: .semibold)
  202. buildCVButton.contentTintColor = ProfilesListPalette.brandBlue
  203. buildCVButton.target = self
  204. buildCVButton.action = #selector(didTapBuildCV)
  205. buildCVButton.isHidden = !showBuildCV
  206. editButton.translatesAutoresizingMaskIntoConstraints = false
  207. editButton.bezelStyle = .rounded
  208. editButton.isBordered = true
  209. editButton.font = .systemFont(ofSize: 12, weight: .medium)
  210. editButton.target = self
  211. editButton.action = #selector(didTapEdit)
  212. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  213. deleteButton.bezelStyle = .rounded
  214. deleteButton.isBordered = false
  215. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  216. deleteButton.contentTintColor = ProfilesListPalette.destructive
  217. deleteButton.target = self
  218. deleteButton.action = #selector(didTapDelete)
  219. let spacer = NSView()
  220. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  221. let actionViews: [NSView] = showBuildCV ? [buildCVButton, editButton, deleteButton] : [editButton, deleteButton]
  222. let actions = NSStackView(views: actionViews)
  223. actions.orientation = .horizontal
  224. actions.spacing = 8
  225. actions.alignment = .centerY
  226. actions.translatesAutoresizingMaskIntoConstraints = false
  227. let row = NSStackView(views: [textStack, spacer, actions])
  228. row.orientation = .horizontal
  229. row.alignment = .top
  230. row.spacing = 16
  231. row.translatesAutoresizingMaskIntoConstraints = false
  232. addSubview(row)
  233. NSLayoutConstraint.activate([
  234. row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
  235. row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
  236. row.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  237. row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
  238. ])
  239. }
  240. required init?(coder: NSCoder) {
  241. fatalError("init(coder:) has not been implemented")
  242. }
  243. @objc private func didTapEdit() {
  244. onEdit?(profileID)
  245. }
  246. @objc private func didTapBuildCV() {
  247. onBuildCV?(profileID)
  248. }
  249. @objc private func didTapDelete() {
  250. onDelete?(profileID)
  251. }
  252. }
  253. // MARK: - Primary CTA (matches job cards’ Apply: 13pt semibold, 32pt tall, 8pt corners)
  254. private final class ProfilesPrimaryButton: NSButton {
  255. private static let horizontalOutset: CGFloat = 20
  256. private var trackingArea: NSTrackingArea?
  257. private var didPushCursor = false
  258. override init(frame frameRect: NSRect) {
  259. super.init(frame: frameRect)
  260. commonInit()
  261. }
  262. required init?(coder: NSCoder) {
  263. super.init(coder: coder)
  264. commonInit()
  265. }
  266. convenience init(title: String, target: AnyObject?, action: Selector?) {
  267. self.init(frame: .zero)
  268. self.title = title
  269. self.target = target
  270. self.action = action
  271. }
  272. private func commonInit() {
  273. translatesAutoresizingMaskIntoConstraints = false
  274. bezelStyle = .rounded
  275. isBordered = false
  276. font = .systemFont(ofSize: 13, weight: .semibold)
  277. contentTintColor = .white
  278. focusRingType = .none
  279. wantsLayer = true
  280. layer?.cornerRadius = 8
  281. layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
  282. }
  283. override var intrinsicContentSize: NSSize {
  284. let base = super.intrinsicContentSize
  285. guard base.width != NSView.noIntrinsicMetric, base.width >= 1 else { return base }
  286. return NSSize(width: base.width + Self.horizontalOutset, height: base.height)
  287. }
  288. override func updateTrackingAreas() {
  289. super.updateTrackingAreas()
  290. if let trackingArea { removeTrackingArea(trackingArea) }
  291. let area = NSTrackingArea(
  292. rect: bounds,
  293. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  294. owner: self,
  295. userInfo: nil
  296. )
  297. addTrackingArea(area)
  298. trackingArea = area
  299. }
  300. override func mouseEntered(with event: NSEvent) {
  301. super.mouseEntered(with: event)
  302. layer?.backgroundColor = ProfilesListPalette.brandBlueHover.cgColor
  303. if !didPushCursor {
  304. NSCursor.pointingHand.push()
  305. didPushCursor = true
  306. }
  307. }
  308. override func mouseExited(with event: NSEvent) {
  309. super.mouseExited(with: event)
  310. layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
  311. if didPushCursor {
  312. NSCursor.pop()
  313. didPushCursor = false
  314. }
  315. }
  316. override func viewWillMove(toWindow newWindow: NSWindow?) {
  317. super.viewWillMove(toWindow: newWindow)
  318. if newWindow == nil, didPushCursor {
  319. NSCursor.pop()
  320. didPushCursor = false
  321. }
  322. }
  323. }