Nenhuma descrição

ProfilesListPageView.swift 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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. private let scrollView = NSScrollView()
  26. private let documentView = ProfilesListDocumentView()
  27. private let contentStack = NSStackView()
  28. private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
  29. private let addButton = ProfilesPrimaryButton(title: "Add new profile →", target: nil, action: nil)
  30. override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
  31. get { .leftToRight }
  32. set { super.userInterfaceLayoutDirection = .leftToRight }
  33. }
  34. override init(frame frameRect: NSRect) {
  35. super.init(frame: frameRect)
  36. setup()
  37. }
  38. required init?(coder: NSCoder) {
  39. super.init(coder: coder)
  40. setup()
  41. }
  42. func reloadFromStore() {
  43. for row in contentStack.arrangedSubviews {
  44. contentStack.removeArrangedSubview(row)
  45. row.removeFromSuperview()
  46. }
  47. let profiles = SavedProfilesStore.loadAll()
  48. emptyStateLabel.isHidden = !profiles.isEmpty
  49. for profile in profiles {
  50. let row = ProfileListRowView(profile: profile)
  51. row.translatesAutoresizingMaskIntoConstraints = false
  52. row.onEdit = { [weak self] id in self?.onEditProfile?(id) }
  53. row.onDelete = { [weak self] id in self?.onDeleteProfile?(id) }
  54. contentStack.addArrangedSubview(row)
  55. row.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  56. }
  57. }
  58. private func setup() {
  59. wantsLayer = true
  60. layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
  61. userInterfaceLayoutDirection = .leftToRight
  62. let title = NSTextField(labelWithString: "Profiles")
  63. title.font = .systemFont(ofSize: 22, weight: .semibold)
  64. title.textColor = ProfilesListPalette.primaryText
  65. let subtitle = NSTextField(wrappingLabelWithString: "Create and manage CV profiles. Each profile stores your details on this Mac.")
  66. subtitle.font = .systemFont(ofSize: 13, weight: .regular)
  67. subtitle.textColor = ProfilesListPalette.secondaryText
  68. subtitle.maximumNumberOfLines = 0
  69. emptyStateLabel.stringValue = "No profiles yet. Tap “Add new profile” to create your first one."
  70. emptyStateLabel.font = .systemFont(ofSize: 13, weight: .regular)
  71. emptyStateLabel.textColor = ProfilesListPalette.secondaryText
  72. emptyStateLabel.isHidden = true
  73. addButton.target = self
  74. addButton.action = #selector(didTapAdd)
  75. addButton.translatesAutoresizingMaskIntoConstraints = false
  76. let titleSubtitleStack = NSStackView(views: [title, subtitle])
  77. titleSubtitleStack.orientation = .vertical
  78. titleSubtitleStack.alignment = .leading
  79. titleSubtitleStack.spacing = 10
  80. titleSubtitleStack.translatesAutoresizingMaskIntoConstraints = false
  81. let footerStack = NSStackView(views: [emptyStateLabel, addButton])
  82. footerStack.orientation = .vertical
  83. footerStack.alignment = .leading
  84. footerStack.spacing = 10
  85. footerStack.translatesAutoresizingMaskIntoConstraints = false
  86. footerStack.setCustomSpacing(16, after: emptyStateLabel)
  87. contentStack.orientation = .vertical
  88. contentStack.alignment = .leading
  89. contentStack.spacing = 12
  90. contentStack.translatesAutoresizingMaskIntoConstraints = false
  91. documentView.translatesAutoresizingMaskIntoConstraints = false
  92. documentView.addSubview(titleSubtitleStack)
  93. documentView.addSubview(contentStack)
  94. documentView.addSubview(footerStack)
  95. scrollView.translatesAutoresizingMaskIntoConstraints = false
  96. scrollView.drawsBackground = false
  97. scrollView.hasVerticalScroller = true
  98. scrollView.hasHorizontalScroller = false
  99. scrollView.autohidesScrollers = true
  100. scrollView.borderType = .noBorder
  101. scrollView.documentView = documentView
  102. addSubview(scrollView)
  103. NSLayoutConstraint.activate([
  104. scrollView.leftAnchor.constraint(equalTo: leftAnchor),
  105. scrollView.rightAnchor.constraint(equalTo: rightAnchor),
  106. scrollView.topAnchor.constraint(equalTo: topAnchor),
  107. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  108. documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
  109. documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
  110. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  111. documentView.bottomAnchor.constraint(equalTo: footerStack.bottomAnchor, constant: 32),
  112. titleSubtitleStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  113. titleSubtitleStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  114. titleSubtitleStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
  115. contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  116. contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  117. contentStack.topAnchor.constraint(equalTo: titleSubtitleStack.bottomAnchor, constant: 24),
  118. footerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  119. footerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
  120. footerStack.topAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 24),
  121. titleSubtitleStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
  122. contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
  123. footerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
  124. ])
  125. reloadFromStore()
  126. }
  127. @objc private func didTapAdd() {
  128. onAddProfile?()
  129. }
  130. }
  131. // MARK: - Row
  132. private final class ProfileListRowView: NSView {
  133. var onEdit: ((UUID) -> Void)?
  134. var onDelete: ((UUID) -> Void)?
  135. private let profileID: UUID
  136. private let editButton = NSButton(title: "Edit", target: nil, action: nil)
  137. private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
  138. init(profile: SavedProfile) {
  139. self.profileID = profile.id
  140. super.init(frame: .zero)
  141. translatesAutoresizingMaskIntoConstraints = false
  142. wantsLayer = true
  143. layer?.cornerRadius = 14
  144. layer?.borderWidth = 1
  145. layer?.borderColor = ProfilesListPalette.border.cgColor
  146. layer?.backgroundColor = ProfilesListPalette.cardBackground.cgColor
  147. if #available(macOS 11.0, *) {
  148. layer?.cornerCurve = .continuous
  149. }
  150. let name = NSTextField(labelWithString: profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName)
  151. name.font = .systemFont(ofSize: 15, weight: .semibold)
  152. name.textColor = ProfilesListPalette.primaryText
  153. let detailParts = [profile.personal.fullName, profile.personal.email].filter { !$0.isEmpty }
  154. let detail = NSTextField(wrappingLabelWithString: detailParts.isEmpty ? "No contact details yet" : detailParts.joined(separator: " · "))
  155. detail.font = .systemFont(ofSize: 12, weight: .regular)
  156. detail.textColor = ProfilesListPalette.secondaryText
  157. detail.maximumNumberOfLines = 2
  158. let textStack = NSStackView(views: [name, detail])
  159. textStack.orientation = .vertical
  160. textStack.alignment = .leading
  161. textStack.spacing = 4
  162. textStack.translatesAutoresizingMaskIntoConstraints = false
  163. editButton.translatesAutoresizingMaskIntoConstraints = false
  164. editButton.bezelStyle = .rounded
  165. editButton.isBordered = true
  166. editButton.font = .systemFont(ofSize: 12, weight: .medium)
  167. editButton.target = self
  168. editButton.action = #selector(didTapEdit)
  169. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  170. deleteButton.bezelStyle = .rounded
  171. deleteButton.isBordered = false
  172. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  173. deleteButton.contentTintColor = ProfilesListPalette.destructive
  174. deleteButton.target = self
  175. deleteButton.action = #selector(didTapDelete)
  176. let spacer = NSView()
  177. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  178. let actions = NSStackView(views: [editButton, deleteButton])
  179. actions.orientation = .horizontal
  180. actions.spacing = 8
  181. actions.alignment = .centerY
  182. actions.translatesAutoresizingMaskIntoConstraints = false
  183. let row = NSStackView(views: [textStack, spacer, actions])
  184. row.orientation = .horizontal
  185. row.alignment = .top
  186. row.spacing = 16
  187. row.translatesAutoresizingMaskIntoConstraints = false
  188. addSubview(row)
  189. NSLayoutConstraint.activate([
  190. row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
  191. row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
  192. row.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  193. row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
  194. ])
  195. }
  196. required init?(coder: NSCoder) {
  197. fatalError("init(coder:) has not been implemented")
  198. }
  199. @objc private func didTapEdit() {
  200. onEdit?(profileID)
  201. }
  202. @objc private func didTapDelete() {
  203. onDelete?(profileID)
  204. }
  205. }
  206. // MARK: - Primary CTA (matches profile page button)
  207. private final class ProfilesPrimaryButton: NSButton {
  208. private var trackingArea: NSTrackingArea?
  209. private var didPushCursor = false
  210. override init(frame frameRect: NSRect) {
  211. super.init(frame: frameRect)
  212. commonInit()
  213. }
  214. required init?(coder: NSCoder) {
  215. super.init(coder: coder)
  216. commonInit()
  217. }
  218. convenience init(title: String, target: AnyObject?, action: Selector?) {
  219. self.init(frame: .zero)
  220. self.title = title
  221. self.target = target
  222. self.action = action
  223. }
  224. private func commonInit() {
  225. bezelStyle = .rounded
  226. isBordered = false
  227. font = .systemFont(ofSize: 16, weight: .semibold)
  228. contentTintColor = .white
  229. wantsLayer = true
  230. layer?.cornerRadius = 14
  231. if #available(macOS 11.0, *) {
  232. layer?.cornerCurve = .continuous
  233. }
  234. layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
  235. }
  236. override func updateTrackingAreas() {
  237. super.updateTrackingAreas()
  238. if let trackingArea { removeTrackingArea(trackingArea) }
  239. let area = NSTrackingArea(
  240. rect: bounds,
  241. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  242. owner: self,
  243. userInfo: nil
  244. )
  245. addTrackingArea(area)
  246. trackingArea = area
  247. }
  248. override func mouseEntered(with event: NSEvent) {
  249. super.mouseEntered(with: event)
  250. layer?.backgroundColor = ProfilesListPalette.brandBlueHover.cgColor
  251. if !didPushCursor {
  252. NSCursor.pointingHand.push()
  253. didPushCursor = true
  254. }
  255. }
  256. override func mouseExited(with event: NSEvent) {
  257. super.mouseExited(with: event)
  258. layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
  259. if didPushCursor {
  260. NSCursor.pop()
  261. didPushCursor = false
  262. }
  263. }
  264. override func viewWillMove(toWindow newWindow: NSWindow?) {
  265. super.viewWillMove(toWindow: newWindow)
  266. if newWindow == nil, didPushCursor {
  267. NSCursor.pop()
  268. didPushCursor = false
  269. }
  270. }
  271. }