PrintContactsView.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  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. // MARK: - Contacts Service
  19. enum ContactsService {
  20. static func requestAccess() async -> Bool {
  21. let store = CNContactStore()
  22. do {
  23. return try await store.requestAccess(for: .contacts)
  24. } catch {
  25. return false
  26. }
  27. }
  28. static func fetchContacts() throws -> [PrintableContact] {
  29. let store = CNContactStore()
  30. let keys: [CNKeyDescriptor] = [
  31. CNContactIdentifierKey as CNKeyDescriptor,
  32. CNContactGivenNameKey as CNKeyDescriptor,
  33. CNContactFamilyNameKey as CNKeyDescriptor,
  34. CNContactOrganizationNameKey as CNKeyDescriptor,
  35. CNContactPhoneNumbersKey as CNKeyDescriptor,
  36. CNContactEmailAddressesKey as CNKeyDescriptor,
  37. CNContactPostalAddressesKey as CNKeyDescriptor,
  38. ]
  39. let request = CNContactFetchRequest(keysToFetch: keys)
  40. request.sortOrder = .givenName
  41. var contacts: [PrintableContact] = []
  42. try store.enumerateContacts(with: request) { contact, _ in
  43. let name = CNContactFormatter.string(from: contact, style: .fullName)?
  44. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  45. let organization = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines)
  46. let displayName: String
  47. if !name.isEmpty {
  48. displayName = name
  49. } else if !organization.isEmpty {
  50. displayName = organization
  51. } else {
  52. displayName = "Unnamed Contact"
  53. }
  54. let phones = contact.phoneNumbers.map(\.value.stringValue).filter { !$0.isEmpty }
  55. let emails = contact.emailAddresses.map { $0.value as String }.filter { !$0.isEmpty }
  56. let addresses = contact.postalAddresses.map { entry in
  57. CNPostalAddressFormatter.string(from: entry.value, style: .mailingAddress)
  58. }.filter { !$0.isEmpty }
  59. contacts.append(
  60. PrintableContact(
  61. identifier: contact.identifier,
  62. displayName: displayName,
  63. organization: organization.isEmpty ? nil : organization,
  64. phoneNumbers: phones,
  65. emailAddresses: emails,
  66. postalAddresses: addresses
  67. )
  68. )
  69. }
  70. return contacts
  71. }
  72. }
  73. // MARK: - Print Contacts Service
  74. enum PrintContactsService {
  75. static func present(from window: NSWindow?) {
  76. let hostWindow = window ?? NSApp.keyWindow
  77. guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
  78. viewController.presentPrintContacts()
  79. }
  80. }
  81. // MARK: - Overlay
  82. final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
  83. var onDismiss: (() -> Void)?
  84. private let backButton = PrintContactsToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  85. private let searchContainer = NSView()
  86. private let searchIconView = NSImageView()
  87. private let searchField = NSTextField()
  88. private let scrollView = NSScrollView()
  89. private let listDocumentView = FlippedDocumentView()
  90. private let listStack = NSStackView()
  91. private let printButton = PrintContactsPrintButton()
  92. private let emptyLabel = NSTextField(labelWithString: "No contacts found")
  93. private let horizontalInset: CGFloat = 16
  94. private let searchHeight: CGFloat = 44
  95. private let contactRowHeight: CGFloat = 52
  96. private var allContacts: [PrintableContact] = []
  97. private var selectedIDs = Set<String>()
  98. private var rowViews: [ContactRowView] = []
  99. init() {
  100. super.init(frame: .zero)
  101. translatesAutoresizingMaskIntoConstraints = false
  102. setup()
  103. refreshAppearance()
  104. NotificationCenter.default.addObserver(
  105. self,
  106. selector: #selector(appearanceDidChange),
  107. name: .appearanceDidChange,
  108. object: nil
  109. )
  110. loadContacts()
  111. }
  112. deinit {
  113. NotificationCenter.default.removeObserver(self)
  114. }
  115. @objc private func appearanceDidChange() {
  116. refreshAppearance()
  117. }
  118. @available(*, unavailable)
  119. required init?(coder: NSCoder) { nil }
  120. func refreshAppearance() {
  121. layer?.backgroundColor = AppTheme.background.cgColor
  122. searchContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  123. searchContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  124. searchField.textColor = AppTheme.textPrimary
  125. searchField.backgroundColor = .clear
  126. searchIconView.contentTintColor = AppTheme.textSecondary
  127. emptyLabel.textColor = AppTheme.textSecondary
  128. backButton.refreshAppearance()
  129. printButton.refreshAppearance()
  130. rowViews.forEach { $0.refreshAppearance() }
  131. }
  132. func present(in parent: NSView) {
  133. guard superview == nil else { return }
  134. parent.addSubview(self)
  135. NSLayoutConstraint.activate([
  136. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  137. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  138. topAnchor.constraint(equalTo: parent.topAnchor),
  139. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  140. ])
  141. alphaValue = 0
  142. NSAnimationContext.runAnimationGroup { context in
  143. context.duration = 0.2
  144. animator().alphaValue = 1
  145. }
  146. window?.makeFirstResponder(searchField)
  147. }
  148. func dismiss(animated: Bool = true) {
  149. let remove = { [weak self] in
  150. self?.removeFromSuperview()
  151. self?.onDismiss?()
  152. }
  153. guard animated else {
  154. remove()
  155. return
  156. }
  157. NSAnimationContext.runAnimationGroup({ context in
  158. context.duration = 0.15
  159. animator().alphaValue = 0
  160. }, completionHandler: remove)
  161. }
  162. private func setup() {
  163. wantsLayer = true
  164. searchContainer.wantsLayer = true
  165. searchContainer.layer?.cornerRadius = searchHeight / 2
  166. searchContainer.layer?.borderWidth = 1
  167. searchContainer.translatesAutoresizingMaskIntoConstraints = false
  168. if let image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search") {
  169. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
  170. searchIconView.image = image.withSymbolConfiguration(config)
  171. }
  172. searchIconView.translatesAutoresizingMaskIntoConstraints = false
  173. searchField.placeholderAttributedString = NSAttributedString(
  174. string: "Search",
  175. attributes: [
  176. .foregroundColor: AppTheme.textSecondary,
  177. .font: AppTheme.regularFont(size: 15),
  178. ]
  179. )
  180. searchField.font = AppTheme.regularFont(size: 15)
  181. searchField.isBordered = false
  182. searchField.isBezeled = false
  183. searchField.drawsBackground = false
  184. searchField.focusRingType = .none
  185. searchField.translatesAutoresizingMaskIntoConstraints = false
  186. searchField.delegate = self
  187. scrollView.translatesAutoresizingMaskIntoConstraints = false
  188. scrollView.hasVerticalScroller = true
  189. scrollView.hasHorizontalScroller = false
  190. scrollView.autohidesScrollers = true
  191. scrollView.borderType = .noBorder
  192. scrollView.drawsBackground = false
  193. listStack.orientation = .vertical
  194. listStack.alignment = .leading
  195. listStack.spacing = 12
  196. listStack.translatesAutoresizingMaskIntoConstraints = false
  197. listDocumentView.translatesAutoresizingMaskIntoConstraints = false
  198. listDocumentView.addSubview(listStack)
  199. emptyLabel.font = AppTheme.regularFont(size: 14)
  200. emptyLabel.alignment = .center
  201. emptyLabel.isHidden = true
  202. emptyLabel.translatesAutoresizingMaskIntoConstraints = false
  203. scrollView.documentView = listDocumentView
  204. backButton.translatesAutoresizingMaskIntoConstraints = false
  205. printButton.translatesAutoresizingMaskIntoConstraints = false
  206. backButton.onClick = { [weak self] in self?.dismiss() }
  207. printButton.onClick = { [weak self] in self?.printContacts() }
  208. addSubview(backButton)
  209. addSubview(searchContainer)
  210. searchContainer.addSubview(searchIconView)
  211. searchContainer.addSubview(searchField)
  212. addSubview(scrollView)
  213. addSubview(emptyLabel)
  214. addSubview(printButton)
  215. NSLayoutConstraint.activate([
  216. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  217. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  218. backButton.widthAnchor.constraint(equalToConstant: 40),
  219. backButton.heightAnchor.constraint(equalToConstant: 40),
  220. searchContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  221. searchContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  222. searchContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
  223. searchContainer.heightAnchor.constraint(equalToConstant: searchHeight),
  224. searchIconView.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 16),
  225. searchIconView.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
  226. searchIconView.widthAnchor.constraint(equalToConstant: 16),
  227. searchIconView.heightAnchor.constraint(equalToConstant: 16),
  228. searchField.leadingAnchor.constraint(equalTo: searchIconView.trailingAnchor, constant: 10),
  229. searchField.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -16),
  230. searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
  231. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  232. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  233. scrollView.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 16),
  234. scrollView.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
  235. listDocumentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  236. listDocumentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
  237. listDocumentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
  238. listDocumentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
  239. listStack.leadingAnchor.constraint(equalTo: listDocumentView.leadingAnchor),
  240. listStack.trailingAnchor.constraint(equalTo: listDocumentView.trailingAnchor),
  241. listStack.topAnchor.constraint(equalTo: listDocumentView.topAnchor),
  242. listStack.bottomAnchor.constraint(equalTo: listDocumentView.bottomAnchor),
  243. emptyLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  244. emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
  245. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  246. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  247. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  248. ])
  249. }
  250. private func loadContacts() {
  251. Task { @MainActor in
  252. let granted = await ContactsService.requestAccess()
  253. guard granted else {
  254. showAccessDeniedAlert()
  255. return
  256. }
  257. do {
  258. allContacts = try ContactsService.fetchContacts()
  259. selectedIDs = Set(allContacts.map(\.identifier))
  260. reloadList()
  261. } catch {
  262. showLoadFailedAlert()
  263. }
  264. }
  265. }
  266. private func filteredContacts() -> [PrintableContact] {
  267. let query = searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  268. guard !query.isEmpty else { return allContacts }
  269. return allContacts.filter { $0.searchableText.contains(query) }
  270. }
  271. private func reloadList() {
  272. rowViews.forEach { $0.removeFromSuperview() }
  273. rowViews.removeAll()
  274. let contacts = filteredContacts()
  275. emptyLabel.isHidden = !contacts.isEmpty
  276. for contact in contacts {
  277. let row = ContactRowView(
  278. name: contact.displayName,
  279. isSelected: selectedIDs.contains(contact.identifier),
  280. rowHeight: contactRowHeight
  281. )
  282. row.onToggle = { [weak self] in
  283. self?.toggleSelection(for: contact.identifier)
  284. }
  285. rowViews.append(row)
  286. listStack.addArrangedSubview(row)
  287. row.leadingAnchor.constraint(equalTo: listStack.leadingAnchor).isActive = true
  288. row.trailingAnchor.constraint(equalTo: listStack.trailingAnchor).isActive = true
  289. }
  290. }
  291. private func toggleSelection(for identifier: String) {
  292. if selectedIDs.contains(identifier) {
  293. selectedIDs.remove(identifier)
  294. } else {
  295. selectedIDs.insert(identifier)
  296. }
  297. reloadList()
  298. }
  299. private func printContacts() {
  300. let contacts = filteredContacts().filter { selectedIDs.contains($0.identifier) }
  301. PrintService.printContacts(contacts, from: window)
  302. }
  303. private func showAccessDeniedAlert() {
  304. let alert = NSAlert()
  305. alert.messageText = "Contacts Access Required"
  306. alert.informativeText = "Allow Smart Printer to access your contacts in System Settings to print them."
  307. alert.alertStyle = .warning
  308. alert.addButton(withTitle: "Open Settings")
  309. alert.addButton(withTitle: "Cancel")
  310. if alert.runModal() == .alertFirstButtonReturn {
  311. if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts") {
  312. NSWorkspace.shared.open(url)
  313. }
  314. }
  315. }
  316. private func showLoadFailedAlert() {
  317. let alert = NSAlert()
  318. alert.messageText = "Could Not Load Contacts"
  319. alert.informativeText = "Your contacts could not be read. Try again after granting access."
  320. alert.alertStyle = .warning
  321. alert.addButton(withTitle: "OK")
  322. alert.runModal()
  323. }
  324. }
  325. // MARK: - Contact Row
  326. private final class ContactRowView: NSControl, AppearanceRefreshable {
  327. var onToggle: (() -> Void)?
  328. private let nameLabel = NSTextField(labelWithString: "")
  329. private var isSelected = false
  330. private let rowHeight: CGFloat
  331. init(name: String, isSelected: Bool, rowHeight: CGFloat) {
  332. self.isSelected = isSelected
  333. self.rowHeight = rowHeight
  334. super.init(frame: .zero)
  335. wantsLayer = true
  336. layer?.cornerRadius = rowHeight / 2
  337. nameLabel.stringValue = name
  338. nameLabel.font = AppTheme.semiboldFont(size: 16)
  339. nameLabel.lineBreakMode = .byTruncatingTail
  340. nameLabel.translatesAutoresizingMaskIntoConstraints = false
  341. addSubview(nameLabel)
  342. NSLayoutConstraint.activate([
  343. heightAnchor.constraint(equalToConstant: rowHeight),
  344. nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
  345. nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
  346. nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  347. ])
  348. refreshAppearance()
  349. }
  350. @available(*, unavailable)
  351. required init?(coder: NSCoder) { nil }
  352. func refreshAppearance() {
  353. if isSelected {
  354. layer?.borderWidth = 0
  355. layer?.backgroundColor = AppTheme.homeActiveBackground.cgColor
  356. nameLabel.textColor = AppTheme.homeActiveForeground
  357. } else {
  358. layer?.borderWidth = 1
  359. layer?.borderColor = AppTheme.paywallBorder.cgColor
  360. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  361. nameLabel.textColor = AppTheme.textPrimary
  362. }
  363. }
  364. override func mouseUp(with event: NSEvent) {
  365. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  366. onToggle?()
  367. }
  368. override func resetCursorRects() {
  369. addCursorRect(bounds, cursor: .pointingHand)
  370. }
  371. }
  372. // MARK: - Search Delegate
  373. extension PrintContactsOverlayView: NSTextFieldDelegate {
  374. func controlTextDidChange(_ obj: Notification) {
  375. reloadList()
  376. }
  377. }
  378. // MARK: - Toolbar Button
  379. private final class PrintContactsToolbarButton: NSControl, AppearanceRefreshable {
  380. var onClick: (() -> Void)?
  381. private let symbolName: String
  382. private let iconView = NSImageView()
  383. init(symbolName: String, accessibilityLabel: String) {
  384. self.symbolName = symbolName
  385. super.init(frame: .zero)
  386. toolTip = accessibilityLabel
  387. wantsLayer = true
  388. layer?.cornerRadius = 12
  389. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  390. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  391. iconView.image = image.withSymbolConfiguration(config)
  392. }
  393. iconView.translatesAutoresizingMaskIntoConstraints = false
  394. addSubview(iconView)
  395. NSLayoutConstraint.activate([
  396. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  397. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  398. ])
  399. refreshAppearance()
  400. }
  401. @available(*, unavailable)
  402. required init?(coder: NSCoder) { nil }
  403. func refreshAppearance() {
  404. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  405. layer?.borderColor = AppTheme.paywallBorder.cgColor
  406. layer?.borderWidth = 1.5
  407. iconView.contentTintColor = AppTheme.textPrimary
  408. }
  409. override func mouseUp(with event: NSEvent) {
  410. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  411. onClick?()
  412. }
  413. override func resetCursorRects() {
  414. addCursorRect(bounds, cursor: .pointingHand)
  415. }
  416. }
  417. // MARK: - Print Button
  418. private final class PrintContactsPrintButton: NSControl, AppearanceRefreshable {
  419. var onClick: (() -> Void)?
  420. private let titleLabel = NSTextField(labelWithString: "Print Contacts")
  421. private let iconView = NSImageView()
  422. private var hoverTracker: HoverTracker?
  423. private var isHovered = false
  424. init() {
  425. super.init(frame: .zero)
  426. wantsLayer = true
  427. layer?.cornerRadius = 22
  428. titleLabel.font = AppTheme.semiboldFont(size: 16)
  429. titleLabel.textColor = .white
  430. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  431. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  432. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  433. iconView.image = image.withSymbolConfiguration(config)
  434. }
  435. iconView.contentTintColor = .white
  436. iconView.translatesAutoresizingMaskIntoConstraints = false
  437. addSubview(titleLabel)
  438. addSubview(iconView)
  439. NSLayoutConstraint.activate([
  440. heightAnchor.constraint(equalToConstant: 52),
  441. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  442. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  443. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  444. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  445. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  446. iconView.widthAnchor.constraint(equalToConstant: 20),
  447. iconView.heightAnchor.constraint(equalToConstant: 20),
  448. ])
  449. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  450. self?.setHovered(hovering)
  451. }
  452. refreshAppearance()
  453. }
  454. @available(*, unavailable)
  455. required init?(coder: NSCoder) { nil }
  456. func refreshAppearance() {
  457. let color = isHovered
  458. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  459. : AppTheme.blue
  460. layer?.backgroundColor = color.cgColor
  461. titleLabel.textColor = .white
  462. iconView.contentTintColor = .white
  463. }
  464. private func setHovered(_ hovering: Bool) {
  465. isHovered = hovering
  466. animateHover {
  467. let color = hovering
  468. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  469. : AppTheme.blue
  470. layer?.backgroundColor = color.cgColor
  471. layer?.transform = hovering
  472. ? CATransform3DMakeScale(1.03, 1.03, 1)
  473. : CATransform3DIdentity
  474. }
  475. }
  476. override func mouseUp(with event: NSEvent) {
  477. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  478. onClick?()
  479. }
  480. override func resetCursorRects() {
  481. addCursorRect(bounds, cursor: .pointingHand)
  482. }
  483. }
  484. // MARK: - Flipped Document View
  485. private final class FlippedDocumentView: NSView {
  486. override var isFlipped: Bool { true }
  487. }