Нет описания

ProfilesListPageView.swift 18KB

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