BusinessCard/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift

618 lines
21 KiB
Swift

import SwiftUI
import Bedrock
import SwiftData
struct BusinessCardView: View {
let card: BusinessCard
var isCompact: Bool = false
private var hasOverlappingContent: Bool {
card.headerLayout.hasOverlappingContent
}
var body: some View {
VStack(spacing: 0) {
CardBannerView(card: card)
CardContentView(card: card, isCompact: isCompact)
.offset(y: hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0)
.padding(.bottom, hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0)
}
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusLarge,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetMedium
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String.localized("Business card"))
.accessibilityValue("\(card.fullName), \(card.role), \(card.company)")
}
}
// MARK: - Banner View
private struct CardBannerView: View {
let card: BusinessCard
var body: some View {
Group {
switch card.headerLayout.bannerContent {
case .profile:
ProfileBannerContent(card: card)
case .logo:
LogoBannerContent(card: card)
case .cover:
CoverBannerContent(card: card)
}
}
.frame(height: Design.CardSize.bannerHeight)
.clipped()
}
}
// MARK: - Profile Banner Content
private struct ProfileBannerContent: View {
let card: BusinessCard
var body: some View {
GeometryReader { geometry in
ZStack {
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
} else {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "person.fill")
.typography(.title2Bold)
Text("Profile")
.typography(.title3)
.bold()
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
}
}
}
}
// MARK: - Logo Banner Content
private struct LogoBannerContent: View {
let card: BusinessCard
var body: some View {
GeometryReader { geometry in
ZStack {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
} else {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "building.2.fill")
.typography(.title2Bold)
Text("Logo")
.typography(.title3)
.bold()
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
}
}
}
}
// MARK: - Cover Banner Content
private struct CoverBannerContent: View {
let card: BusinessCard
var body: some View {
GeometryReader { geometry in
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
} else {
ZStack {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "photo.fill")
.typography(.title2Bold)
Text("Cover")
.typography(.title3)
.bold()
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
}
}
}
}
// MARK: - Content View
private struct CardContentView: View {
let card: BusinessCard
let isCompact: Bool
private var textColor: Color { Color.Text.primary }
private var userInfoTopPadding: CGFloat {
card.headerLayout.contentOverlay == .none ? Design.Spacing.large : 0
}
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
contentOverlay
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(card.fullName)
.typography(.title2)
.bold()
.foregroundStyle(textColor)
if !card.pronouns.isEmpty {
Text("(\(card.pronouns))")
.typography(.subheading)
.foregroundStyle(Color.Text.secondary)
}
}
Text(card.role)
.typography(.heading)
.foregroundStyle(textColor)
Text(card.company)
.typography(.subheading)
.foregroundStyle(Color.Text.secondary)
if !card.headline.isEmpty {
Text(card.headline)
.typography(.caption)
.foregroundStyle(Color.Text.secondary)
.padding(.top, Design.Spacing.xxSmall)
}
}
.padding(.top, userInfoTopPadding)
if !isCompact {
Divider()
.padding(.vertical, Design.Spacing.xSmall)
ContactFieldsListView(card: card)
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.bottom, Design.Spacing.large)
}
@ViewBuilder
private var contentOverlay: some View {
switch card.headerLayout.contentOverlay {
case .none:
EmptyView()
case .avatar:
HStack {
ProfileAvatarView(card: card)
Spacer()
}
case .logoRectangle:
HStack {
LogoRectangleView(card: card)
Spacer()
}
case .avatarAndLogo:
HStack(alignment: .bottom) {
ProfileAvatarView(card: card)
Spacer()
LogoRectangleView(card: card)
}
}
}
}
// MARK: - Profile Avatar
private struct ProfileAvatarView: View {
let card: BusinessCard
var body: some View {
Group {
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Image(systemName: card.avatarSystemName)
.typography(.title3)
.foregroundStyle(card.theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(card.theme.accentColor)
}
}
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
.clipShape(.circle)
.overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
}
}
// MARK: - Logo Badge View
private struct LogoBadgeView: View {
let card: BusinessCard
var body: some View {
Group {
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
VStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "building.2")
.typography(.body)
Text("Logo")
.typography(.caption2)
}
.foregroundStyle(card.theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
.background(card.theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
}
}
// MARK: - Logo Rectangle View
private struct LogoRectangleView: View {
let card: BusinessCard
private let aspectRatio: CGFloat = Design.CardSize.logoContainerAspectRatio
var body: some View {
Group {
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
VStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "building.2")
.typography(.body)
Text("Logo")
.typography(.caption2)
}
.foregroundStyle(card.theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: Design.CardSize.avatarLarge * aspectRatio, height: Design.CardSize.avatarLarge)
.background(card.theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
}
}
// MARK: - Contact Fields List
private struct ContactFieldsListView: View {
@Environment(\.openURL) private var openURL
@AppStorage("businessCard.contactFieldsViewMode") private var viewModeRawValue = ContactFieldsViewMode.list.rawValue
let card: BusinessCard
private var viewMode: ContactFieldsViewMode {
get { ContactFieldsViewMode(rawValue: viewModeRawValue) ?? .list }
nonmutating set { viewModeRawValue = newValue.rawValue }
}
private var columns: [GridItem] {
[
GridItem(.flexible(), spacing: Design.Spacing.small),
GridItem(.flexible(), spacing: Design.Spacing.small)
]
}
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.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)
.padding(.top, Design.Spacing.xxSmall)
}
// 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)
}
}
}
}
}
private func openField(_ field: ContactField) {
if let url = field.buildURL() {
openURL(url)
}
}
}
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
var id: String { rawValue }
var title: String {
switch self {
case .list: return "List"
case .grid: return "Grid"
}
}
}
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 {
field.displayValue
}
private var labelText: String {
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 {
Button(action: action) {
HStack(alignment: .center, spacing: Design.Spacing.medium) {
ZStack {
Circle()
.fill(themeColor.opacity(0.22))
.frame(width: 34, height: 34)
field.iconImage()
.typography(.caption)
.foregroundStyle(themeColor)
}
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(valueText)
.typography(.subheading)
.foregroundStyle(Color.Text.primary)
.multilineTextAlignment(.leading)
.lineLimit(valueLineLimit)
.minimumScaleFactor(0.72)
.allowsTightening(true)
Text(labelText)
.typography(.captionEmphasis)
.italic()
.foregroundStyle(Color.Text.secondary)
.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)
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel("\(field.displayName): \(field.value)")
.accessibilityHint(field.title.isEmpty ? String(localized: "Opens \(field.displayName)") : field.title)
}
}
// MARK: - Previews
#Preview("Profile Banner") {
@Previewable @State var card = BusinessCard(
role: "Lead iOS Developer",
company: "Toyota",
themeName: "Coral",
headerLayoutRawValue: "profileBanner",
firstName: "Matt",
lastName: "Bruce"
)
BusinessCardView(card: card)
.padding()
.background(Color.AppBackground.base)
}
#Preview("Cover + Avatar + Logo") {
@Previewable @State var card = BusinessCard(
role: "Lead iOS Developer",
company: "Toyota",
themeName: "Violet",
headerLayoutRawValue: "coverWithAvatarAndLogo",
firstName: "Matt",
lastName: "Bruce"
)
BusinessCardView(card: card)
.padding()
.background(Color.AppBackground.base)
}