5aa31153e3
Android UI Tests / ui-tests (pull_request) Has been cancelled
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>
428 lines
16 KiB
Swift
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"
|
|
}
|
|
}
|