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
6 changed files with 130 additions and 7 deletions
Showing only changes of commit 5aa31153e3 - Show all commits
@@ -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,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