83c3428b05
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.
Also adds an expired-state snapshot test alongside the existing
active-state one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
544 lines
21 KiB
Swift
544 lines
21 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)
|
|
} 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"
|
|
}
|
|
}
|