暂无描述

ProfilesListPageView.swift 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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. // Keep the pill at intrinsic width so title + arrow stay grouped and centered; otherwise a wide stack pins text and icon to opposite edges.
  95. addButton.setContentHuggingPriority(.required, for: .horizontal)
  96. addButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  97. let titleSubtitleStack = NSStackView(views: [title, subtitle])
  98. titleSubtitleStack.orientation = .vertical
  99. titleSubtitleStack.alignment = .leading
  100. titleSubtitleStack.spacing = 10
  101. titleSubtitleStack.translatesAutoresizingMaskIntoConstraints = false
  102. pendingFlowLabel.font = .systemFont(ofSize: 13, weight: .medium)
  103. pendingFlowLabel.textColor = ProfilesListPalette.brandBlue
  104. pendingFlowLabel.maximumNumberOfLines = 0
  105. pendingFlowLabel.isHidden = true
  106. let pageHeaderStack = NSStackView(views: [titleSubtitleStack, pendingFlowLabel])
  107. pageHeaderStack.orientation = .vertical
  108. pageHeaderStack.alignment = .leading
  109. pageHeaderStack.spacing = 12
  110. pageHeaderStack.translatesAutoresizingMaskIntoConstraints = false
  111. let footerStack = NSStackView(views: [emptyStateLabel, addButton])
  112. footerStack.orientation = .vertical
  113. footerStack.alignment = .leading
  114. footerStack.spacing = 10
  115. footerStack.translatesAutoresizingMaskIntoConstraints = false
  116. footerStack.setCustomSpacing(16, after: emptyStateLabel)
  117. contentStack.orientation = .vertical
  118. contentStack.alignment = .leading
  119. contentStack.spacing = 12
  120. contentStack.translatesAutoresizingMaskIntoConstraints = false
  121. documentView.translatesAutoresizingMaskIntoConstraints = false
  122. documentView.addSubview(pageHeaderStack)
  123. documentView.addSubview(contentStack)
  124. documentView.addSubview(footerStack)
  125. scrollView.translatesAutoresizingMaskIntoConstraints = false
  126. scrollView.drawsBackground = false
  127. scrollView.hasVerticalScroller = true
  128. scrollView.hasHorizontalScroller = false
  129. scrollView.autohidesScrollers = true
  130. scrollView.borderType = .noBorder
  131. scrollView.documentView = documentView
  132. addSubview(scrollView)
  133. NSLayoutConstraint.activate([
  134. scrollView.leftAnchor.constraint(equalTo: leftAnchor),
  135. scrollView.rightAnchor.constraint(equalTo: rightAnchor),
  136. scrollView.topAnchor.constraint(equalTo: topAnchor),
  137. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  138. documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
  139. documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
  140. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  141. documentView.bottomAnchor.constraint(equalTo: footerStack.bottomAnchor, constant: 32),
  142. pageHeaderStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  143. pageHeaderStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  144. pageHeaderStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
  145. contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  146. contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  147. contentStack.topAnchor.constraint(equalTo: pageHeaderStack.bottomAnchor, constant: 24),
  148. footerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  149. footerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  150. footerStack.topAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 24),
  151. pageHeaderStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
  152. contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
  153. footerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
  154. ])
  155. reloadFromStore()
  156. }
  157. @objc private func didTapAdd() {
  158. onAddProfile?()
  159. }
  160. }
  161. // MARK: - Row
  162. private final class ProfileListRowView: NSView {
  163. var onEdit: ((UUID) -> Void)?
  164. var onDelete: ((UUID) -> Void)?
  165. var onBuildCV: ((UUID) -> Void)?
  166. private let profileID: UUID
  167. private let buildCVButton = NSButton(title: "Build CV", target: nil, action: nil)
  168. private let editButton = NSButton(title: "Edit", target: nil, action: nil)
  169. private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
  170. init(profile: SavedProfile, showBuildCV: Bool) {
  171. self.profileID = profile.id
  172. super.init(frame: .zero)
  173. translatesAutoresizingMaskIntoConstraints = false
  174. wantsLayer = true
  175. layer?.cornerRadius = 14
  176. layer?.borderWidth = 1
  177. layer?.borderColor = ProfilesListPalette.border.cgColor
  178. layer?.backgroundColor = ProfilesListPalette.cardBackground.cgColor
  179. if #available(macOS 11.0, *) {
  180. layer?.cornerCurve = .continuous
  181. }
  182. let name = NSTextField(labelWithString: profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName)
  183. name.font = .systemFont(ofSize: 15, weight: .semibold)
  184. name.textColor = ProfilesListPalette.primaryText
  185. let detailParts = [profile.personal.fullName, profile.personal.email].filter { !$0.isEmpty }
  186. let detail = NSTextField(wrappingLabelWithString: detailParts.isEmpty ? "No contact details yet" : detailParts.joined(separator: " · "))
  187. detail.font = .systemFont(ofSize: 12, weight: .regular)
  188. detail.textColor = ProfilesListPalette.secondaryText
  189. detail.maximumNumberOfLines = 2
  190. let textStack = NSStackView(views: [name, detail])
  191. textStack.orientation = .vertical
  192. textStack.alignment = .leading
  193. textStack.spacing = 4
  194. textStack.translatesAutoresizingMaskIntoConstraints = false
  195. buildCVButton.translatesAutoresizingMaskIntoConstraints = false
  196. buildCVButton.bezelStyle = .rounded
  197. buildCVButton.isBordered = true
  198. buildCVButton.font = .systemFont(ofSize: 12, weight: .semibold)
  199. buildCVButton.contentTintColor = ProfilesListPalette.brandBlue
  200. buildCVButton.target = self
  201. buildCVButton.action = #selector(didTapBuildCV)
  202. buildCVButton.isHidden = !showBuildCV
  203. editButton.translatesAutoresizingMaskIntoConstraints = false
  204. editButton.bezelStyle = .rounded
  205. editButton.isBordered = true
  206. editButton.font = .systemFont(ofSize: 12, weight: .medium)
  207. editButton.target = self
  208. editButton.action = #selector(didTapEdit)
  209. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  210. deleteButton.bezelStyle = .rounded
  211. deleteButton.isBordered = false
  212. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  213. deleteButton.contentTintColor = ProfilesListPalette.destructive
  214. deleteButton.target = self
  215. deleteButton.action = #selector(didTapDelete)
  216. let spacer = NSView()
  217. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  218. let actionViews: [NSView] = showBuildCV ? [buildCVButton, editButton, deleteButton] : [editButton, deleteButton]
  219. let actions = NSStackView(views: actionViews)
  220. actions.orientation = .horizontal
  221. actions.spacing = 8
  222. actions.alignment = .centerY
  223. actions.translatesAutoresizingMaskIntoConstraints = false
  224. let row = NSStackView(views: [textStack, spacer, actions])
  225. row.orientation = .horizontal
  226. row.alignment = .top
  227. row.spacing = 16
  228. row.translatesAutoresizingMaskIntoConstraints = false
  229. addSubview(row)
  230. NSLayoutConstraint.activate([
  231. row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
  232. row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
  233. row.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  234. row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
  235. ])
  236. }
  237. required init?(coder: NSCoder) {
  238. fatalError("init(coder:) has not been implemented")
  239. }
  240. @objc private func didTapEdit() {
  241. onEdit?(profileID)
  242. }
  243. @objc private func didTapBuildCV() {
  244. onBuildCV?(profileID)
  245. }
  246. @objc private func didTapDelete() {
  247. onDelete?(profileID)
  248. }
  249. }
  250. // MARK: - Primary CTA (matches profile page button)
  251. private final class ProfilesPrimaryButton: NSButton {
  252. private static let intrinsicPadding = NSEdgeInsets(top: 12, left: 28, bottom: 12, right: 28)
  253. private var trackingArea: NSTrackingArea?
  254. private var didPushCursor = false
  255. override init(frame frameRect: NSRect) {
  256. super.init(frame: frameRect)
  257. commonInit()
  258. }
  259. required init?(coder: NSCoder) {
  260. super.init(coder: coder)
  261. commonInit()
  262. }
  263. convenience init(title: String, target: AnyObject?, action: Selector?) {
  264. self.init(frame: .zero)
  265. self.title = title
  266. self.target = target
  267. self.action = action
  268. }
  269. private func commonInit() {
  270. bezelStyle = .rounded
  271. isBordered = false
  272. font = .systemFont(ofSize: 15, weight: .semibold)
  273. contentTintColor = .white
  274. wantsLayer = true
  275. layer?.masksToBounds = true
  276. if #available(macOS 11.0, *) {
  277. layer?.cornerCurve = .continuous
  278. }
  279. layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
  280. }
  281. override var intrinsicContentSize: NSSize {
  282. let base = super.intrinsicContentSize
  283. let p = Self.intrinsicPadding
  284. return NSSize(width: base.width + p.left + p.right, height: base.height + p.top + p.bottom)
  285. }
  286. override func layout() {
  287. super.layout()
  288. let h = bounds.height
  289. guard h > 1 else { return }
  290. layer?.cornerRadius = h / 2
  291. }
  292. override func updateTrackingAreas() {
  293. super.updateTrackingAreas()
  294. if let trackingArea { removeTrackingArea(trackingArea) }
  295. let area = NSTrackingArea(
  296. rect: bounds,
  297. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  298. owner: self,
  299. userInfo: nil
  300. )
  301. addTrackingArea(area)
  302. trackingArea = area
  303. }
  304. override func mouseEntered(with event: NSEvent) {
  305. super.mouseEntered(with: event)
  306. layer?.backgroundColor = ProfilesListPalette.brandBlueHover.cgColor
  307. if !didPushCursor {
  308. NSCursor.pointingHand.push()
  309. didPushCursor = true
  310. }
  311. }
  312. override func mouseExited(with event: NSEvent) {
  313. super.mouseExited(with: event)
  314. layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
  315. if didPushCursor {
  316. NSCursor.pop()
  317. didPushCursor = false
  318. }
  319. }
  320. override func viewWillMove(toWindow newWindow: NSWindow?) {
  321. super.viewWillMove(toWindow: newWindow)
  322. if newWindow == nil, didPushCursor {
  323. NSCursor.pop()
  324. didPushCursor = false
  325. }
  326. }
  327. }