diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/HoneyDueShareCodec.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/HoneyDueShareCodec.kt index 3acc20e..6f53c43 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/HoneyDueShareCodec.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/HoneyDueShareCodec.kt @@ -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('/', '\\', ':', '*', '?', '"', '<', '>', '|') } diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/models/HoneyDueShareCodecTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/models/HoneyDueShareCodecTest.kt new file mode 100644 index 0000000..69a59f4 Binary files /dev/null and b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/models/HoneyDueShareCodecTest.kt differ diff --git a/iosApp/HoneyDueQLPreview/Assets.xcassets/AppLogo.imageset/AppLogo@2x.png b/iosApp/HoneyDueQLPreview/Assets.xcassets/AppLogo.imageset/AppLogo@2x.png new file mode 100644 index 0000000..02d229d Binary files /dev/null and b/iosApp/HoneyDueQLPreview/Assets.xcassets/AppLogo.imageset/AppLogo@2x.png differ diff --git a/iosApp/HoneyDueQLPreview/Assets.xcassets/AppLogo.imageset/Contents.json b/iosApp/HoneyDueQLPreview/Assets.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..3033e14 --- /dev/null +++ b/iosApp/HoneyDueQLPreview/Assets.xcassets/AppLogo.imageset/Contents.json @@ -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 + } +} diff --git a/iosApp/HoneyDueQLPreview/Assets.xcassets/Contents.json b/iosApp/HoneyDueQLPreview/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/HoneyDueQLPreview/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/HoneyDueQLPreview/PreviewViewController.swift b/iosApp/HoneyDueQLPreview/PreviewViewController.swift index 4bd5ebc..c657ecd 100644 --- a/iosApp/HoneyDueQLPreview/PreviewViewController.swift +++ b/iosApp/HoneyDueQLPreview/PreviewViewController.swift @@ -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 diff --git a/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift b/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift new file mode 100644 index 0000000..ca692ef --- /dev/null +++ b/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift @@ -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) + } +} diff --git a/iosApp/HoneyDueTests/Resources/AppLogo.png b/iosApp/HoneyDueTests/Resources/AppLogo.png new file mode 100644 index 0000000..02d229d Binary files /dev/null and b/iosApp/HoneyDueTests/Resources/AppLogo.png differ diff --git a/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_after_issue7_fix.issue7_residence_invite_preview_dark.png b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_after_issue7_fix.issue7_residence_invite_preview_dark.png new file mode 100644 index 0000000..cae69c4 Binary files /dev/null and b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_after_issue7_fix.issue7_residence_invite_preview_dark.png differ diff --git a/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_expired_state.issue7_residence_invite_preview_expired_dark.png b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_expired_state.issue7_residence_invite_preview_expired_dark.png new file mode 100644 index 0000000..27a3b84 Binary files /dev/null and b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_expired_state.issue7_residence_invite_preview_expired_dark.png differ