Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23f4d70ac1 |
@@ -59,29 +59,12 @@ object HoneyDueShareCodec {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a filesystem-safe package filename with `.honeydue` extension.
|
* 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 {
|
fun safeShareFileName(displayName: String): String {
|
||||||
val safeName = displayName
|
val safeName = displayName
|
||||||
// Keep whitespace through the filter so adjacent space+tab
|
.replace(" ", "_")
|
||||||
// sequences survive to the regex-collapse step below. Drop
|
.replace("/", "-")
|
||||||
// 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)
|
.take(50)
|
||||||
.ifBlank { "honeyDue" }
|
|
||||||
return "$safeName.honeydue"
|
return "$safeName.honeydue"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "AppLogo@2x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -263,40 +263,13 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateUIForResidence(with residence: ResidencePreviewData) {
|
private func updateUIForResidence(with residence: ResidencePreviewData) {
|
||||||
// Brand icon. Prefer the bundled honeyDue logo so the preview
|
// Update icon
|
||||||
// reads as a HoneyDue invite at a glance; fall back to a tinted
|
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||||
// SF Symbol for accessibility / asset-load failures.
|
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||||
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
|
titleLabel.text = residence.residenceName
|
||||||
subtitleLabel.text = "honeyDue Residence Invite"
|
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
|
// Clear existing details
|
||||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
@@ -307,183 +280,9 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||||
if let expiredAgo {
|
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
|
||||||
// "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
|
// MARK: - Type Discriminator
|
||||||
|
|||||||
@@ -1,437 +0,0 @@
|
|||||||
//
|
|
||||||
// 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.
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 149 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB |
@@ -120,7 +120,6 @@ struct CompleteTaskView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.keyboardDismissToolbar()
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
|
||||||
} label: {
|
} label: {
|
||||||
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
|
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
|
||||||
@@ -142,7 +141,6 @@ struct CompleteTaskView: View {
|
|||||||
TextEditor(text: $notes)
|
TextEditor(text: $notes)
|
||||||
.frame(minHeight: 100)
|
.frame(minHeight: 100)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.keyboardDismissToolbar()
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -289,6 +287,12 @@ struct CompleteTaskView: View {
|
|||||||
.background(WarmGradientBackground())
|
.background(WarmGradientBackground())
|
||||||
.navigationTitle(L10n.Tasks.completeTask)
|
.navigationTitle(L10n.Tasks.completeTask)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
// ONE keyboard "Done" toolbar at the form root — per-field
|
||||||
|
// `.keyboardDismissToolbar()` modifiers each install a
|
||||||
|
// separate `ToolbarItemGroup(placement: .keyboard)`, and
|
||||||
|
// SwiftUI stacks them on the responder chain so any focused
|
||||||
|
// field renders multiple Done buttons side-by-side (issue #5).
|
||||||
|
.keyboardDismissToolbar()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(L10n.Common.cancel) {
|
Button(L10n.Common.cancel) {
|
||||||
|
|||||||
Reference in New Issue
Block a user