import UIKit import QuickLook class PreviewViewController: UIViewController, QLPreviewingController { // MARK: - Types /// Represents the type of .honeydue package private enum PackageType { case contractor case residence } // MARK: - UI Elements private let containerView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false return view }() private let iconImageView: UIImageView = { let imageView = UIImageView() imageView.translatesAutoresizingMaskIntoConstraints = false imageView.contentMode = .scaleAspectFit imageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) // App primary color let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light) imageView.image = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config) return imageView }() private let titleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.font = .systemFont(ofSize: 24, weight: .bold) label.textColor = .label label.textAlignment = .center label.numberOfLines = 2 return label }() private let subtitleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.font = .systemFont(ofSize: 15, weight: .medium) label.textColor = .secondaryLabel label.textAlignment = .center label.text = "honeyDue Contractor File" return label }() private let dividerView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .separator return view }() private let detailsStackView: UIStackView = { let stack = UIStackView() stack.translatesAutoresizingMaskIntoConstraints = false stack.axis = .vertical stack.spacing = 12 stack.alignment = .leading return stack }() private let instructionCard: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1) view.layer.cornerRadius = 12 return view }() private let instructionLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.font = .systemFont(ofSize: 15, weight: .medium) label.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) label.textAlignment = .center label.numberOfLines = 0 label.text = "Tap the share button below, then select \"honeyDue\" to import this contractor." return label }() private let arrowImageView: UIImageView = { let imageView = UIImageView() imageView.translatesAutoresizingMaskIntoConstraints = false imageView.contentMode = .scaleAspectFit imageView.tintColor = .secondaryLabel let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium) imageView.image = UIImage(systemName: "arrow.down", withConfiguration: config) return imageView }() // MARK: - Data private var contractorData: ContractorPreviewData? private var residenceData: ResidencePreviewData? private var currentPackageType: PackageType = .contractor // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() print("honeyDueQLPreview: viewDidLoad called") setupUI() } // MARK: - Setup private func setupUI() { view.backgroundColor = .systemBackground view.addSubview(containerView) containerView.addSubview(iconImageView) containerView.addSubview(titleLabel) containerView.addSubview(subtitleLabel) containerView.addSubview(dividerView) containerView.addSubview(detailsStackView) containerView.addSubview(instructionCard) instructionCard.addSubview(instructionLabel) containerView.addSubview(arrowImageView) NSLayoutConstraint.activate([ containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor), containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40), containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32), containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32), containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340), iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor), iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), iconImageView.widthAnchor.constraint(equalToConstant: 80), iconImageView.heightAnchor.constraint(equalToConstant: 80), titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16), titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20), dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), dividerView.heightAnchor.constraint(equalToConstant: 1), detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20), detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24), instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16), instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16), instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16), instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16), arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16), arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), ]) } private func addDetailRow(icon: String, text: String) { let rowStack = UIStackView() rowStack.axis = .horizontal rowStack.spacing = 12 rowStack.alignment = .center let iconView = UIImageView() iconView.translatesAutoresizingMaskIntoConstraints = false let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium) iconView.image = UIImage(systemName: icon, withConfiguration: config) iconView.tintColor = .secondaryLabel iconView.widthAnchor.constraint(equalToConstant: 24).isActive = true iconView.heightAnchor.constraint(equalToConstant: 24).isActive = true let textLabel = UILabel() textLabel.font = .systemFont(ofSize: 15) textLabel.textColor = .label textLabel.text = text textLabel.numberOfLines = 1 rowStack.addArrangedSubview(iconView) rowStack.addArrangedSubview(textLabel) detailsStackView.addArrangedSubview(rowStack) } // MARK: - QLPreviewingController func preparePreviewOfFile(at url: URL) async throws { print("honeyDueQLPreview: preparePreviewOfFile called with URL: \(url)") // Parse the .honeydue file — single Codable pass to detect type, then decode let data = try Data(contentsOf: url) let decoder = JSONDecoder() let envelope = try? decoder.decode(PackageTypeEnvelope.self, from: data) if envelope?.type == "residence" { currentPackageType = .residence let residence = try decoder.decode(ResidencePreviewData.self, from: data) self.residenceData = residence print("honeyDueQLPreview: Parsed residence: \(residence.residenceName)") await MainActor.run { self.updateUIForResidence(with: residence) } } else { currentPackageType = .contractor let contractor = try decoder.decode(ContractorPreviewData.self, from: data) self.contractorData = contractor print("honeyDueQLPreview: Parsed contractor: \(contractor.name)") await MainActor.run { self.updateUIForContractor(with: contractor) } } } private func updateUIForContractor(with contractor: ContractorPreviewData) { // Update icon let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light) iconImageView.image = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config) titleLabel.text = contractor.name subtitleLabel.text = "honeyDue Contractor File" instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to import this contractor." // Clear existing details detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } // Add details if let company = contractor.company, !company.isEmpty { addDetailRow(icon: "building.2", text: company) } if let phone = contractor.phone, !phone.isEmpty { addDetailRow(icon: "phone", text: phone) } if let email = contractor.email, !email.isEmpty { addDetailRow(icon: "envelope", text: email) } if !contractor.specialtyNames.isEmpty { let specialties = contractor.specialtyNames.joined(separator: ", ") addDetailRow(icon: "wrench.and.screwdriver", text: specialties) } if let exportedBy = contractor.exportedBy, !exportedBy.isEmpty { addDetailRow(icon: "person", text: "Shared by \(exportedBy)") } } private func updateUIForResidence(with residence: ResidencePreviewData) { // Brand icon. Prefer the bundled honeyDue logo so the preview // reads as a HoneyDue invite at a glance; fall back to a tinted // SF Symbol for accessibility / asset-load failures. if let logo = UIImage(named: "AppLogo") { iconImageView.image = logo.withRenderingMode(.alwaysOriginal) iconImageView.contentMode = .scaleAspectFit iconImageView.layer.cornerRadius = 16 iconImageView.layer.masksToBounds = true } else { let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light) iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config) } titleLabel.text = residence.residenceName subtitleLabel.text = "honeyDue Residence Invite" // Numbered steps so non-technical recipients know exactly what to // do. Replaces the prior "Tap the share button below…" which // implied an in-app button and didn't say where the share // control lives (top-right of the QuickLook bar). instructionLabel.text = "How to join:\n" + "1. Tap the Share button (top right of this preview)\n" + "2. Choose \"honeyDue\" from the share sheet\n" + "3. Sign in if prompted — the app finishes the rest" // Clear existing details detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } // Add details if let sharedBy = residence.sharedBy, !sharedBy.isEmpty { addDetailRow(icon: "person", text: "Shared by \(sharedBy)") } if let expiresAt = residence.expiresAt, !expiresAt.isEmpty { let formatted = Self.formatExpiresAt(expiresAt) addDetailRow(icon: "clock", text: "Expires \(formatted)") } } // MARK: - Formatting helpers /// Render the share-link expiry as a human-readable phrase. /// /// The wire format from the API is ISO-8601 with optional fractional /// seconds (`2026-05-12T17:11:02.067272789Z`); previous behaviour /// echoed that string verbatim (gitea#7). When parsing succeeds we /// produce a relative phrase ("today at 5:11 PM", "in 23 hours", /// "May 12, 2026 at 5:11 PM"); if parsing fails we return the /// original string unchanged so the recipient still has *something*. static func formatExpiresAt(_ isoString: String) -> String { guard let date = parseIsoDate(isoString) else { return isoString } let now = Date() let elapsed = date.timeIntervalSince(now) // If the link is already expired, say so plainly. if elapsed <= 0 { return "expired \(relativeFormatter.localizedString(for: date, relativeTo: now))" } // Within a day → relative ("in 5 hours" / "in 12 minutes"). if elapsed < 24 * 60 * 60 { return relativeFormatter.localizedString(for: date, relativeTo: now) } // Otherwise an absolute date + time so users planning ahead see // exactly when the invite lapses. return "on \(absoluteFormatter.string(from: date))" } private static func parseIsoDate(_ raw: String) -> Date? { if let d = isoFormatterWithFraction.date(from: raw) { return d } if let d = isoFormatterNoFraction.date(from: raw) { return d } return nil } private static let isoFormatterWithFraction: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() private static let isoFormatterNoFraction: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() private static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.unitsStyle = .full return f }() private static let absoluteFormatter: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium f.timeStyle = .short return f }() } // MARK: - Type Discriminator /// Lightweight struct to detect the package type without a full parse private struct PackageTypeEnvelope: Decodable { let type: String? } // MARK: - Data Model struct ContractorPreviewData: Codable { let version: Int let name: String let company: String? let phone: String? let email: String? let website: String? let notes: String? let streetAddress: String? let city: String? let stateProvince: String? let postalCode: String? let specialtyNames: [String] let rating: Double? let isFavorite: Bool let exportedAt: String? let exportedBy: String? enum CodingKeys: String, CodingKey { case version, name, company, phone, email, website, notes case streetAddress = "street_address" case city case stateProvince = "state_province" case postalCode = "postal_code" case specialtyNames = "specialty_names" case rating case isFavorite = "is_favorite" case exportedAt = "exported_at" case exportedBy = "exported_by" } } struct ResidencePreviewData: Codable { let version: Int let type: String let shareCode: String let residenceName: String let sharedBy: String? let expiresAt: String? let exportedAt: String? let exportedBy: String? enum CodingKeys: String, CodingKey { case version, type case shareCode = "share_code" case residenceName = "residence_name" case sharedBy = "shared_by" case expiresAt = "expires_at" case exportedAt = "exported_at" case exportedBy = "exported_by" } }