PrintContactsView.swift 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952
  1. import Cocoa
  2. import Contacts
  3. // MARK: - Models
  4. struct PrintableContact: Equatable {
  5. let identifier: String
  6. let displayName: String
  7. let organization: String?
  8. let phoneNumbers: [String]
  9. let emailAddresses: [String]
  10. let postalAddresses: [String]
  11. var searchableText: String {
  12. ([displayName, organization] + phoneNumbers + emailAddresses + postalAddresses)
  13. .compactMap { $0 }
  14. .joined(separator: " ")
  15. .lowercased()
  16. }
  17. }
  18. private enum ContactSortOrder: String, CaseIterable {
  19. case nameAscending
  20. case nameDescending
  21. var title: String {
  22. switch self {
  23. case .nameAscending: "Names A to Z"
  24. case .nameDescending: "Names Z to A"
  25. }
  26. }
  27. func sort(_ contacts: [PrintableContact]) -> [PrintableContact] {
  28. switch self {
  29. case .nameAscending:
  30. contacts.sorted {
  31. $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
  32. }
  33. case .nameDescending:
  34. contacts.sorted {
  35. $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedDescending
  36. }
  37. }
  38. }
  39. }
  40. // MARK: - Contacts Service
  41. enum ContactsError: Error {
  42. case accessDenied
  43. }
  44. enum ContactsService {
  45. private static let store = CNContactStore()
  46. private static var cachedContacts: [PrintableContact]?
  47. static var isAuthorized: Bool {
  48. switch CNContactStore.authorizationStatus(for: .contacts) {
  49. case .authorized, .limited:
  50. return true
  51. default:
  52. return false
  53. }
  54. }
  55. @MainActor
  56. static func ensureAccess() async -> Bool {
  57. switch CNContactStore.authorizationStatus(for: .contacts) {
  58. case .authorized, .limited:
  59. return true
  60. case .notDetermined:
  61. do {
  62. return try await store.requestAccess(for: .contacts)
  63. } catch {
  64. return false
  65. }
  66. case .denied, .restricted:
  67. return false
  68. @unknown default:
  69. return false
  70. }
  71. }
  72. @MainActor
  73. static func loadContacts(forceRefresh: Bool = false) async throws -> [PrintableContact] {
  74. if !forceRefresh, let cachedContacts, isAuthorized {
  75. return cachedContacts
  76. }
  77. guard await ensureAccess() else {
  78. throw ContactsError.accessDenied
  79. }
  80. let contacts = try fetchContacts()
  81. cachedContacts = contacts
  82. return contacts
  83. }
  84. static func invalidateCache() {
  85. cachedContacts = nil
  86. }
  87. static func fetchContacts() throws -> [PrintableContact] {
  88. let keys: [CNKeyDescriptor] = [
  89. CNContactIdentifierKey as CNKeyDescriptor,
  90. CNContactGivenNameKey as CNKeyDescriptor,
  91. CNContactFamilyNameKey as CNKeyDescriptor,
  92. CNContactOrganizationNameKey as CNKeyDescriptor,
  93. CNContactPhoneNumbersKey as CNKeyDescriptor,
  94. CNContactEmailAddressesKey as CNKeyDescriptor,
  95. CNContactPostalAddressesKey as CNKeyDescriptor,
  96. ]
  97. let request = CNContactFetchRequest(keysToFetch: keys)
  98. request.sortOrder = .givenName
  99. var contacts: [PrintableContact] = []
  100. try store.enumerateContacts(with: request) { contact, _ in
  101. let name = CNContactFormatter.string(from: contact, style: .fullName)?
  102. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  103. let organization = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines)
  104. let displayName: String
  105. if !name.isEmpty {
  106. displayName = name
  107. } else if !organization.isEmpty {
  108. displayName = organization
  109. } else {
  110. displayName = "Unnamed Contact"
  111. }
  112. let phones = contact.phoneNumbers.map(\.value.stringValue).filter { !$0.isEmpty }
  113. let emails = contact.emailAddresses.map { $0.value as String }.filter { !$0.isEmpty }
  114. let addresses = contact.postalAddresses.map { entry in
  115. CNPostalAddressFormatter.string(from: entry.value, style: .mailingAddress)
  116. }.filter { !$0.isEmpty }
  117. contacts.append(
  118. PrintableContact(
  119. identifier: contact.identifier,
  120. displayName: displayName,
  121. organization: organization.isEmpty ? nil : organization,
  122. phoneNumbers: phones,
  123. emailAddresses: emails,
  124. postalAddresses: addresses
  125. )
  126. )
  127. }
  128. return contacts
  129. }
  130. }
  131. // MARK: - Print Contacts Service
  132. enum PrintContactsService {
  133. static func present(from window: NSWindow?) {
  134. let hostWindow = window ?? NSApp.keyWindow
  135. guard PremiumAccess.require(feature: .printContacts, from: hostWindow) else { return }
  136. guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
  137. viewController.presentPrintContacts()
  138. }
  139. }
  140. // MARK: - Overlay
  141. final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
  142. var onDismiss: (() -> Void)?
  143. override var mouseDownCanMoveWindow: Bool { false }
  144. private let backButton = PrintContactsToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  145. private let sortButton = PrintContactsSortButton()
  146. private let searchContainer = NSView()
  147. private let searchIconView = NSImageView()
  148. private let searchField = NSTextField()
  149. private let scrollView = NSScrollView()
  150. private let listDocumentView = FlippedDocumentView()
  151. private let listStack = NSStackView()
  152. private let selectAllRow = PrintContactsSelectAllRow()
  153. private let printButton = PrintContactsPrintButton()
  154. private let emptyLabel = NSTextField(labelWithString: "No contacts found")
  155. private let horizontalInset: CGFloat = 16
  156. private let rowContentInset: CGFloat = 16
  157. private let checkboxNameSpacing: CGFloat = 12
  158. private let searchHeight: CGFloat = 44
  159. private let contactRowHeight: CGFloat = 52
  160. private var allContacts: [PrintableContact] = []
  161. private var sortOrder: ContactSortOrder = .nameAscending
  162. private var selectedIDs = Set<String>()
  163. private var rowViews: [ContactRowView] = []
  164. init() {
  165. super.init(frame: .zero)
  166. translatesAutoresizingMaskIntoConstraints = false
  167. setup()
  168. refreshAppearance()
  169. NotificationCenter.default.addObserver(
  170. self,
  171. selector: #selector(appearanceDidChange),
  172. name: .appearanceDidChange,
  173. object: nil
  174. )
  175. loadContacts()
  176. }
  177. deinit {
  178. NotificationCenter.default.removeObserver(self)
  179. }
  180. @objc private func appearanceDidChange() {
  181. refreshAppearance()
  182. }
  183. @available(*, unavailable)
  184. required init?(coder: NSCoder) { nil }
  185. func refreshAppearance() {
  186. layer?.backgroundColor = AppTheme.background.cgColor
  187. searchContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  188. searchContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  189. searchField.textColor = AppTheme.textPrimary
  190. searchField.backgroundColor = .clear
  191. searchIconView.contentTintColor = AppTheme.textSecondary
  192. emptyLabel.textColor = AppTheme.textSecondary
  193. backButton.refreshAppearance()
  194. sortButton.refreshAppearance()
  195. selectAllRow.refreshAppearance()
  196. printButton.refreshAppearance()
  197. rowViews.forEach { $0.refreshAppearance() }
  198. }
  199. func present(in parent: NSView) {
  200. if superview == nil {
  201. parent.addSubview(self)
  202. NSLayoutConstraint.activate([
  203. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  204. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  205. topAnchor.constraint(equalTo: parent.topAnchor),
  206. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  207. ])
  208. alphaValue = 1
  209. } else {
  210. parent.addSubview(self, positioned: .above, relativeTo: nil)
  211. }
  212. window?.makeFirstResponder(searchField)
  213. }
  214. override func hitTest(_ point: NSPoint) -> NSView? {
  215. let hit = super.hitTest(point)
  216. if hit != nil { return hit }
  217. return bounds.contains(point) ? self : nil
  218. }
  219. func dismiss(animated: Bool = true) {
  220. let remove = { [weak self] in
  221. self?.removeFromSuperview()
  222. self?.onDismiss?()
  223. }
  224. guard animated else {
  225. remove()
  226. return
  227. }
  228. NSAnimationContext.runAnimationGroup({ context in
  229. context.duration = 0.15
  230. animator().alphaValue = 0
  231. }, completionHandler: remove)
  232. }
  233. private func setup() {
  234. wantsLayer = true
  235. searchContainer.wantsLayer = true
  236. searchContainer.layer?.cornerRadius = searchHeight / 2
  237. searchContainer.layer?.borderWidth = 1
  238. searchContainer.translatesAutoresizingMaskIntoConstraints = false
  239. if let image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search") {
  240. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
  241. searchIconView.image = image.withSymbolConfiguration(config)
  242. }
  243. searchIconView.translatesAutoresizingMaskIntoConstraints = false
  244. searchField.placeholderAttributedString = NSAttributedString(
  245. string: "Search",
  246. attributes: [
  247. .foregroundColor: AppTheme.textSecondary,
  248. .font: AppTheme.regularFont(size: 15),
  249. ]
  250. )
  251. searchField.font = AppTheme.regularFont(size: 15)
  252. searchField.isBordered = false
  253. searchField.isBezeled = false
  254. searchField.drawsBackground = false
  255. searchField.focusRingType = .none
  256. searchField.translatesAutoresizingMaskIntoConstraints = false
  257. searchField.delegate = self
  258. scrollView.translatesAutoresizingMaskIntoConstraints = false
  259. scrollView.hasVerticalScroller = true
  260. scrollView.hasHorizontalScroller = false
  261. scrollView.autohidesScrollers = true
  262. scrollView.borderType = .noBorder
  263. scrollView.drawsBackground = false
  264. listStack.orientation = .vertical
  265. listStack.alignment = .leading
  266. listStack.spacing = 12
  267. listStack.translatesAutoresizingMaskIntoConstraints = false
  268. listDocumentView.translatesAutoresizingMaskIntoConstraints = false
  269. listDocumentView.addSubview(listStack)
  270. emptyLabel.font = AppTheme.regularFont(size: 14)
  271. emptyLabel.alignment = .center
  272. emptyLabel.isHidden = true
  273. emptyLabel.translatesAutoresizingMaskIntoConstraints = false
  274. scrollView.documentView = listDocumentView
  275. backButton.translatesAutoresizingMaskIntoConstraints = false
  276. sortButton.translatesAutoresizingMaskIntoConstraints = false
  277. printButton.translatesAutoresizingMaskIntoConstraints = false
  278. backButton.onClick = { [weak self] in self?.dismiss() }
  279. sortButton.sortOrder = sortOrder
  280. sortButton.onSortChange = { [weak self] order in
  281. self?.sortOrder = order
  282. self?.reloadList()
  283. }
  284. selectAllRow.applyLayout(contentInset: rowContentInset, checkboxNameSpacing: checkboxNameSpacing)
  285. selectAllRow.onClick = { [weak self] in self?.toggleSelectAll() }
  286. printButton.onClick = { [weak self] in self?.printContacts() }
  287. addSubview(backButton)
  288. addSubview(sortButton)
  289. addSubview(searchContainer)
  290. searchContainer.addSubview(searchIconView)
  291. searchContainer.addSubview(searchField)
  292. addSubview(selectAllRow)
  293. addSubview(scrollView)
  294. addSubview(emptyLabel)
  295. addSubview(printButton)
  296. NSLayoutConstraint.activate([
  297. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  298. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  299. backButton.widthAnchor.constraint(equalToConstant: 40),
  300. backButton.heightAnchor.constraint(equalToConstant: 40),
  301. searchContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  302. searchContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  303. searchContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
  304. searchContainer.heightAnchor.constraint(equalToConstant: searchHeight),
  305. searchIconView.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 16),
  306. searchIconView.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
  307. searchIconView.widthAnchor.constraint(equalToConstant: 16),
  308. searchIconView.heightAnchor.constraint(equalToConstant: 16),
  309. searchField.leadingAnchor.constraint(equalTo: searchIconView.trailingAnchor, constant: 10),
  310. searchField.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -16),
  311. searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
  312. selectAllRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  313. selectAllRow.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 12),
  314. selectAllRow.trailingAnchor.constraint(lessThanOrEqualTo: sortButton.leadingAnchor, constant: -8),
  315. selectAllRow.heightAnchor.constraint(equalToConstant: 36),
  316. sortButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  317. sortButton.centerYAnchor.constraint(equalTo: selectAllRow.centerYAnchor),
  318. sortButton.widthAnchor.constraint(equalToConstant: 40),
  319. sortButton.heightAnchor.constraint(equalToConstant: 40),
  320. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  321. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  322. scrollView.topAnchor.constraint(equalTo: selectAllRow.bottomAnchor, constant: 8),
  323. scrollView.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
  324. listDocumentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  325. listDocumentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
  326. listDocumentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
  327. listDocumentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
  328. listStack.leadingAnchor.constraint(equalTo: listDocumentView.leadingAnchor),
  329. listStack.trailingAnchor.constraint(equalTo: listDocumentView.trailingAnchor),
  330. listStack.topAnchor.constraint(equalTo: listDocumentView.topAnchor),
  331. listStack.bottomAnchor.constraint(equalTo: listDocumentView.bottomAnchor),
  332. emptyLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  333. emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
  334. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  335. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  336. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  337. ])
  338. }
  339. private func loadContacts() {
  340. Task { @MainActor in
  341. do {
  342. allContacts = try await ContactsService.loadContacts()
  343. selectedIDs = Set(allContacts.map(\.identifier))
  344. reloadList()
  345. } catch ContactsError.accessDenied {
  346. showAccessDeniedAlert()
  347. } catch {
  348. showLoadFailedAlert()
  349. }
  350. }
  351. }
  352. private func filteredContacts() -> [PrintableContact] {
  353. let query = searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  354. let contacts: [PrintableContact]
  355. if query.isEmpty {
  356. contacts = allContacts
  357. } else {
  358. contacts = allContacts.filter { $0.searchableText.contains(query) }
  359. }
  360. return sortOrder.sort(contacts)
  361. }
  362. private func reloadList() {
  363. rowViews.forEach { $0.removeFromSuperview() }
  364. rowViews.removeAll()
  365. let contacts = filteredContacts()
  366. emptyLabel.isHidden = !contacts.isEmpty
  367. for contact in contacts {
  368. let row = ContactRowView(
  369. name: contact.displayName,
  370. isSelected: selectedIDs.contains(contact.identifier),
  371. rowHeight: contactRowHeight,
  372. contentInset: rowContentInset,
  373. checkboxNameSpacing: checkboxNameSpacing
  374. )
  375. row.onToggle = { [weak self] in
  376. self?.toggleSelection(for: contact.identifier)
  377. }
  378. rowViews.append(row)
  379. listStack.addArrangedSubview(row)
  380. row.leadingAnchor.constraint(equalTo: listStack.leadingAnchor).isActive = true
  381. row.trailingAnchor.constraint(equalTo: listStack.trailingAnchor).isActive = true
  382. }
  383. updateSelectAllRow()
  384. }
  385. private func selectAllState() -> ContactSelectionState {
  386. let contacts = filteredContacts()
  387. guard !contacts.isEmpty else { return .unchecked }
  388. let selectedCount = contacts.filter { selectedIDs.contains($0.identifier) }.count
  389. if selectedCount == 0 { return .unchecked }
  390. if selectedCount == contacts.count { return .checked }
  391. return .indeterminate
  392. }
  393. private func updateSelectAllRow() {
  394. let contacts = filteredContacts()
  395. selectAllRow.isHidden = contacts.isEmpty
  396. selectAllRow.selectionState = selectAllState()
  397. }
  398. private func toggleSelectAll() {
  399. let contacts = filteredContacts()
  400. switch selectAllState() {
  401. case .checked, .indeterminate:
  402. contacts.forEach { selectedIDs.remove($0.identifier) }
  403. case .unchecked:
  404. contacts.forEach { selectedIDs.insert($0.identifier) }
  405. }
  406. for (row, contact) in zip(rowViews, contacts) {
  407. row.setSelected(selectedIDs.contains(contact.identifier))
  408. }
  409. updateSelectAllRow()
  410. }
  411. private func toggleSelection(for identifier: String) {
  412. if selectedIDs.contains(identifier) {
  413. selectedIDs.remove(identifier)
  414. } else {
  415. selectedIDs.insert(identifier)
  416. }
  417. let contacts = filteredContacts()
  418. for (row, contact) in zip(rowViews, contacts) where contact.identifier == identifier {
  419. row.setSelected(selectedIDs.contains(identifier))
  420. break
  421. }
  422. updateSelectAllRow()
  423. }
  424. private func printContacts() {
  425. let contacts = filteredContacts().filter { selectedIDs.contains($0.identifier) }
  426. PrintService.printContacts(contacts, from: window)
  427. }
  428. private func showAccessDeniedAlert() {
  429. let alert = NSAlert()
  430. alert.messageText = "Contacts Access Required"
  431. alert.informativeText = "Allow Smart Printer to access your contacts in System Settings to print them."
  432. alert.alertStyle = .warning
  433. alert.addButton(withTitle: "Open Settings")
  434. alert.addButton(withTitle: "Cancel")
  435. if alert.runModal() == .alertFirstButtonReturn {
  436. if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts") {
  437. NSWorkspace.shared.open(url)
  438. }
  439. }
  440. }
  441. private func showLoadFailedAlert() {
  442. let alert = NSAlert()
  443. alert.messageText = "Could Not Load Contacts"
  444. alert.informativeText = "Your contacts could not be read. Try again after granting access."
  445. alert.alertStyle = .warning
  446. alert.addButton(withTitle: "OK")
  447. alert.runModal()
  448. }
  449. }
  450. // MARK: - Selection Checkbox
  451. private enum ContactSelectionState {
  452. case unchecked
  453. case checked
  454. case indeterminate
  455. var symbolName: String {
  456. switch self {
  457. case .unchecked: "square"
  458. case .checked: "checkmark.square.fill"
  459. case .indeterminate: "minus.square.fill"
  460. }
  461. }
  462. var accessibilityDescription: String {
  463. switch self {
  464. case .unchecked: "Not selected"
  465. case .checked: "Selected"
  466. case .indeterminate: "Partially selected"
  467. }
  468. }
  469. }
  470. private final class ContactCheckboxView: NSView, AppearanceRefreshable {
  471. var state: ContactSelectionState = .unchecked {
  472. didSet { refreshAppearance() }
  473. }
  474. private let iconView = NSImageView()
  475. init() {
  476. super.init(frame: .zero)
  477. translatesAutoresizingMaskIntoConstraints = false
  478. iconView.translatesAutoresizingMaskIntoConstraints = false
  479. addSubview(iconView)
  480. NSLayoutConstraint.activate([
  481. iconView.leadingAnchor.constraint(equalTo: leadingAnchor),
  482. iconView.trailingAnchor.constraint(equalTo: trailingAnchor),
  483. iconView.topAnchor.constraint(equalTo: topAnchor),
  484. iconView.bottomAnchor.constraint(equalTo: bottomAnchor),
  485. widthAnchor.constraint(equalToConstant: 20),
  486. heightAnchor.constraint(equalToConstant: 20),
  487. ])
  488. refreshAppearance()
  489. }
  490. @available(*, unavailable)
  491. required init?(coder: NSCoder) { nil }
  492. func refreshAppearance() {
  493. if let image = NSImage(
  494. systemSymbolName: state.symbolName,
  495. accessibilityDescription: state.accessibilityDescription
  496. ) {
  497. let weight: NSFont.Weight = state == .unchecked ? .regular : .semibold
  498. let config = NSImage.SymbolConfiguration(pointSize: 20, weight: weight)
  499. iconView.image = image.withSymbolConfiguration(config)
  500. }
  501. switch state {
  502. case .unchecked:
  503. iconView.contentTintColor = AppTheme.textSecondary
  504. case .checked, .indeterminate:
  505. iconView.contentTintColor = AppTheme.blue
  506. }
  507. }
  508. }
  509. // MARK: - Contact Row
  510. private final class ContactRowView: NSControl, AppearanceRefreshable {
  511. var onToggle: (() -> Void)?
  512. private let checkboxView = ContactCheckboxView()
  513. private let nameLabel = NSTextField(labelWithString: "")
  514. private var isSelected = false
  515. private let rowHeight: CGFloat
  516. init(
  517. name: String,
  518. isSelected: Bool,
  519. rowHeight: CGFloat,
  520. contentInset: CGFloat,
  521. checkboxNameSpacing: CGFloat
  522. ) {
  523. self.isSelected = isSelected
  524. self.rowHeight = rowHeight
  525. super.init(frame: .zero)
  526. wantsLayer = true
  527. layer?.cornerRadius = rowHeight / 2
  528. nameLabel.stringValue = name
  529. nameLabel.font = AppTheme.semiboldFont(size: 16)
  530. nameLabel.lineBreakMode = .byTruncatingTail
  531. nameLabel.translatesAutoresizingMaskIntoConstraints = false
  532. addSubview(checkboxView)
  533. addSubview(nameLabel)
  534. NSLayoutConstraint.activate([
  535. heightAnchor.constraint(equalToConstant: rowHeight),
  536. checkboxView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: contentInset),
  537. checkboxView.centerYAnchor.constraint(equalTo: centerYAnchor),
  538. nameLabel.leadingAnchor.constraint(equalTo: checkboxView.trailingAnchor, constant: checkboxNameSpacing),
  539. nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -contentInset),
  540. nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  541. ])
  542. refreshAppearance()
  543. }
  544. @available(*, unavailable)
  545. required init?(coder: NSCoder) { nil }
  546. func setSelected(_ selected: Bool) {
  547. isSelected = selected
  548. refreshAppearance()
  549. }
  550. func refreshAppearance() {
  551. checkboxView.state = isSelected ? .checked : .unchecked
  552. if isSelected {
  553. layer?.borderWidth = 0
  554. layer?.backgroundColor = AppTheme.homeActiveBackground.cgColor
  555. nameLabel.textColor = AppTheme.homeActiveForeground
  556. } else {
  557. layer?.borderWidth = 1
  558. layer?.borderColor = AppTheme.paywallBorder.cgColor
  559. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  560. nameLabel.textColor = AppTheme.textPrimary
  561. }
  562. checkboxView.refreshAppearance()
  563. }
  564. override func mouseUp(with event: NSEvent) {
  565. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  566. onToggle?()
  567. }
  568. override func resetCursorRects() {
  569. addCursorRect(bounds, cursor: .pointingHand)
  570. }
  571. }
  572. // MARK: - Search Delegate
  573. extension PrintContactsOverlayView: NSTextFieldDelegate {
  574. func controlTextDidChange(_ obj: Notification) {
  575. reloadList()
  576. }
  577. }
  578. // MARK: - Select All Row
  579. private final class PrintContactsSelectAllRow: NSControl, AppearanceRefreshable {
  580. var onClick: (() -> Void)?
  581. private let checkboxView = ContactCheckboxView()
  582. private let titleLabel = NSTextField(labelWithString: "Select all")
  583. private var contentInset: CGFloat = 16
  584. private var checkboxNameSpacing: CGFloat = 12
  585. private var leadingConstraint: NSLayoutConstraint?
  586. private var titleLeadingConstraint: NSLayoutConstraint?
  587. var selectionState: ContactSelectionState {
  588. get { checkboxView.state }
  589. set { checkboxView.state = newValue }
  590. }
  591. func applyLayout(contentInset: CGFloat, checkboxNameSpacing: CGFloat) {
  592. self.contentInset = contentInset
  593. self.checkboxNameSpacing = checkboxNameSpacing
  594. leadingConstraint?.constant = contentInset
  595. titleLeadingConstraint?.constant = checkboxNameSpacing
  596. }
  597. init() {
  598. super.init(frame: .zero)
  599. translatesAutoresizingMaskIntoConstraints = false
  600. titleLabel.font = AppTheme.regularFont(size: 15)
  601. titleLabel.isSelectable = false
  602. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  603. addSubview(checkboxView)
  604. addSubview(titleLabel)
  605. let leading = checkboxView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: contentInset)
  606. let titleLeading = titleLabel.leadingAnchor.constraint(equalTo: checkboxView.trailingAnchor, constant: checkboxNameSpacing)
  607. leadingConstraint = leading
  608. titleLeadingConstraint = titleLeading
  609. NSLayoutConstraint.activate([
  610. leading,
  611. checkboxView.centerYAnchor.constraint(equalTo: centerYAnchor),
  612. titleLeading,
  613. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  614. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
  615. ])
  616. refreshAppearance()
  617. }
  618. @available(*, unavailable)
  619. required init?(coder: NSCoder) { nil }
  620. func refreshAppearance() {
  621. titleLabel.textColor = AppTheme.textPrimary
  622. checkboxView.refreshAppearance()
  623. }
  624. override func mouseUp(with event: NSEvent) {
  625. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  626. onClick?()
  627. }
  628. override func resetCursorRects() {
  629. addCursorRect(bounds, cursor: .pointingHand)
  630. }
  631. }
  632. // MARK: - Sort Button
  633. private final class PrintContactsSortButton: NSControl, AppearanceRefreshable {
  634. var sortOrder: ContactSortOrder = .nameAscending {
  635. didSet { rebuildMenu() }
  636. }
  637. var onSortChange: ((ContactSortOrder) -> Void)?
  638. private let iconView = NSImageView()
  639. private let sortMenu = NSMenu()
  640. init() {
  641. super.init(frame: .zero)
  642. toolTip = "Sort"
  643. wantsLayer = true
  644. layer?.cornerRadius = 12
  645. if let image = NSImage(systemSymbolName: "arrow.up.arrow.down", accessibilityDescription: "Sort") {
  646. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  647. iconView.image = image.withSymbolConfiguration(config)
  648. }
  649. iconView.translatesAutoresizingMaskIntoConstraints = false
  650. addSubview(iconView)
  651. NSLayoutConstraint.activate([
  652. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  653. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  654. ])
  655. rebuildMenu()
  656. refreshAppearance()
  657. }
  658. @available(*, unavailable)
  659. required init?(coder: NSCoder) { nil }
  660. func refreshAppearance() {
  661. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  662. layer?.borderColor = AppTheme.paywallBorder.cgColor
  663. layer?.borderWidth = 1.5
  664. iconView.contentTintColor = AppTheme.textPrimary
  665. }
  666. private func rebuildMenu() {
  667. sortMenu.removeAllItems()
  668. for order in ContactSortOrder.allCases {
  669. let item = NSMenuItem(title: order.title, action: #selector(menuItemSelected(_:)), keyEquivalent: "")
  670. item.target = self
  671. item.representedObject = order.rawValue
  672. item.state = order == sortOrder ? .on : .off
  673. sortMenu.addItem(item)
  674. }
  675. }
  676. @objc private func menuItemSelected(_ sender: NSMenuItem) {
  677. guard let raw = sender.representedObject as? String,
  678. let order = ContactSortOrder(rawValue: raw) else { return }
  679. sortOrder = order
  680. rebuildMenu()
  681. onSortChange?(order)
  682. }
  683. override func mouseDown(with event: NSEvent) {
  684. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  685. rebuildMenu()
  686. sortMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: bounds.height + 4), in: self)
  687. }
  688. override func resetCursorRects() {
  689. addCursorRect(bounds, cursor: .pointingHand)
  690. }
  691. }
  692. // MARK: - Toolbar Button
  693. private final class PrintContactsToolbarButton: NSControl, AppearanceRefreshable {
  694. var onClick: (() -> Void)?
  695. private let symbolName: String
  696. private let iconView = NSImageView()
  697. init(symbolName: String, accessibilityLabel: String) {
  698. self.symbolName = symbolName
  699. super.init(frame: .zero)
  700. toolTip = accessibilityLabel
  701. wantsLayer = true
  702. layer?.cornerRadius = 12
  703. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  704. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  705. iconView.image = image.withSymbolConfiguration(config)
  706. }
  707. iconView.translatesAutoresizingMaskIntoConstraints = false
  708. addSubview(iconView)
  709. NSLayoutConstraint.activate([
  710. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  711. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  712. ])
  713. refreshAppearance()
  714. }
  715. @available(*, unavailable)
  716. required init?(coder: NSCoder) { nil }
  717. func refreshAppearance() {
  718. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  719. layer?.borderColor = AppTheme.paywallBorder.cgColor
  720. layer?.borderWidth = 1.5
  721. iconView.contentTintColor = AppTheme.textPrimary
  722. }
  723. override func mouseUp(with event: NSEvent) {
  724. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  725. onClick?()
  726. }
  727. override func resetCursorRects() {
  728. addCursorRect(bounds, cursor: .pointingHand)
  729. }
  730. }
  731. // MARK: - Print Button
  732. private final class PrintContactsPrintButton: NSControl, AppearanceRefreshable {
  733. var onClick: (() -> Void)?
  734. private let titleLabel = NSTextField(labelWithString: "Print Contacts")
  735. private let iconView = NSImageView()
  736. private var hoverTracker: HoverTracker?
  737. private var isHovered = false
  738. init() {
  739. super.init(frame: .zero)
  740. wantsLayer = true
  741. layer?.cornerRadius = 22
  742. titleLabel.font = AppTheme.semiboldFont(size: 16)
  743. titleLabel.textColor = .white
  744. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  745. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  746. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  747. iconView.image = image.withSymbolConfiguration(config)
  748. }
  749. iconView.contentTintColor = .white
  750. iconView.translatesAutoresizingMaskIntoConstraints = false
  751. addSubview(titleLabel)
  752. addSubview(iconView)
  753. NSLayoutConstraint.activate([
  754. heightAnchor.constraint(equalToConstant: 52),
  755. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  756. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  757. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  758. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  759. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  760. iconView.widthAnchor.constraint(equalToConstant: 20),
  761. iconView.heightAnchor.constraint(equalToConstant: 20),
  762. ])
  763. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  764. self?.setHovered(hovering)
  765. }
  766. refreshAppearance()
  767. }
  768. @available(*, unavailable)
  769. required init?(coder: NSCoder) { nil }
  770. func refreshAppearance() {
  771. let color = isHovered
  772. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  773. : AppTheme.blue
  774. layer?.backgroundColor = color.cgColor
  775. titleLabel.textColor = .white
  776. iconView.contentTintColor = .white
  777. }
  778. private func setHovered(_ hovering: Bool) {
  779. isHovered = hovering
  780. animateHover {
  781. let color = hovering
  782. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  783. : AppTheme.blue
  784. layer?.backgroundColor = color.cgColor
  785. layer?.transform = hovering
  786. ? CATransform3DMakeScale(1.03, 1.03, 1)
  787. : CATransform3DIdentity
  788. }
  789. }
  790. override func mouseUp(with event: NSEvent) {
  791. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  792. onClick?()
  793. }
  794. override func resetCursorRects() {
  795. addCursorRect(bounds, cursor: .pointingHand)
  796. }
  797. }
  798. // MARK: - Flipped Document View
  799. private final class FlippedDocumentView: NSView {
  800. override var isFlipped: Bool { true }
  801. }