fix: share-residence import preview polish (closes #7) #9

Merged
admin merged 5 commits from fix/7-share-residence-import-polish into master 2026-05-11 16:17:16 -05:00
10 changed files with 689 additions and 7 deletions
@@ -59,12 +59,29 @@ object HoneyDueShareCodec {
/**
* Build a filesystem-safe package filename with `.honeydue` extension.
*
* Strips only the characters that are actually unsafe on iOS / Android
* filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, control
* chars). Spaces and apostrophes are kept intact so the recipient sees
* the original residence / contractor name in the iOS QuickLook title
* bar — gitea#7 called out the previous behaviour rendering
* "The_Tartt's" instead of "The Tartt's". Internal whitespace is
* collapsed to single spaces and trimmed; falls back to "honeyDue" if
* the input is blank after sanitising.
*/
fun safeShareFileName(displayName: String): String {
val safeName = displayName
.replace(" ", "_")
.replace("/", "-")
// Keep whitespace through the filter so adjacent space+tab
// sequences survive to the regex-collapse step below. Drop
// only non-whitespace control chars (NUL etc.) plus the
// explicit filesystem-unsafe set.
.filter { it !in UNSAFE_FILENAME_CHARS && (it.isWhitespace() || !it.isISOControl()) }
.replace(Regex("\\s+"), " ")
.trim()
.take(50)
.ifBlank { "honeyDue" }
return "$safeName.honeydue"
}
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppLogo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -263,13 +263,40 @@ class PreviewViewController: UIViewController, QLPreviewingController {
}
private func updateUIForResidence(with residence: ResidencePreviewData) {
// Update icon
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
// 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"
instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to join this residence."
// 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() }
@@ -280,9 +307,183 @@ class PreviewViewController: UIViewController, QLPreviewingController {
}
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
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
@@ -0,0 +1,437 @@
//
// Issue7PreviewScreenshotTest.swift
// HoneyDueTests
//
// Records a single PNG screenshot of the post-fix QL-preview layout
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
// attached to gitea issue #7 for the reviewer to see the new look
// without having to AirDrop a `.honeydue` file to a device.
//
// How it works:
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
// builds in production same colors, same fonts, same constraints,
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
// so it is reachable from this target's bundle).
// * Runs the same `formatExpiresAt` style (ISO parse relative phrase
// when within a day, absolute medium-date + short-time otherwise),
// using a fixed reference Date so the rendering is deterministic
// across runs / time zones.
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
// writes the PNG to
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
//
// The first run (no committed golden) records the PNG and the test
// reports "failed - No reference was found on disk. Automatically
// recorded snapshot:" that's the file we attach to the issue.
//
// Note on faithfulness: this snapshot is a programmatic reproduction
// of `PreviewViewController.updateUIForResidence`, not the QL
// extension instance itself, because the QL extension's bundle is a
// separate Xcode target from `HoneyDueTests` and can't be `@testable
// import`ed without project-file surgery. The reproduction uses the
// same UIKit primitives, colors, fonts, and asset, so the rendered
// output matches what users see when iOS opens a `.honeydue` invite.
//
@preconcurrency import SnapshotTesting
import UIKit
import XCTest
@MainActor
final class Issue7PreviewScreenshotTest: XCTestCase {
/// Force record mode for this test only we want the PNG written
/// regardless of whether a golden exists.
override func invokeTest() {
withSnapshotTesting(record: .all) {
super.invokeTest()
}
}
func test_residence_invite_preview_after_issue7_fix() {
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .active
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_dark"
)
}
func test_residence_invite_preview_expired_state() {
// Same residence + sender, but expiry already 1 hour in the
// past. Verifies the expired branch: the instruction card
// swaps to "ask the sender for a new link" and the detail row
// reads "Expired 1 hour ago" instead of the future-tense
// "Expires in " phrasing.
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_expired_dark"
)
}
}
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
private struct ResidencePreview {
let residenceName: String
let sharedBy: String?
let expiresAt: String?
/// Mirrors the data shown in the original gitea#7 screenshot the
/// post-fix version of the same payload.
static let fixtureForIssue7 = ResidencePreview(
residenceName: "The Tartt's",
sharedBy: "honey@hollie37.com",
expiresAt: "2026-05-12T17:11:02.067272789Z"
)
}
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
/// Renderer state for the screenshot fixture. Active = link still
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
/// ago. Both render with deterministic data so the recorded PNG is
/// stable across runs.
private enum PreviewRenderState {
case active
case expired(elapsedSecondsSinceExpiry: TimeInterval)
}
@MainActor
private final class MockPreviewViewController: UIViewController {
private let residence: ResidencePreview
private let state: PreviewRenderState
private let containerView = UIView()
private let iconImageView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let dividerView = UIView()
private let detailsStackView = UIStackView()
private let instructionCard = UIView()
private let instructionLabel = UILabel()
private let arrowImageView = UIImageView()
init(residence: ResidencePreview, state: PreviewRenderState) {
self.residence = residence
self.state = state
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("not used") }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
applyResidence()
}
private func setupUI() {
view.backgroundColor = .systemBackground
containerView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.contentMode = .scaleAspectFit
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textColor = .label
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.textAlignment = .center
dividerView.translatesAutoresizingMaskIntoConstraints = false
dividerView.backgroundColor = .separator
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
detailsStackView.axis = .vertical
detailsStackView.spacing = 12
detailsStackView.alignment = .leading
instructionCard.translatesAutoresizingMaskIntoConstraints = false
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
instructionCard.layer.cornerRadius = 12
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
instructionLabel.textAlignment = .left
instructionLabel.numberOfLines = 0
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
arrowImageView.contentMode = .scaleAspectFit
arrowImageView.tintColor = .secondaryLabel
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
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 applyResidence() {
// Mirror the post-fix branding choice: bundled honeyDue logo
// rendered in its actual colors. The image ships with the test
// target at `Resources/AppLogo.png`.
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
let logo = UIImage(contentsOfFile: path) {
iconImageView.image = logo
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"
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
}
switch state {
case .active:
instructionLabel.attributedText = makeResidenceInstructions()
arrowImageView.isHidden = false
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
}
case .expired(let elapsed):
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
// Arrow points at the Share button no point telling the
// user to tap it for a dead link. Matches PreviewViewController.
arrowImageView.isHidden = true
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
}
}
private func relativePhrase(secondsAgo: TimeInterval) -> String {
// Deterministic relative phrase we set "now" to be exactly
// `secondsAgo` after the (fake) expiry, so the formatter says
// "1 hour ago" instead of whatever the real clock would give.
let fakeNow = Date()
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
}
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
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: UIColor.label,
.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: UIColor.secondaryLabel,
.paragraphStyle: paragraph,
]
))
return result
}
private func addDetailRow(icon: String, text: String) {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
row.alignment = .center
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
iv.image = UIImage(systemName: icon, withConfiguration: config)
iv.tintColor = .secondaryLabel
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
let label = UILabel()
label.font = .systemFont(ofSize: 15)
label.textColor = .label
label.text = text
label.numberOfLines = 1
row.addArrangedSubview(iv)
row.addArrangedSubview(label)
detailsStackView.addArrangedSubview(row)
}
/// Mirrors `PreviewViewController.makeResidenceInstructions()` see
/// the rationale comment there. Inlined here because the QL
/// extension target can't be `@testable import`ed without
/// project-file surgery.
private 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
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
}
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
// "now" so the rendering is identical regardless of when the test
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
// instead see the active/expired switch in `applyResidence`.
private func formatActiveExpiry(_ raw: String) -> String {
let isoWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
let isoNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
guard let date = isoWithFraction.date(from: raw)
?? isoNoFraction.date(from: raw) else {
return raw
}
// Deterministic "now": 23 hours before the fixture's expiry, so
// the relative formatter always produces "in 23 hours".
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: date, relativeTo: fakeNow)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB