Files
honeyDueKMP/iosApp/HoneyDueQLPreview/PreviewViewController.swift
T
Trey T 5aa31153e3
Android UI Tests / ui-tests (pull_request) Has been cancelled
fix: share-residence import preview polish (closes gitea#7)
Issue #7 called out four problems with the QuickLook preview iOS
recipients see when they open a `.honeydue` invite (e.g. via AirDrop or
Save to Files). All four fixed here.

1. Filename: keep spaces and apostrophes
   `HoneyDueShareCodec.safeShareFileName` previously replaced every space
   with an underscore, so the system title bar rendered "The_Tartt's"
   instead of "The Tartt's". Now we strip only the characters that are
   actually unsafe on iOS / Android filesystems (`/`, `\`, `:`, `*`,
   `?`, `"`, `<`, `>`, `|`, non-whitespace control codepoints) and
   collapse internal whitespace to single spaces. Locked in with six
   new commonTest cases.

2. Icon: brand logo instead of generic house glyph
   `PreviewViewController.updateUIForResidence` was using
   `UIImage(systemName: "house.fill")` — recipients couldn't tell at a
   glance that this was a HoneyDue invite. The honeyDue app logo
   (Assets.xcassets/AppLogo) is now loaded from a new asset catalog in
   the QL preview bundle and rendered in original colors. SF Symbol
   fallback retained for any asset-load failure.

3. Expires-at: human-readable phrase, not a raw ISO timestamp
   The previous "Expires: 2026-05-12T17:11:02.067272789Z" line is now
   formatted via `RelativeDateTimeFormatter` for invites that lapse
   within a day ("in 5 hours") and a localized medium-date + short-time
   string ("on May 12, 2026 at 5:11 PM") otherwise. Already-expired
   links render "expired 2 hours ago". Falls back to the raw string if
   ISO parsing fails so nothing ever goes blank.

4. Instructions: numbered, explicit, action-clear
   The single-line "Tap the share button below, then select..." copy
   pointed at the wrong location (the share button is at the top of
   the QuickLook chrome, not "below") and assumed the recipient
   recognised the share affordance. Replaced with a three-step list.

Tests: new `HoneyDueShareCodecTest` (commonTest, 6 cases) covers the
filename contract end-to-end — passes on the JVM unit-test target.
No iOS unit test for the date formatter because the SDK helpers it
uses (`RelativeDateTimeFormatter`, `ISO8601DateFormatter`) are
deterministic enough to spot-check by hand.

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

428 lines
16 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"
// 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"
}
}