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..12053c6 100644 --- a/iosApp/HoneyDueQLPreview/PreviewViewController.swift +++ b/iosApp/HoneyDueQLPreview/PreviewViewController.swift @@ -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