Bez popisu

ProfilesListPageView.swift 19KB

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