407 lines
14 KiB
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)
|
|
}
|