Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-11 12:38:17 -06:00
parent e6f6684c88
commit 6c0fa315b2
3 changed files with 152 additions and 46 deletions

View File

@ -98,7 +98,7 @@ enum CardHeaderLayout: String, CaseIterable, Identifiable, Hashable, Sendable {
case .coverOnly: case .coverOnly:
return "photo.fill" return "photo.fill"
case .profileWithLogoBadge: case .profileWithLogoBadge:
return "person.crop.square.badge.checkmark" return "person.crop.square"
case .logoWithAvatar: case .logoWithAvatar:
return "building.2.fill" return "building.2.fill"
case .coverWithAvatar: case .coverWithAvatar:

View File

@ -205,10 +205,6 @@
}, },
"Contact" : { "Contact" : {
},
"Contact fields" : {
"comment" : "A label displayed above a list of a user's contact fields.",
"isCommentAutoGenerated" : true
}, },
"Could not load card: %@" : { "Could not load card: %@" : {
"comment" : "A description of an error that might occur when fetching a shared card. The argument is text describing the underlying error.", "comment" : "A description of an error that might occur when fetching a shared card. The argument is text describing the underlying error.",
@ -949,10 +945,6 @@
}, },
"Username/Link" : { "Username/Link" : {
},
"View" : {
"comment" : "A label for a segmented control that lets a user choose how to display contact fields.",
"isCommentAutoGenerated" : true
}, },
"Wallet export is coming soon. We'll let you know as soon as it's ready." : { "Wallet export is coming soon. We'll let you know as soon as it's ready." : {
"localizations" : { "localizations" : {

View File

@ -350,44 +350,63 @@ private struct ContactFieldsListView: View {
] ]
} }
private var primaryContactFields: [ContactField] {
card.orderedContactFields.filter { ["phone", "email", "address"].contains($0.typeId) }
}
private var secondaryContactFields: [ContactField] {
card.orderedContactFields.filter { !["phone", "email", "address"].contains($0.typeId) }
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.xxxLarge) {
HStack { if !primaryContactFields.isEmpty {
Text("Contact fields") primaryContactSection(fields: primaryContactFields)
}
if !secondaryContactFields.isEmpty {
secondaryFieldsSection(title: primaryContactFields.isEmpty ? nil : "Profiles & Links", fields: secondaryContactFields)
}
}
}
private func primaryContactSection(fields: [ContactField]) -> some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
Text("Contact")
.typography(.caption)
.foregroundStyle(Color.Text.secondary)
.padding(.top, Design.Spacing.xxSmall)
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
ForEach(fields.indices, id: \.self) { index in
let field = fields[index]
PrimaryContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
openField(field)
}
if index < fields.count - 1 {
Divider()
.opacity(0.45)
}
}
}
}
}
@ViewBuilder
private func secondaryFieldsSection(title: String?, fields: [ContactField]) -> some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
if let title {
Text(title)
.typography(.caption) .typography(.caption)
.foregroundStyle(Color.Text.secondary) .foregroundStyle(Color.Text.secondary)
.padding(.top, Design.Spacing.xxSmall)
Spacer()
Picker("View", selection: Binding(
get: { viewMode },
set: { viewMode = $0 }
)) {
ForEach(ContactFieldsViewMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
.frame(width: 120)
} }
if viewMode == .list { // Always present profiles/links as grid. Keep the view mode enum/storage for future toggling.
VStack(alignment: .leading, spacing: Design.Spacing.small) { LazyVGrid(columns: columns, spacing: Design.Spacing.small) {
ForEach(card.orderedContactFields) { field in ForEach(fields) { field in
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) { ContactFieldRowView(field: field, themeColor: card.theme.primaryColor, style: .grid) {
openField(field) openField(field)
}
}
}
.padding(Design.Spacing.small)
.background(Color.AppBackground.base.opacity(0.45))
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
} else {
LazyVGrid(columns: columns, spacing: Design.Spacing.small) {
ForEach(card.orderedContactFields) { field in
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
openField(field)
}
} }
} }
} }
@ -401,6 +420,44 @@ private struct ContactFieldsListView: View {
} }
} }
private struct PrimaryContactFieldRowView: View {
let field: ContactField
let themeColor: Color
let action: () -> Void
private var labelText: String {
let trimmed = field.title.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? field.displayName : trimmed
}
var body: some View {
Button(action: action) {
HStack(alignment: .top, spacing: Design.Spacing.medium) {
field.iconImage()
.typography(.subheading)
.foregroundStyle(themeColor)
.frame(width: 28)
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
Text(field.displayValue)
.typography(.subheading)
.foregroundStyle(Color.Text.primary)
.multilineTextAlignment(.leading)
.lineLimit(2)
Text(labelText)
.typography(.caption)
.foregroundStyle(Color.Text.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, Design.Spacing.xxSmall)
}
.buttonStyle(.plain)
}
}
private enum ContactFieldsViewMode: String, CaseIterable, Identifiable { private enum ContactFieldsViewMode: String, CaseIterable, Identifiable {
case list case list
case grid case grid
@ -416,8 +473,14 @@ private enum ContactFieldsViewMode: String, CaseIterable, Identifiable {
} }
private struct ContactFieldRowView: View { private struct ContactFieldRowView: View {
enum Style {
case list
case grid
}
let field: ContactField let field: ContactField
let themeColor: Color let themeColor: Color
let style: Style
let action: () -> Void let action: () -> Void
private var valueText: String { private var valueText: String {
@ -425,7 +488,54 @@ private struct ContactFieldRowView: View {
} }
private var labelText: String { private var labelText: String {
field.title.isEmpty ? field.displayName : field.title let trimmedTitle = field.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else { return field.displayName }
return shouldUseProviderLabel(for: trimmedTitle) ? field.displayName : trimmedTitle
}
private func shouldUseProviderLabel(for title: String) -> Bool {
let normalized = title
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let noisyPrefixes = [
"connect with",
"follow me",
"add me",
"subscribe",
"view our work",
"ask me",
"pay via",
"schedule"
]
if noisyPrefixes.contains(where: normalized.hasPrefix) {
return true
}
// If this is a long, canned suggestion, prefer provider name.
if let suggestions = field.fieldType?.titleSuggestions {
let matchedSuggestion = suggestions
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.contains(normalized)
if matchedSuggestion {
let words = normalized.split(whereSeparator: \.isWhitespace)
if words.count >= 3 {
return true
}
}
}
return false
}
private var valueLineLimit: Int {
style == .grid ? 2 : 1
}
private var rowMinHeight: CGFloat? {
style == .grid ? 84 : nil
} }
var body: some View { var body: some View {
@ -445,17 +555,21 @@ private struct ContactFieldRowView: View {
.typography(.subheading) .typography(.subheading)
.foregroundStyle(Color.Text.primary) .foregroundStyle(Color.Text.primary)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(1) .lineLimit(valueLineLimit)
.minimumScaleFactor(0.72)
.allowsTightening(true)
Text(labelText) Text(labelText)
.typography(.caption) .typography(.captionEmphasis)
.italic()
.foregroundStyle(Color.Text.secondary) .foregroundStyle(Color.Text.secondary)
.lineLimit(1) .lineLimit(style == .grid ? 2 : 1)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.medium) .padding(.vertical, Design.Spacing.medium)
.frame(maxWidth: .infinity, minHeight: rowMinHeight, alignment: .center)
.background(Color.AppBackground.base.opacity(0.82)) .background(Color.AppBackground.base.opacity(0.82))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) RoundedRectangle(cornerRadius: Design.CornerRadius.medium)