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" // Branch the copy on whether the share link has already lapsed. // Active invites get the standard "How to join" numbered steps; // expired invites get a clear dead-end message asking the // recipient to ping the sender for a new link — no point // showing share-sheet directions for a link the server will // reject. let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt) if let expiredAgo { instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy) } else { instructionLabel.attributedText = Self.makeResidenceInstructions() } // 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 { if let expiredAgo { // "Expired 1 hour ago" — capitalised past-tense; no // "Expires " prefix because the share link no longer // expires, it has already done so (gitea#7 review). addDetailRow(icon: "clock", text: "Expired \(expiredAgo)") } else { let formatted = Self.formatActiveExpiry(expiresAt) addDetailRow(icon: "clock", text: "Expires \(formatted)") } } } // MARK: - Formatting helpers /// Render an *active* (not-yet-expired) share-link expiry as a /// human-readable phrase. Within a day uses /// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes"); /// further out switches to absolute date + time so users planning /// ahead see exactly when the invite lapses. Falls back to the raw /// ISO string if parsing fails so the row never goes blank. /// /// Callers must check [expiredRelativePhraseOrNil] first — this /// function assumes a future expiry and produces wording that only /// makes sense in that case. static func formatActiveExpiry(_ isoString: String) -> String { guard let date = parseIsoDate(isoString) else { return isoString } let now = Date() let elapsed = date.timeIntervalSince(now) if elapsed < 24 * 60 * 60 { return relativeFormatter.localizedString(for: date, relativeTo: now) } return "on \(absoluteFormatter.string(from: date))" } /// If the share link has already lapsed, return the relative /// "X ago" phrase. `nil` means active (or unparseable) — callers /// should fall back to [formatActiveExpiry] for those cases. The /// split lets `updateUIForResidence` branch the entire UI block /// (row text + instruction card) on the same signal (gitea#7 /// review: an expired link should send the recipient back to the /// sender for a new invite, not show share-sheet directions for a /// link the server will reject). static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? { guard let isoString, let date = parseIsoDate(isoString) else { return nil } let now = Date() if date.timeIntervalSince(now) > 0 { return nil } return relativeFormatter.localizedString(for: date, relativeTo: now) } 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 }() /// Builds the "How to join" instruction copy as an attributed /// string with the iOS share-icon glyph (square + up-arrow) inlined /// next to "Tap [icon]". The glyph is the universal share symbol /// across iOS, so the recipient finds the right control whether /// it's at the top, bottom, or behind a More menu — instead of us /// claiming a fixed position the chrome can move (gitea#7 review /// feedback). private static func makeResidenceInstructions() -> NSAttributedString { let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium) let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 2 paragraph.alignment = .left let result = NSMutableAttributedString() func appendText(_ s: String) { result.append(NSAttributedString( string: s, attributes: [ .font: bodyFont, .foregroundColor: tint, .paragraphStyle: paragraph, ] )) } appendText("How to join:\n1. Tap ") let shareImage = UIImage( systemName: "square.and.arrow.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) )?.withTintColor(tint, renderingMode: .alwaysOriginal) if let shareImage { let attachment = NSTextAttachment() attachment.image = shareImage // Align the glyph baseline with the surrounding text by // nudging the bounds down a few points; the SF Symbol's // natural bounds sit a hair above the cap height. attachment.bounds = CGRect( x: 0, y: -3, width: shareImage.size.width, height: shareImage.size.height ) result.append(NSAttributedString(attachment: attachment)) } appendText("\n2. Choose \"honeyDue\" from the share sheet") appendText("\n3. Sign in if prompted — the app finishes the rest") return result } /// Expired-state copy for the instruction card. Tells the recipient /// the share link is no longer valid and to ping the sender (by /// email if we know it) for a new one — replaces the active "How to /// join" steps since the server will reject the bundled code /// anyway. private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString { // Slightly warmer tint than the active instruction copy — the // app's `appError` red would feel alarmist for "just ask again", // and the secondary-label gray reads as muted/disabled which is // accurate to the link's actual state. let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium) let tint = UIColor.secondaryLabel let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold) let titleTint = UIColor.label let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 2 paragraph.alignment = .left let result = NSMutableAttributedString() result.append(NSAttributedString( string: "This invite has expired.\n", attributes: [ .font: titleFont, .foregroundColor: titleTint, .paragraphStyle: paragraph, ] )) let body = if let s = sharedBy, !s.isEmpty { "Ask \(s) to send a new link." } else { "Ask the sender to share a new link." } result.append(NSAttributedString( string: body, attributes: [ .font: bodyFont, .foregroundColor: tint, .paragraphStyle: paragraph, ] )) return result } } // 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" } }