fix: share-residence import preview polish (closes #7) #9
@@ -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.
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