Sin descripción

ProfilesListPageView.swift 14KB

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