|
@@ -0,0 +1,565 @@
|
|
|
|
|
+import Cocoa
|
|
|
|
|
+import Contacts
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Models
|
|
|
|
|
+
|
|
|
|
|
+struct PrintableContact: Equatable {
|
|
|
|
|
+ let identifier: String
|
|
|
|
|
+ let displayName: String
|
|
|
|
|
+ let organization: String?
|
|
|
|
|
+ let phoneNumbers: [String]
|
|
|
|
|
+ let emailAddresses: [String]
|
|
|
|
|
+ let postalAddresses: [String]
|
|
|
|
|
+
|
|
|
|
|
+ var searchableText: String {
|
|
|
|
|
+ ([displayName, organization] + phoneNumbers + emailAddresses + postalAddresses)
|
|
|
|
|
+ .compactMap { $0 }
|
|
|
|
|
+ .joined(separator: " ")
|
|
|
|
|
+ .lowercased()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Contacts Service
|
|
|
|
|
+
|
|
|
|
|
+enum ContactsService {
|
|
|
|
|
+ static func requestAccess() async -> Bool {
|
|
|
|
|
+ let store = CNContactStore()
|
|
|
|
|
+ do {
|
|
|
|
|
+ return try await store.requestAccess(for: .contacts)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func fetchContacts() throws -> [PrintableContact] {
|
|
|
|
|
+ let store = CNContactStore()
|
|
|
|
|
+ let keys: [CNKeyDescriptor] = [
|
|
|
|
|
+ CNContactIdentifierKey as CNKeyDescriptor,
|
|
|
|
|
+ CNContactGivenNameKey as CNKeyDescriptor,
|
|
|
|
|
+ CNContactFamilyNameKey as CNKeyDescriptor,
|
|
|
|
|
+ CNContactOrganizationNameKey as CNKeyDescriptor,
|
|
|
|
|
+ CNContactPhoneNumbersKey as CNKeyDescriptor,
|
|
|
|
|
+ CNContactEmailAddressesKey as CNKeyDescriptor,
|
|
|
|
|
+ CNContactPostalAddressesKey as CNKeyDescriptor,
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ let request = CNContactFetchRequest(keysToFetch: keys)
|
|
|
|
|
+ request.sortOrder = .givenName
|
|
|
|
|
+
|
|
|
|
|
+ var contacts: [PrintableContact] = []
|
|
|
|
|
+ try store.enumerateContacts(with: request) { contact, _ in
|
|
|
|
|
+ let name = CNContactFormatter.string(from: contact, style: .fullName)?
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ let organization = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let displayName: String
|
|
|
|
|
+ if !name.isEmpty {
|
|
|
|
|
+ displayName = name
|
|
|
|
|
+ } else if !organization.isEmpty {
|
|
|
|
|
+ displayName = organization
|
|
|
|
|
+ } else {
|
|
|
|
|
+ displayName = "Unnamed Contact"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let phones = contact.phoneNumbers.map(\.value.stringValue).filter { !$0.isEmpty }
|
|
|
|
|
+ let emails = contact.emailAddresses.map { $0.value as String }.filter { !$0.isEmpty }
|
|
|
|
|
+ let addresses = contact.postalAddresses.map { entry in
|
|
|
|
|
+ CNPostalAddressFormatter.string(from: entry.value, style: .mailingAddress)
|
|
|
|
|
+ }.filter { !$0.isEmpty }
|
|
|
|
|
+
|
|
|
|
|
+ contacts.append(
|
|
|
|
|
+ PrintableContact(
|
|
|
|
|
+ identifier: contact.identifier,
|
|
|
|
|
+ displayName: displayName,
|
|
|
|
|
+ organization: organization.isEmpty ? nil : organization,
|
|
|
|
|
+ phoneNumbers: phones,
|
|
|
|
|
+ emailAddresses: emails,
|
|
|
|
|
+ postalAddresses: addresses
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return contacts
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Print Contacts Service
|
|
|
|
|
+
|
|
|
|
|
+enum PrintContactsService {
|
|
|
|
|
+ static func present(from window: NSWindow?) {
|
|
|
|
|
+ let hostWindow = window ?? NSApp.keyWindow
|
|
|
|
|
+ guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
|
|
|
|
|
+ viewController.presentPrintContacts()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Overlay
|
|
|
|
|
+
|
|
|
|
|
+final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
|
|
|
|
|
+ var onDismiss: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let backButton = PrintContactsToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
|
|
|
+ private let searchContainer = NSView()
|
|
|
|
|
+ private let searchIconView = NSImageView()
|
|
|
|
|
+ private let searchField = NSTextField()
|
|
|
|
|
+ private let scrollView = NSScrollView()
|
|
|
|
|
+ private let listStack = NSStackView()
|
|
|
|
|
+ private let printButton = PrintContactsPrintButton()
|
|
|
|
|
+ private let emptyLabel = NSTextField(labelWithString: "No contacts found")
|
|
|
|
|
+
|
|
|
|
|
+ private let horizontalInset: CGFloat = 16
|
|
|
|
|
+ private let searchHeight: CGFloat = 44
|
|
|
|
|
+ private let contactRowHeight: CGFloat = 52
|
|
|
|
|
+
|
|
|
|
|
+ private var allContacts: [PrintableContact] = []
|
|
|
|
|
+ private var selectedIDs = Set<String>()
|
|
|
|
|
+ private var rowViews: [ContactRowView] = []
|
|
|
|
|
+
|
|
|
|
|
+ init() {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ setup()
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
|
|
+ self,
|
|
|
|
|
+ selector: #selector(appearanceDidChange),
|
|
|
|
|
+ name: .appearanceDidChange,
|
|
|
|
|
+ object: nil
|
|
|
|
|
+ )
|
|
|
|
|
+ loadContacts()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ deinit {
|
|
|
|
|
+ NotificationCenter.default.removeObserver(self)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func appearanceDidChange() {
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.background.cgColor
|
|
|
|
|
+ searchContainer.layer?.backgroundColor = AppTheme.blueLight.cgColor
|
|
|
|
|
+ searchField.textColor = AppTheme.textPrimary
|
|
|
|
|
+ searchField.backgroundColor = .clear
|
|
|
|
|
+ searchIconView.contentTintColor = AppTheme.textSecondary
|
|
|
|
|
+ emptyLabel.textColor = AppTheme.textSecondary
|
|
|
|
|
+ backButton.refreshAppearance()
|
|
|
|
|
+ printButton.refreshAppearance()
|
|
|
|
|
+ rowViews.forEach { $0.refreshAppearance() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func present(in parent: NSView) {
|
|
|
|
|
+ guard superview == nil else { return }
|
|
|
|
|
+ parent.addSubview(self)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
|
|
|
|
+ trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
|
|
|
|
+ topAnchor.constraint(equalTo: parent.topAnchor),
|
|
|
|
|
+ bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ alphaValue = 0
|
|
|
|
|
+ NSAnimationContext.runAnimationGroup { context in
|
|
|
|
|
+ context.duration = 0.2
|
|
|
|
|
+ animator().alphaValue = 1
|
|
|
|
|
+ }
|
|
|
|
|
+ window?.makeFirstResponder(searchField)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func dismiss(animated: Bool = true) {
|
|
|
|
|
+ let remove = { [weak self] in
|
|
|
|
|
+ self?.removeFromSuperview()
|
|
|
|
|
+ self?.onDismiss?()
|
|
|
|
|
+ }
|
|
|
|
|
+ guard animated else {
|
|
|
|
|
+ remove()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ NSAnimationContext.runAnimationGroup({ context in
|
|
|
|
|
+ context.duration = 0.15
|
|
|
|
|
+ animator().alphaValue = 0
|
|
|
|
|
+ }, completionHandler: remove)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setup() {
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+
|
|
|
|
|
+ searchContainer.wantsLayer = true
|
|
|
|
|
+ searchContainer.layer?.cornerRadius = searchHeight / 2
|
|
|
|
|
+ searchContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search") {
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
|
|
|
|
+ searchIconView.image = image.withSymbolConfiguration(config)
|
|
|
|
|
+ }
|
|
|
|
|
+ searchIconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ searchField.placeholderAttributedString = NSAttributedString(
|
|
|
|
|
+ string: "Search",
|
|
|
|
|
+ attributes: [
|
|
|
|
|
+ .foregroundColor: AppTheme.textSecondary,
|
|
|
|
|
+ .font: AppTheme.regularFont(size: 15),
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ searchField.font = AppTheme.regularFont(size: 15)
|
|
|
|
|
+ searchField.isBordered = false
|
|
|
|
|
+ searchField.isBezeled = false
|
|
|
|
|
+ searchField.drawsBackground = false
|
|
|
|
|
+ searchField.focusRingType = .none
|
|
|
|
|
+ searchField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ searchField.delegate = self
|
|
|
|
|
+
|
|
|
|
|
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ scrollView.hasVerticalScroller = true
|
|
|
|
|
+ scrollView.hasHorizontalScroller = false
|
|
|
|
|
+ scrollView.autohidesScrollers = true
|
|
|
|
|
+ scrollView.borderType = .noBorder
|
|
|
|
|
+ scrollView.drawsBackground = false
|
|
|
|
|
+
|
|
|
|
|
+ listStack.orientation = .vertical
|
|
|
|
|
+ listStack.alignment = .leading
|
|
|
|
|
+ listStack.spacing = 12
|
|
|
|
|
+ listStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ emptyLabel.font = AppTheme.regularFont(size: 14)
|
|
|
|
|
+ emptyLabel.alignment = .center
|
|
|
|
|
+ emptyLabel.isHidden = true
|
|
|
|
|
+ emptyLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ scrollView.documentView = listStack
|
|
|
|
|
+
|
|
|
|
|
+ backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ printButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ backButton.onClick = { [weak self] in self?.dismiss() }
|
|
|
|
|
+ printButton.onClick = { [weak self] in self?.printContacts() }
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(backButton)
|
|
|
|
|
+ addSubview(searchContainer)
|
|
|
|
|
+ searchContainer.addSubview(searchIconView)
|
|
|
|
|
+ searchContainer.addSubview(searchField)
|
|
|
|
|
+ addSubview(scrollView)
|
|
|
|
|
+ addSubview(emptyLabel)
|
|
|
|
|
+ addSubview(printButton)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
|
|
|
|
|
+ backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
|
|
|
|
+ backButton.widthAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
+ backButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
+
|
|
|
|
|
+ searchContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
|
|
|
|
+ searchContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
|
|
|
|
+ searchContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
|
|
|
|
|
+ searchContainer.heightAnchor.constraint(equalToConstant: searchHeight),
|
|
|
|
|
+
|
|
|
|
|
+ searchIconView.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 16),
|
|
|
|
|
+ searchIconView.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
|
|
|
|
|
+ searchIconView.widthAnchor.constraint(equalToConstant: 16),
|
|
|
|
|
+ searchIconView.heightAnchor.constraint(equalToConstant: 16),
|
|
|
|
|
+
|
|
|
|
|
+ searchField.leadingAnchor.constraint(equalTo: searchIconView.trailingAnchor, constant: 10),
|
|
|
|
|
+ searchField.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -16),
|
|
|
|
|
+ searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
|
|
|
|
+ scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
|
|
|
|
+ scrollView.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 16),
|
|
|
|
|
+ scrollView.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
|
|
|
|
|
+
|
|
|
|
|
+ listStack.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
|
|
|
|
|
+ listStack.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
|
|
|
|
|
+ listStack.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
|
|
|
|
|
+ listStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ emptyLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
+ emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
+ printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
|
|
|
|
|
+ printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func loadContacts() {
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ let granted = await ContactsService.requestAccess()
|
|
|
|
|
+ guard granted else {
|
|
|
|
|
+ showAccessDeniedAlert()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ do {
|
|
|
|
|
+ allContacts = try ContactsService.fetchContacts()
|
|
|
|
|
+ selectedIDs = Set(allContacts.map(\.identifier))
|
|
|
|
|
+ reloadList()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ showLoadFailedAlert()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func filteredContacts() -> [PrintableContact] {
|
|
|
|
|
+ let query = searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
+ guard !query.isEmpty else { return allContacts }
|
|
|
|
|
+ return allContacts.filter { $0.searchableText.contains(query) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func reloadList() {
|
|
|
|
|
+ rowViews.forEach { $0.removeFromSuperview() }
|
|
|
|
|
+ rowViews.removeAll()
|
|
|
|
|
+
|
|
|
|
|
+ let contacts = filteredContacts()
|
|
|
|
|
+ emptyLabel.isHidden = !contacts.isEmpty
|
|
|
|
|
+
|
|
|
|
|
+ for contact in contacts {
|
|
|
|
|
+ let row = ContactRowView(
|
|
|
|
|
+ name: contact.displayName,
|
|
|
|
|
+ isSelected: selectedIDs.contains(contact.identifier),
|
|
|
|
|
+ rowHeight: contactRowHeight
|
|
|
|
|
+ )
|
|
|
|
|
+ row.onToggle = { [weak self] in
|
|
|
|
|
+ self?.toggleSelection(for: contact.identifier)
|
|
|
|
|
+ }
|
|
|
|
|
+ rowViews.append(row)
|
|
|
|
|
+ listStack.addArrangedSubview(row)
|
|
|
|
|
+ row.leadingAnchor.constraint(equalTo: listStack.leadingAnchor).isActive = true
|
|
|
|
|
+ row.trailingAnchor.constraint(equalTo: listStack.trailingAnchor).isActive = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func toggleSelection(for identifier: String) {
|
|
|
|
|
+ if selectedIDs.contains(identifier) {
|
|
|
|
|
+ selectedIDs.remove(identifier)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ selectedIDs.insert(identifier)
|
|
|
|
|
+ }
|
|
|
|
|
+ reloadList()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func printContacts() {
|
|
|
|
|
+ let contacts = filteredContacts().filter { selectedIDs.contains($0.identifier) }
|
|
|
|
|
+ PrintService.printContacts(contacts, from: window)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func showAccessDeniedAlert() {
|
|
|
|
|
+ let alert = NSAlert()
|
|
|
|
|
+ alert.messageText = "Contacts Access Required"
|
|
|
|
|
+ alert.informativeText = "Allow Smart Printer to access your contacts in System Settings to print them."
|
|
|
|
|
+ alert.alertStyle = .warning
|
|
|
|
|
+ alert.addButton(withTitle: "Open Settings")
|
|
|
|
|
+ alert.addButton(withTitle: "Cancel")
|
|
|
|
|
+ if alert.runModal() == .alertFirstButtonReturn {
|
|
|
|
|
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts") {
|
|
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func showLoadFailedAlert() {
|
|
|
|
|
+ let alert = NSAlert()
|
|
|
|
|
+ alert.messageText = "Could Not Load Contacts"
|
|
|
|
|
+ alert.informativeText = "Your contacts could not be read. Try again after granting access."
|
|
|
|
|
+ alert.alertStyle = .warning
|
|
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
|
|
+ alert.runModal()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Contact Row
|
|
|
|
|
+
|
|
|
|
|
+private final class ContactRowView: NSControl, AppearanceRefreshable {
|
|
|
|
|
+ var onToggle: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let nameLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private var isSelected = false
|
|
|
|
|
+ private let rowHeight: CGFloat
|
|
|
|
|
+
|
|
|
|
|
+ init(name: String, isSelected: Bool, rowHeight: CGFloat) {
|
|
|
|
|
+ self.isSelected = isSelected
|
|
|
|
|
+ self.rowHeight = rowHeight
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = rowHeight / 2
|
|
|
|
|
+
|
|
|
|
|
+ nameLabel.stringValue = name
|
|
|
|
|
+ nameLabel.font = AppTheme.semiboldFont(size: 16)
|
|
|
|
|
+ nameLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(nameLabel)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: rowHeight),
|
|
|
|
|
+ nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
|
|
|
|
|
+ nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
|
|
|
|
+ nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ layer?.borderWidth = 0
|
|
|
|
|
+ if isSelected {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.homeActiveBackground.cgColor
|
|
|
|
|
+ nameLabel.textColor = AppTheme.homeActiveForeground
|
|
|
|
|
+ } else {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.14).cgColor
|
|
|
|
|
+ nameLabel.textColor = AppTheme.textPrimary
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
|
|
+ onToggle?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Search Delegate
|
|
|
|
|
+
|
|
|
|
|
+extension PrintContactsOverlayView: NSTextFieldDelegate {
|
|
|
|
|
+ func controlTextDidChange(_ obj: Notification) {
|
|
|
|
|
+ reloadList()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Toolbar Button
|
|
|
|
|
+
|
|
|
|
|
+private final class PrintContactsToolbarButton: NSControl, AppearanceRefreshable {
|
|
|
|
|
+ var onClick: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let symbolName: String
|
|
|
|
|
+ private let iconView = NSImageView()
|
|
|
|
|
+
|
|
|
|
|
+ init(symbolName: String, accessibilityLabel: String) {
|
|
|
|
|
+ self.symbolName = symbolName
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ toolTip = accessibilityLabel
|
|
|
|
|
+
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 12
|
|
|
|
|
+
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
|
|
+ iconView.image = image.withSymbolConfiguration(config)
|
|
|
|
|
+ }
|
|
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(iconView)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
+ iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
|
|
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
|
|
+ layer?.borderWidth = 1.5
|
|
|
|
|
+ iconView.contentTintColor = AppTheme.textPrimary
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
|
|
+ onClick?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Print Button
|
|
|
|
|
+
|
|
|
|
|
+private final class PrintContactsPrintButton: NSControl, AppearanceRefreshable {
|
|
|
|
|
+ var onClick: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "Print Contacts")
|
|
|
|
|
+ private let iconView = NSImageView()
|
|
|
|
|
+ private var hoverTracker: HoverTracker?
|
|
|
|
|
+ private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ init() {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 22
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.font = AppTheme.semiboldFont(size: 16)
|
|
|
|
|
+ titleLabel.textColor = .white
|
|
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
|
|
|
|
+ iconView.image = image.withSymbolConfiguration(config)
|
|
|
|
|
+ }
|
|
|
|
|
+ iconView.contentTintColor = .white
|
|
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(titleLabel)
|
|
|
|
|
+ addSubview(iconView)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 52),
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
|
|
|
|
|
+ titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
|
|
|
|
|
+ iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
|
|
|
|
|
+ iconView.widthAnchor.constraint(equalToConstant: 20),
|
|
|
|
|
+ iconView.heightAnchor.constraint(equalToConstant: 20),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ hoverTracker = HoverTracker(view: self) { [weak self] hovering in
|
|
|
|
|
+ self?.setHovered(hovering)
|
|
|
|
|
+ }
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ let color = isHovered
|
|
|
|
|
+ ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
|
|
|
|
|
+ : AppTheme.blue
|
|
|
|
|
+ layer?.backgroundColor = color.cgColor
|
|
|
|
|
+ titleLabel.textColor = .white
|
|
|
|
|
+ iconView.contentTintColor = .white
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setHovered(_ hovering: Bool) {
|
|
|
|
|
+ isHovered = hovering
|
|
|
|
|
+ animateHover {
|
|
|
|
|
+ let color = hovering
|
|
|
|
|
+ ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
|
|
|
|
|
+ : AppTheme.blue
|
|
|
|
|
+ layer?.backgroundColor = color.cgColor
|
|
|
|
|
+ layer?.transform = hovering
|
|
|
|
|
+ ? CATransform3DMakeScale(1.03, 1.03, 1)
|
|
|
|
|
+ : CATransform3DIdentity
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
|
|
+ onClick?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|