fix: share-residence import preview polish (closes gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Android UI Tests / ui-tests (pull_request) Has been cancelled
Issue #7 called out four problems with the QuickLook preview iOS recipients see when they open a `.honeydue` invite (e.g. via AirDrop or Save to Files). All four fixed here. 1. Filename: keep spaces and apostrophes `HoneyDueShareCodec.safeShareFileName` previously replaced every space with an underscore, so the system title bar rendered "The_Tartt's" instead of "The Tartt's". Now we strip only the characters that are actually unsafe on iOS / Android filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, non-whitespace control codepoints) and collapse internal whitespace to single spaces. Locked in with six new commonTest cases. 2. Icon: brand logo instead of generic house glyph `PreviewViewController.updateUIForResidence` was using `UIImage(systemName: "house.fill")` — recipients couldn't tell at a glance that this was a HoneyDue invite. The honeyDue app logo (Assets.xcassets/AppLogo) is now loaded from a new asset catalog in the QL preview bundle and rendered in original colors. SF Symbol fallback retained for any asset-load failure. 3. Expires-at: human-readable phrase, not a raw ISO timestamp The previous "Expires: 2026-05-12T17:11:02.067272789Z" line is now formatted via `RelativeDateTimeFormatter` for invites that lapse within a day ("in 5 hours") and a localized medium-date + short-time string ("on May 12, 2026 at 5:11 PM") otherwise. Already-expired links render "expired 2 hours ago". Falls back to the raw string if ISO parsing fails so nothing ever goes blank. 4. Instructions: numbered, explicit, action-clear The single-line "Tap the share button below, then select..." copy pointed at the wrong location (the share button is at the top of the QuickLook chrome, not "below") and assumed the recipient recognised the share affordance. Replaced with a three-step list. Tests: new `HoneyDueShareCodecTest` (commonTest, 6 cases) covers the filename contract end-to-end — passes on the JVM unit-test target. No iOS unit test for the date formatter because the SDK helpers it uses (`RelativeDateTimeFormatter`, `ISO8601DateFormatter`) are deterministic enough to spot-check by hand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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,29 @@ 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."
|
||||
// Numbered steps so non-technical recipients know exactly what to
|
||||
// do. Replaces the prior "Tap the share button below…" which
|
||||
// implied an in-app button and didn't say where the share
|
||||
// control lives (top-right of the QuickLook bar).
|
||||
instructionLabel.text = "How to join:\n"
|
||||
+ "1. Tap the Share button (top right of this preview)\n"
|
||||
+ "2. Choose \"honeyDue\" from the share sheet\n"
|
||||
+ "3. Sign in if prompted — the app finishes the rest"
|
||||
|
||||
// Clear existing details
|
||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
@@ -280,9 +296,72 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
}
|
||||
|
||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
|
||||
let formatted = Self.formatExpiresAt(expiresAt)
|
||||
addDetailRow(icon: "clock", text: "Expires \(formatted)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting helpers
|
||||
|
||||
/// Render the share-link expiry as a human-readable phrase.
|
||||
///
|
||||
/// The wire format from the API is ISO-8601 with optional fractional
|
||||
/// seconds (`2026-05-12T17:11:02.067272789Z`); previous behaviour
|
||||
/// echoed that string verbatim (gitea#7). When parsing succeeds we
|
||||
/// produce a relative phrase ("today at 5:11 PM", "in 23 hours",
|
||||
/// "May 12, 2026 at 5:11 PM"); if parsing fails we return the
|
||||
/// original string unchanged so the recipient still has *something*.
|
||||
static func formatExpiresAt(_ isoString: String) -> String {
|
||||
guard let date = parseIsoDate(isoString) else { return isoString }
|
||||
|
||||
let now = Date()
|
||||
let elapsed = date.timeIntervalSince(now)
|
||||
|
||||
// If the link is already expired, say so plainly.
|
||||
if elapsed <= 0 {
|
||||
return "expired \(relativeFormatter.localizedString(for: date, relativeTo: now))"
|
||||
}
|
||||
|
||||
// Within a day → relative ("in 5 hours" / "in 12 minutes").
|
||||
if elapsed < 24 * 60 * 60 {
|
||||
return relativeFormatter.localizedString(for: date, relativeTo: now)
|
||||
}
|
||||
|
||||
// Otherwise an absolute date + time so users planning ahead see
|
||||
// exactly when the invite lapses.
|
||||
return "on \(absoluteFormatter.string(from: date))"
|
||||
}
|
||||
|
||||
private static func parseIsoDate(_ raw: String) -> Date? {
|
||||
if let d = isoFormatterWithFraction.date(from: raw) { return d }
|
||||
if let d = isoFormatterNoFraction.date(from: raw) { return d }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let isoFormatterWithFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .full
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absoluteFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Type Discriminator
|
||||
|
||||
Reference in New Issue
Block a user