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:
|
||||
return "photo.fill"
|
||||
case .profileWithLogoBadge:
|
||||
return "person.crop.square.badge.checkmark"
|
||||
return "person.crop.square"
|
||||
case .logoWithAvatar:
|
||||
return "building.2.fill"
|
||||
case .coverWithAvatar:
|
||||
|
||||
@ -205,10 +205,6 @@
|
||||
},
|
||||
"Contact" : {
|
||||
|
||||
},
|
||||
"Contact fields" : {
|
||||
"comment" : "A label displayed above a list of a user's contact fields.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"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.",
|
||||
@ -949,10 +945,6 @@
|
||||
},
|
||||
"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." : {
|
||||
"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 {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
HStack {
|
||||
Text("Contact fields")
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxxLarge) {
|
||||
if !primaryContactFields.isEmpty {
|
||||
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)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
|
||||
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)
|
||||
.padding(.top, Design.Spacing.xxSmall)
|
||||
}
|
||||
|
||||
if viewMode == .list {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
ForEach(card.orderedContactFields) { field in
|
||||
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
|
||||
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)
|
||||
}
|
||||
// Always present profiles/links as grid. Keep the view mode enum/storage for future toggling.
|
||||
LazyVGrid(columns: columns, spacing: Design.Spacing.small) {
|
||||
ForEach(fields) { field in
|
||||
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor, style: .grid) {
|
||||
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 {
|
||||
case list
|
||||
case grid
|
||||
@ -416,8 +473,14 @@ private enum ContactFieldsViewMode: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
private struct ContactFieldRowView: View {
|
||||
enum Style {
|
||||
case list
|
||||
case grid
|
||||
}
|
||||
|
||||
let field: ContactField
|
||||
let themeColor: Color
|
||||
let style: Style
|
||||
let action: () -> Void
|
||||
|
||||
private var valueText: String {
|
||||
@ -425,7 +488,54 @@ private struct ContactFieldRowView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -445,17 +555,21 @@ private struct ContactFieldRowView: View {
|
||||
.typography(.subheading)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(1)
|
||||
.lineLimit(valueLineLimit)
|
||||
.minimumScaleFactor(0.72)
|
||||
.allowsTightening(true)
|
||||
|
||||
Text(labelText)
|
||||
.typography(.caption)
|
||||
.typography(.captionEmphasis)
|
||||
.italic()
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(style == .grid ? 2 : 1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, minHeight: rowMinHeight, alignment: .center)
|
||||
.background(Color.AppBackground.base.opacity(0.82))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user