Files
honeyDueKMP/iosApp/HoneyDueQLPreview/PreviewViewController.swift
Trey T 0b6f26da99
Android UI Tests / ui-tests (pull_request) Has been cancelled
fix(qlpreview): hide share-arrow in expired state (gitea#7 review)
The down-chevron above the system Share button is a "tap here"
cue for the active flow. In the expired state there's nothing
worth sharing (the bundled code will be rejected on import) so
the arrow is misleading; hide it whenever we render the
"This invite has expired" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:57 -05:00

550 lines
22 KiB
Swift

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)
// The down-chevron points at the Share button as a visual
// cue to tap it; in the expired state there's nothing
// useful to share (the server will reject the bundled
// code) so the arrow becomes misleading. Hide it.
arrowImageView.isHidden = true
} else {
instructionLabel.attributedText = Self.makeResidenceInstructions()
arrowImageView.isHidden = false
}
// 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"
}
}