BusinessCard/BusinessCard/Views/BusinessCardView.swift

407 lines
14 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.effectiveDisplayName), \(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)
}
}
// MARK: - Profile Banner Content
private struct ProfileBannerContent: View {
let card: BusinessCard
var body: some View {
ZStack {
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "person.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Profile")
.font(.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 {
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()
.clipped()
} else {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "building.2.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Logo")
.font(.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 {
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
ZStack {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "photo.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Cover")
.font(.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 }
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.effectiveDisplayName)
.font(.title2)
.bold()
.foregroundStyle(textColor)
if !card.pronouns.isEmpty {
Text("(\(card.pronouns))")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
}
}
Text(card.role)
.font(.headline)
.foregroundStyle(textColor)
Text(card.company)
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
if !card.headline.isEmpty {
Text(card.headline)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
.padding(.top, Design.Spacing.xxSmall)
}
}
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)
.font(.system(size: Design.BaseFontSize.title))
.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")
.font(.system(size: Design.BaseFontSize.body))
Text("Logo")
.font(.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")
.font(.system(size: Design.BaseFontSize.body))
Text("Logo")
.font(.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
let card: BusinessCard
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
ForEach(card.orderedContactFields) { field in
ContactFieldRowView(field: field) {
if let url = field.buildURL() {
openURL(url)
}
}
}
}
}
}
private struct ContactFieldRowView: View {
let field: ContactField
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(alignment: .top, spacing: Design.Spacing.medium) {
field.iconImage()
.font(.body)
.foregroundStyle(.white)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
.background(field.iconColor)
.clipShape(.circle)
VStack(alignment: .leading, spacing: 0) {
Text(field.displayValue)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
.multilineTextAlignment(.leading)
Text(field.title.isEmpty ? field.displayName : field.title)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.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(
displayName: "Matt Bruce",
role: "Lead iOS Developer",
company: "Toyota",
themeName: "Coral",
headerLayoutRawValue: "profileBanner"
)
BusinessCardView(card: card)
.padding()
.background(Color.AppBackground.base)
}
#Preview("Cover + Avatar + Logo") {
@Previewable @State var card = BusinessCard(
displayName: "Matt Bruce",
role: "Lead iOS Developer",
company: "Toyota",
themeName: "Violet",
headerLayoutRawValue: "coverWithAvatarAndLogo"
)
BusinessCardView(card: card)
.padding()
.background(Color.AppBackground.base)
}