PrintContactsView.swift 34 KB

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