diff --git a/BusinessCard/Models/CardHeaderLayout.swift b/BusinessCard/Models/CardHeaderLayout.swift index 0496976..acf77b5 100644 --- a/BusinessCard/Models/CardHeaderLayout.swift +++ b/BusinessCard/Models/CardHeaderLayout.swift @@ -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: diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 8b7c8b7..5bae403 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -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" : { diff --git a/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift b/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift index ce3b412..3b498c6 100644 --- a/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift +++ b/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift @@ -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)