Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e6f6684c88
commit
6c0fa315b2
@ -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:
|
||||||
|
|||||||
@ -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" : {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user