Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-09 14:21:20 -06:00
parent 5e774cc778
commit aed2f62918
6 changed files with 619 additions and 599 deletions

View File

@ -374,7 +374,8 @@ final class BusinessCard {
extension BusinessCard { extension BusinessCard {
@MainActor @MainActor
static func createSamples(in context: ModelContext) { static func createSamples(in context: ModelContext) {
// Sample 1: Property Developer - Uses coverWithCenteredLogo layout // Sample 1: Property Developer - Uses coverWithAvatarAndLogo layout
// Best when: Has cover, logo, and profile - logo centered on cover
let sample1 = BusinessCard( let sample1 = BusinessCard(
displayName: "Daniel Sullivan", displayName: "Daniel Sullivan",
role: "Property Developer", role: "Property Developer",
@ -383,7 +384,7 @@ extension BusinessCard {
isDefault: true, isDefault: true,
themeName: "Coral", themeName: "Coral",
layoutStyleRawValue: "split", layoutStyleRawValue: "split",
headerLayoutRawValue: "coverWithCenteredLogo", headerLayoutRawValue: "coverWithAvatarAndLogo",
avatarSystemName: "person.crop.circle", avatarSystemName: "person.crop.circle",
pronouns: "he/him", pronouns: "he/him",
bio: "Building the future of Dallas real estate" bio: "Building the future of Dallas real estate"
@ -395,7 +396,8 @@ extension BusinessCard {
sample1.addContactField(.address, value: "Dallas, TX", title: "Work") sample1.addContactField(.address, value: "Dallas, TX", title: "Work")
sample1.addContactField(.linkedIn, value: "linkedin.com/in/danielsullivan", title: "") sample1.addContactField(.linkedIn, value: "linkedin.com/in/danielsullivan", title: "")
// Sample 2: Creative Lead - Uses coverWithAvatar layout // Sample 2: Creative Lead - Uses logoBanner layout
// Best when: Strong logo, no cover needed
let sample2 = BusinessCard( let sample2 = BusinessCard(
displayName: "Maya Chen", displayName: "Maya Chen",
role: "Creative Lead", role: "Creative Lead",
@ -404,7 +406,7 @@ extension BusinessCard {
isDefault: false, isDefault: false,
themeName: "Midnight", themeName: "Midnight",
layoutStyleRawValue: "stacked", layoutStyleRawValue: "stacked",
headerLayoutRawValue: "coverWithAvatar", headerLayoutRawValue: "logoBanner",
avatarSystemName: "sparkles", avatarSystemName: "sparkles",
pronouns: "she/her", pronouns: "she/her",
bio: "Designing experiences that matter" bio: "Designing experiences that matter"
@ -417,7 +419,8 @@ extension BusinessCard {
sample2.addContactField(.twitter, value: "twitter.com/mayachen", title: "") sample2.addContactField(.twitter, value: "twitter.com/mayachen", title: "")
sample2.addContactField(.instagram, value: "instagram.com/mayachen.design", title: "") sample2.addContactField(.instagram, value: "instagram.com/mayachen.design", title: "")
// Sample 3: DJ - Uses profileBanner layout (profile photo as banner) // Sample 3: DJ - Uses profileBanner layout
// Best when: Strong profile photo, personal brand
let sample3 = BusinessCard( let sample3 = BusinessCard(
displayName: "DJ Michaels", displayName: "DJ Michaels",
role: "DJ", role: "DJ",

View File

@ -1,41 +1,95 @@
import Foundation import Foundation
// MARK: - Banner Content Type
/// What fills the banner area of the card header.
enum BannerContentType: Sendable {
/// Profile photo fills the entire banner
case profile
/// Logo (3:2 landscape) fills the banner
case logo
/// Cover photo fills the banner
case cover
}
// MARK: - Content Overlay Type
/// What overlaps from the banner into the content area.
enum ContentOverlayType: Sendable {
/// No overlay - content starts immediately below banner
case none
/// Circular avatar overlapping from banner
case avatar
/// Logo rectangle (3:2) overlapping from banner
case logoRectangle
/// Both avatar and logo displayed side-by-side overlapping
case avatarAndLogo
}
// MARK: - Card Header Layout
/// Defines how the business card header arranges profile, cover, and logo images. /// Defines how the business card header arranges profile, cover, and logo images.
/// These 8 layouts match the reference app exactly.
enum CardHeaderLayout: String, CaseIterable, Identifiable, Hashable, Sendable { enum CardHeaderLayout: String, CaseIterable, Identifiable, Hashable, Sendable {
/// Profile photo fills the entire banner area.
/// Best when: User has a strong profile photo they want prominently displayed. // MARK: - Cases (8 templates matching reference app)
/// 1. Profile photo fills the entire banner area, no overlay.
/// Banner: Profile | Overlay: None
case profileBanner case profileBanner
/// Cover image as banner with profile avatar overlapping at bottom-left. /// 2. Logo (3:2 landscape) fills the entire banner area, no overlay.
/// Best when: User has both cover and profile photos, no logo. /// Banner: Logo | Overlay: None
case logoBanner
/// 3. Cover photo fills the banner, no overlay.
/// Banner: Cover | Overlay: None
case coverOnly
/// 4. Profile photo fills banner, logo badge overlaps in content.
/// Banner: Profile | Overlay: Logo Badge
case profileWithLogoBadge
/// 5. Logo fills the banner, avatar overlaps in content.
/// Banner: Logo | Overlay: Avatar
case logoWithAvatar
/// 6. Cover photo fills banner, avatar overlaps in content.
/// Banner: Cover | Overlay: Avatar
case coverWithAvatar case coverWithAvatar
/// Cover image with company logo centered in banner, profile avatar overlapping below. /// 7. Cover photo fills banner, logo rectangle overlaps in content.
/// Best when: User has all three images and wants logo prominently displayed. /// Banner: Cover | Overlay: Logo Rectangle
case coverWithCenteredLogo case coverWithLogo
/// Cover image with small logo badge in corner, profile avatar overlapping. /// 8. Cover photo fills banner, avatar and logo side-by-side overlap in content.
/// Best when: User wants subtle logo presence with prominent avatar. /// Banner: Cover | Overlay: Avatar + Logo
case coverWithLogoBadge case coverWithAvatarAndLogo
/// Profile avatar and logo displayed side-by-side in content area, cover as banner.
/// Best when: User wants both profile and logo equally visible.
case avatarAndLogoSideBySide
// MARK: - Identifiable
var id: String { rawValue } var id: String { rawValue }
// MARK: - Display Properties
var displayName: String { var displayName: String {
switch self { switch self {
case .profileBanner: case .profileBanner:
return String.localized("Profile Banner") return String.localized("Profile Banner")
case .logoBanner:
return String.localized("Logo Banner")
case .coverOnly:
return String.localized("Cover Only")
case .profileWithLogoBadge:
return String.localized("Profile + Logo")
case .logoWithAvatar:
return String.localized("Logo + Avatar")
case .coverWithAvatar: case .coverWithAvatar:
return String.localized("Cover + Avatar") return String.localized("Cover + Avatar")
case .coverWithCenteredLogo: case .coverWithLogo:
return String.localized("Centered Logo") return String.localized("Cover + Logo")
case .coverWithLogoBadge: case .coverWithAvatarAndLogo:
return String.localized("Logo Badge") return String.localized("Cover + Both")
case .avatarAndLogoSideBySide:
return String.localized("Side by Side")
} }
} }
@ -43,14 +97,20 @@ enum CardHeaderLayout: String, CaseIterable, Identifiable, Hashable, Sendable {
switch self { switch self {
case .profileBanner: case .profileBanner:
return String.localized("Your photo fills the banner") return String.localized("Your photo fills the banner")
case .logoBanner:
return String.localized("Company logo fills the banner")
case .coverOnly:
return String.localized("Cover image only")
case .profileWithLogoBadge:
return String.localized("Profile with logo badge")
case .logoWithAvatar:
return String.localized("Logo with avatar overlay")
case .coverWithAvatar: case .coverWithAvatar:
return String.localized("Cover with avatar overlay") return String.localized("Cover with avatar overlay")
case .coverWithCenteredLogo: case .coverWithLogo:
return String.localized("Logo centered, avatar below") return String.localized("Cover with logo overlay")
case .coverWithLogoBadge: case .coverWithAvatarAndLogo:
return String.localized("Small logo badge in corner") return String.localized("Cover with avatar and logo")
case .avatarAndLogoSideBySide:
return String.localized("Avatar and logo together")
} }
} }
@ -59,68 +119,127 @@ enum CardHeaderLayout: String, CaseIterable, Identifiable, Hashable, Sendable {
switch self { switch self {
case .profileBanner: case .profileBanner:
return "person.crop.rectangle.fill" return "person.crop.rectangle.fill"
case .logoBanner:
return "building.2.crop.circle.fill"
case .coverOnly:
return "photo.fill"
case .profileWithLogoBadge:
return "person.crop.square.badge.checkmark"
case .logoWithAvatar:
return "building.2.fill"
case .coverWithAvatar: case .coverWithAvatar:
return "person.crop.rectangle.stack.fill" return "person.crop.rectangle.stack.fill"
case .coverWithCenteredLogo: case .coverWithLogo:
return "building.2.fill" return "rectangle.stack.fill"
case .coverWithLogoBadge: case .coverWithAvatarAndLogo:
return "person.crop.square.badge.camera"
case .avatarAndLogoSideBySide:
return "rectangle.split.2x1.fill" return "rectangle.split.2x1.fill"
} }
} }
/// Whether this layout requires a cover photo to look good. // MARK: - Rendering Properties
/// What fills the banner area
var bannerContent: BannerContentType {
switch self {
case .profileBanner, .profileWithLogoBadge:
return .profile
case .logoBanner, .logoWithAvatar:
return .logo
case .coverOnly, .coverWithAvatar, .coverWithLogo, .coverWithAvatarAndLogo:
return .cover
}
}
/// What overlaps into the content area
var contentOverlay: ContentOverlayType {
switch self {
case .profileBanner, .logoBanner, .coverOnly:
return .none
case .profileWithLogoBadge:
return .logoRectangle
case .logoWithAvatar, .coverWithAvatar:
return .avatar
case .coverWithLogo:
return .logoRectangle
case .coverWithAvatarAndLogo:
return .avatarAndLogo
}
}
/// Whether this layout has content that overlaps from banner into content area
var hasOverlappingContent: Bool {
contentOverlay != .none
}
// MARK: - Image Requirements
/// Whether this layout requires a cover photo.
var requiresCoverPhoto: Bool { var requiresCoverPhoto: Bool {
bannerContent == .cover
}
/// Whether this layout requires a company logo.
var requiresLogo: Bool {
switch self { switch self {
case .profileBanner: case .logoBanner, .profileWithLogoBadge, .logoWithAvatar, .coverWithLogo, .coverWithAvatarAndLogo:
return false
case .coverWithAvatar, .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide:
return true return true
case .profileBanner, .coverOnly, .coverWithAvatar:
return false
} }
} }
/// Whether this layout benefits from a company logo. /// Whether this layout requires a profile photo.
var benefitsFromLogo: Bool { var requiresProfilePhoto: Bool {
switch self { switch self {
case .profileBanner, .coverWithAvatar: case .profileBanner, .profileWithLogoBadge:
return false
case .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide:
return true return true
case .logoBanner, .coverOnly, .logoWithAvatar, .coverWithAvatar, .coverWithLogo, .coverWithAvatarAndLogo:
return false
} }
} }
/// Whether this layout shows the avatar in the content area (overlapping from banner). /// Returns images that are missing for this layout to render properly.
var showsAvatarInContent: Bool { func missingImages(hasProfile: Bool, hasCover: Bool, hasLogo: Bool) -> [String] {
switch self { var missing: [String] = []
case .profileBanner:
return false if requiresCoverPhoto && !hasCover {
case .coverWithAvatar, .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide: missing.append(String.localized("Cover"))
return true
} }
if requiresLogo && !hasLogo {
missing.append(String.localized("Logo"))
}
if requiresProfilePhoto && !hasProfile {
missing.append(String.localized("Profile"))
}
return missing
} }
/// Whether all required images are present.
func hasAllRequiredImages(hasProfile: Bool, hasCover: Bool, hasLogo: Bool) -> Bool {
missingImages(hasProfile: hasProfile, hasCover: hasCover, hasLogo: hasLogo).isEmpty
}
// MARK: - Suggestion Logic
/// Returns the best layout based on available images. /// Returns the best layout based on available images.
/// This is a pure function that doesn't require actor isolation.
nonisolated static func suggested(hasProfile: Bool, hasCover: Bool, hasLogo: Bool) -> CardHeaderLayout { nonisolated static func suggested(hasProfile: Bool, hasCover: Bool, hasLogo: Bool) -> CardHeaderLayout {
switch (hasCover, hasLogo, hasProfile) { switch (hasCover, hasLogo, hasProfile) {
case (true, true, true): case (true, true, true):
// All images available - use centered logo layout return .coverWithAvatarAndLogo
return .coverWithCenteredLogo
case (true, true, false): case (true, true, false):
// Cover and logo but no profile return .coverWithLogo
return .coverWithCenteredLogo
case (true, false, true): case (true, false, true):
// Cover and profile - show cover with avatar overlay
return .coverWithAvatar return .coverWithAvatar
case (false, _, true):
// Only profile - make it the banner
return .profileBanner
case (true, false, false): case (true, false, false):
// Only cover - still use cover with avatar (will show placeholder) return .coverOnly
return .coverWithAvatar case (false, true, true):
default: return .logoWithAvatar
// Default fallback case (false, true, false):
return .logoBanner
case (false, false, true):
return .profileBanner
case (false, false, false):
return .profileBanner return .profileBanner
} }
} }

View File

@ -371,6 +371,9 @@
}, },
"Links" : { "Links" : {
},
"Logo" : {
}, },
"Maiden Name" : { "Maiden Name" : {
@ -506,6 +509,9 @@
}, },
"Preview card" : { "Preview card" : {
},
"Profile" : {
}, },
"Profile Link" : { "Profile Link" : {

View File

@ -6,20 +6,17 @@ struct BusinessCardView: View {
let card: BusinessCard let card: BusinessCard
var isCompact: Bool = false var isCompact: Bool = false
/// Whether the current header layout includes an avatar that overlaps from banner to content private var hasOverlappingContent: Bool {
private var hasAvatarOverlap: Bool { card.headerLayout.hasOverlappingContent
card.headerLayout.showsAvatarInContent
} }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Banner with logo
CardBannerView(card: card) CardBannerView(card: card)
// Content area with avatar overlapping (conditional based on layout)
CardContentView(card: card, isCompact: isCompact) CardContentView(card: card, isCompact: isCompact)
.offset(y: hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0) .offset(y: hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0)
.padding(.bottom, hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0) .padding(.bottom, hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0)
} }
.background(Color.AppBackground.elevated) .background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge)) .clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
@ -42,162 +39,112 @@ private struct CardBannerView: View {
var body: some View { var body: some View {
Group { Group {
switch card.headerLayout { switch card.headerLayout.bannerContent {
case .profileBanner: case .profile:
ProfileBannerLayout(card: card) ProfileBannerContent(card: card)
case .coverWithAvatar: case .logo:
CoverWithAvatarBannerLayout(card: card) LogoBannerContent(card: card)
case .coverWithCenteredLogo: case .cover:
CoverWithCenteredLogoLayout(card: card) CoverBannerContent(card: card)
case .coverWithLogoBadge:
CoverWithLogoBadgeLayout(card: card)
case .avatarAndLogoSideBySide:
CoverOnlyBannerLayout(card: card)
} }
} }
.frame(height: Design.CardSize.bannerHeight) .frame(height: Design.CardSize.bannerHeight)
} }
} }
// MARK: - Profile Banner Layout // MARK: - Profile Banner Content
/// Profile photo fills the entire banner private struct ProfileBannerContent: View {
private struct ProfileBannerLayout: View {
let card: BusinessCard let card: BusinessCard
var body: some View { var body: some View {
ZStack { ZStack {
// Background: profile photo or cover photo or gradient
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) { if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.clipped() .clipped()
} else if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else { } else {
// Fallback gradient
LinearGradient( LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor], colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
}
VStack(spacing: Design.Spacing.xSmall) {
// Company logo overlay (if no profile photo) Image(systemName: "person.fill")
if card.photoData == nil { .font(.system(size: Design.BaseFontSize.display, weight: .bold))
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) { Text("Profile")
Image(uiImage: uiImage) .font(.title3)
.resizable() .bold()
.scaledToFit()
.frame(height: Design.CardSize.logoSize)
} else if card.coverPhotoData == nil && !card.company.isEmpty {
Text(card.company.prefix(1).uppercased())
.font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded))
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
} }
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
} }
} }
} }
} }
// MARK: - Cover With Avatar Banner Layout // MARK: - Logo Banner Content
/// Cover image as banner, avatar overlaps into content area private struct LogoBannerContent: View {
private struct CoverWithAvatarBannerLayout: View {
let card: BusinessCard
var body: some View {
coverBackground(for: card)
}
}
// MARK: - Cover With Centered Logo Layout
/// Cover image with company logo centered in the banner
private struct CoverWithCenteredLogoLayout: View {
let card: BusinessCard let card: BusinessCard
var body: some View { var body: some View {
ZStack { ZStack {
coverBackground(for: card) LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Centered logo
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) { if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFill()
.frame(height: Design.CardSize.logoSize) .clipped()
} else if !card.company.isEmpty { } else {
Text(card.company.prefix(1).uppercased()) VStack(spacing: Design.Spacing.xSmall) {
.font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded)) Image(systemName: "building.2.fill")
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium)) .font(.system(size: Design.BaseFontSize.display, weight: .bold))
} Text("Logo")
} .font(.title3)
} .bold()
}
// MARK: - Cover With Logo Badge Layout
/// Cover image with small logo badge in corner
private struct CoverWithLogoBadgeLayout: View {
let card: BusinessCard
var body: some View {
ZStack {
coverBackground(for: card)
// Logo badge in bottom-right corner
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
VStack {
Spacer()
HStack {
Spacer()
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(height: Design.CardSize.logoSize / 1.5)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
.padding(Design.Spacing.small)
}
} }
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
} }
} }
} }
} }
// MARK: - Cover Only Banner Layout // MARK: - Cover Banner Content
/// Cover image only (for side-by-side layout where avatar/logo are in content) private struct CoverBannerContent: View {
private struct CoverOnlyBannerLayout: View {
let card: BusinessCard let card: BusinessCard
var body: some View { var body: some View {
coverBackground(for: card) if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
} Image(uiImage: uiImage)
} .resizable()
.scaledToFill()
// MARK: - Helper Function .clipped()
} else {
/// Shared cover background view ZStack {
@ViewBuilder LinearGradient(
private func coverBackground(for card: BusinessCard) -> some View { colors: [card.theme.primaryColor, card.theme.secondaryColor],
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { startPoint: .topLeading,
Image(uiImage: uiImage) endPoint: .bottomTrailing
.resizable() )
.scaledToFill()
.clipped() VStack(spacing: Design.Spacing.xSmall) {
} else { Image(systemName: "photo.fill")
LinearGradient( .font(.system(size: Design.BaseFontSize.display, weight: .bold))
colors: [card.theme.primaryColor, card.theme.secondaryColor], Text("Cover")
startPoint: .topLeading, .font(.title3)
endPoint: .bottomTrailing .bold()
) }
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
}
} }
} }
@ -209,31 +156,10 @@ private struct CardContentView: View {
private var textColor: Color { Color.Text.primary } private var textColor: Color { Color.Text.primary }
/// Whether to show the avatar in the content area
private var showsAvatarInContent: Bool {
card.headerLayout.showsAvatarInContent
}
/// Whether this is the side-by-side layout
private var isSideBySideLayout: Bool {
card.headerLayout == .avatarAndLogoSideBySide
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
// Avatar row (conditional based on layout) contentOverlay
if showsAvatarInContent {
HStack(alignment: .bottom, spacing: Design.Spacing.small) {
ProfileAvatarView(card: card)
// Side-by-side: show logo next to avatar
if isSideBySideLayout {
LogoBadgeView(card: card)
}
}
}
// Name and title
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {
Text(card.effectiveDisplayName) Text(card.effectiveDisplayName)
@ -268,13 +194,36 @@ private struct CardContentView: View {
Divider() Divider()
.padding(.vertical, Design.Spacing.xSmall) .padding(.vertical, Design.Spacing.xSmall)
// Contact fields from array (preferred) or legacy properties
ContactFieldsListView(card: card) ContactFieldsListView(card: card)
} }
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.bottom, 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 // MARK: - Profile Avatar
@ -298,22 +247,13 @@ private struct ProfileAvatarView: View {
} }
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge) .frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
.clipShape(.circle) .clipShape(.circle)
.overlay( .overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
Circle() .shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
.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 // MARK: - Logo Badge View
/// Logo displayed as a rounded rectangle badge (for side-by-side layout)
private struct LogoBadgeView: View { private struct LogoBadgeView: View {
let card: BusinessCard let card: BusinessCard
@ -322,28 +262,57 @@ private struct LogoBadgeView: View {
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) { if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFill()
.padding(Design.Spacing.small) .clipped()
} else { } else {
Image(systemName: "building.2") VStack(spacing: Design.Spacing.xxSmall) {
.font(.system(size: Design.BaseFontSize.title)) Image(systemName: "building.2")
.foregroundStyle(card.theme.textColor) .font(.system(size: Design.BaseFontSize.body))
.frame(maxWidth: .infinity, maxHeight: .infinity) Text("Logo")
.font(.caption2)
}
.foregroundStyle(card.theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
.frame(width: Design.CardSize.logoSize, height: Design.CardSize.logoSize) .frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
.background(card.theme.accentColor) .background(card.theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay( .overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) .shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick) }
) }
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint), // MARK: - Logo Rectangle View
radius: Design.Shadow.radiusSmall,
x: Design.Shadow.offsetNone, private struct LogoRectangleView: View {
y: Design.Shadow.offsetSmall 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)
} }
} }
@ -355,7 +324,6 @@ private struct ContactFieldsListView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
// New contact fields array (preferred)
ForEach(card.orderedContactFields) { field in ForEach(card.orderedContactFields) { field in
ContactFieldRowView(field: field) { ContactFieldRowView(field: field) {
if let url = field.buildURL() { if let url = field.buildURL() {
@ -363,12 +331,10 @@ private struct ContactFieldsListView: View {
} }
} }
} }
} }
} }
} }
/// A clickable row for a contact field
private struct ContactFieldRowView: View { private struct ContactFieldRowView: View {
let field: ContactField let field: ContactField
let action: () -> Void let action: () -> Void
@ -376,7 +342,6 @@ private struct ContactFieldRowView: View {
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
HStack(alignment: .top, spacing: Design.Spacing.medium) { HStack(alignment: .top, spacing: Design.Spacing.medium) {
// Icon with brand color
field.iconImage() field.iconImage()
.font(.body) .font(.body)
.foregroundStyle(.white) .foregroundStyle(.white)
@ -385,13 +350,11 @@ private struct ContactFieldRowView: View {
.clipShape(.circle) .clipShape(.circle)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Value (uses displayValue for formatted output, e.g., multi-line addresses)
Text(field.displayValue) Text(field.displayValue)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.Text.primary) .foregroundStyle(Color.Text.primary)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
// Title/Label
Text(field.title.isEmpty ? field.displayName : field.title) Text(field.title.isEmpty ? field.displayName : field.title)
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.secondary) .foregroundStyle(Color.Text.secondary)
@ -400,7 +363,6 @@ private struct ContactFieldRowView: View {
Spacer() Spacer()
// Action indicator
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.tertiary) .foregroundStyle(Color.Text.tertiary)
@ -413,87 +375,30 @@ private struct ContactFieldRowView: View {
} }
} }
// MARK: - Preview // MARK: - Previews
#Preview("Cover + Avatar Layout") { #Preview("Profile Banner") {
@Previewable @State var card: BusinessCard = { @Previewable @State var card = BusinessCard(
let card = BusinessCard( displayName: "Matt Bruce",
displayName: "Matt Bruce", role: "Lead iOS Developer",
role: "Lead iOS Developer", company: "Toyota",
company: "Toyota", themeName: "Coral",
themeName: "Coral", headerLayoutRawValue: "profileBanner"
layoutStyleRawValue: "stacked", )
headerLayoutRawValue: "coverWithAvatar",
headline: "Building the future of mobility"
)
// Add contact fields manually without SwiftData
let emailField = ContactField(typeId: "email", value: "matt.bruce@toyota.com", title: "Work", orderIndex: 0)
let phoneField = ContactField(typeId: "phone", value: "+1 (214) 755-1043", title: "Cell", orderIndex: 1)
let websiteField = ContactField(typeId: "website", value: "toyota.com", title: "", orderIndex: 2)
// Create structured address
let address = PostalAddress(
street: "6565 Headquarters Dr",
city: "Plano",
state: "TX",
postalCode: "75024"
)
let addressField = ContactField(typeId: "address", value: address.encode(), title: "Work", orderIndex: 3)
let linkedInField = ContactField(typeId: "linkedIn", value: "linkedin.com/in/mattbruce", title: "", orderIndex: 4)
let twitterField = ContactField(typeId: "twitter", value: "twitter.com/mattbruce", title: "", orderIndex: 5)
card.contactFields = [emailField, phoneField, websiteField, addressField, linkedInField, twitterField]
return card
}()
BusinessCardView(card: card) BusinessCardView(card: card)
.padding() .padding()
.background(Color.AppBackground.base) .background(Color.AppBackground.base)
} }
#Preview("Profile Banner Layout") { #Preview("Cover + Avatar + Logo") {
@Previewable @State var card: BusinessCard = { @Previewable @State var card = BusinessCard(
let card = BusinessCard( displayName: "Matt Bruce",
displayName: "Matt Bruce", role: "Lead iOS Developer",
role: "Lead iOS Developer", company: "Toyota",
company: "Toyota", themeName: "Violet",
themeName: "Ocean", headerLayoutRawValue: "coverWithAvatarAndLogo"
layoutStyleRawValue: "stacked", )
headerLayoutRawValue: "profileBanner",
headline: "Building the future of mobility"
)
let emailField = ContactField(typeId: "email", value: "matt.bruce@toyota.com", title: "Work", orderIndex: 0)
card.contactFields = [emailField]
return card
}()
BusinessCardView(card: card)
.padding()
.background(Color.AppBackground.base)
}
#Preview("Cover + Logo + Avatar Layout") {
@Previewable @State var card: BusinessCard = {
let card = BusinessCard(
displayName: "Matt Bruce",
role: "Lead iOS Developer",
company: "Toyota",
themeName: "Midnight",
layoutStyleRawValue: "stacked",
headerLayoutRawValue: "coverWithCenteredLogo",
headline: "Building the future of mobility"
)
let emailField = ContactField(typeId: "email", value: "matt.bruce@toyota.com", title: "Work", orderIndex: 0)
card.contactFields = [emailField]
return card
}()
BusinessCardView(card: card) BusinessCardView(card: card)
.padding() .padding()

View File

@ -523,14 +523,33 @@ private struct ImageLayoutRow: View {
let onSelectImage: (CardEditorView.ImageType) -> Void let onSelectImage: (CardEditorView.ImageType) -> Void
let onSelectLayout: () -> Void let onSelectLayout: () -> Void
/// Whether the selected layout shows avatar in content area /// Whether the selected layout has overlapping content
private var showsAvatarInContent: Bool { private var hasOverlappingContent: Bool {
selectedHeaderLayout.showsAvatarInContent selectedHeaderLayout.hasOverlappingContent
} }
/// Whether the selected layout is side-by-side @ViewBuilder
private var isSideBySideLayout: Bool { private var overlayContent: some View {
selectedHeaderLayout == .avatarAndLogoSideBySide switch selectedHeaderLayout.contentOverlay {
case .none:
EmptyView()
case .avatar:
HStack {
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
Spacer()
}
case .logoRectangle:
HStack {
EditorLogoRectangleView(logoData: logoData, theme: selectedTheme)
Spacer()
}
case .avatarAndLogo:
HStack {
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
Spacer()
EditorLogoRectangleView(logoData: logoData, theme: selectedTheme)
}
}
} }
var body: some View { var body: some View {
@ -547,27 +566,13 @@ private struct ImageLayoutRow: View {
selectedHeaderLayout: selectedHeaderLayout selectedHeaderLayout: selectedHeaderLayout
) )
// Avatar overlay (for layouts that show avatar in content) // Overlay content based on layout
if showsAvatarInContent { if hasOverlappingContent {
HStack(spacing: Design.Spacing.small) { overlayContent
ProfilePhotoView( .offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap)
photoData: photoData,
avatarSystemName: avatarSystemName,
theme: selectedTheme
)
// Side-by-side: show logo badge next to avatar
if isSideBySideLayout {
EditorLogoBadgeView(
logoData: logoData,
theme: selectedTheme
)
}
}
.offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap)
} }
} }
.padding(.bottom, showsAvatarInContent ? Design.CardSize.avatarOverlap : 0) .padding(.bottom, hasOverlappingContent ? Design.CardSize.avatarOverlap : 0)
// Layout selector button // Layout selector button
Button(action: onSelectLayout) { Button(action: onSelectLayout) {
@ -624,17 +629,13 @@ private struct EditorBannerPreviewView: View {
var body: some View { var body: some View {
Group { Group {
switch selectedHeaderLayout { switch selectedHeaderLayout.bannerContent {
case .profileBanner: case .profile:
profileBannerPreview profileBannerPreview
case .coverWithAvatar: case .logo:
coverOnlyPreview logoBannerPreview
case .coverWithCenteredLogo: case .cover:
coverWithCenteredLogoPreview coverBannerPreview
case .coverWithLogoBadge:
coverWithLogoBadgePreview
case .avatarAndLogoSideBySide:
coverOnlyPreview
} }
} }
.frame(height: Design.CardSize.bannerHeight) .frame(height: Design.CardSize.bannerHeight)
@ -651,73 +652,46 @@ private struct EditorBannerPreviewView: View {
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.clipped() .clipped()
} else if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { } else {
themeGradient
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "person.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Profile")
.font(.title3)
.bold()
}
.foregroundStyle(selectedTheme.textColor.opacity(Design.Opacity.medium))
}
}
}
/// Logo (3:2) fills the banner
private var logoBannerPreview: some View {
ZStack {
themeGradient
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.clipped() .clipped()
} else { } else {
themeGradient VStack(spacing: Design.Spacing.xSmall) {
} Image(systemName: "building.2.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
// Show logo if no profile photo Text("Logo")
if photoData == nil { .font(.title3)
if let logoData, let uiImage = UIImage(data: logoData) { .bold()
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(height: Design.CardSize.logoSize)
} }
.foregroundStyle(selectedTheme.textColor.opacity(Design.Opacity.medium))
} }
} }
} }
/// Cover image only (avatar overlaps into content) /// Cover photo fills the banner
private var coverOnlyPreview: some View { private var coverBannerPreview: some View {
coverBackground
}
/// Cover with centered logo
private var coverWithCenteredLogoPreview: some View {
ZStack {
coverBackground
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(height: Design.CardSize.logoSize)
}
}
}
/// Cover with small logo badge in corner
private var coverWithLogoBadgePreview: some View {
ZStack {
coverBackground
if let logoData, let uiImage = UIImage(data: logoData) {
VStack {
Spacer()
HStack {
Spacer()
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(height: Design.CardSize.logoSize / 1.5)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
.padding(Design.Spacing.small)
}
}
}
}
}
// MARK: - Helpers
private var coverBackground: some View {
Group { Group {
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
@ -725,11 +699,24 @@ private struct EditorBannerPreviewView: View {
.scaledToFill() .scaledToFill()
.clipped() .clipped()
} else { } else {
themeGradient ZStack {
themeGradient
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "photo.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Cover")
.font(.title3)
.bold()
}
.foregroundStyle(selectedTheme.textColor.opacity(Design.Opacity.medium))
}
} }
} }
} }
// MARK: - Helpers
private var themeGradient: some View { private var themeGradient: some View {
LinearGradient( LinearGradient(
colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor],
@ -739,6 +726,7 @@ private struct EditorBannerPreviewView: View {
} }
} }
// MARK: - Editor Logo Badge View // MARK: - Editor Logo Badge View
/// Logo badge for side-by-side layout in editor /// Logo badge for side-by-side layout in editor
@ -776,6 +764,45 @@ private struct EditorLogoBadgeView: View {
} }
} }
// MARK: - Editor Logo Rectangle View
/// Logo rectangle (3:2) for coverWithLogo and coverWithAvatarAndLogo layouts in editor
private struct EditorLogoRectangleView: View {
let logoData: Data?
let theme: CardTheme
private let aspectRatio: CGFloat = Design.CardSize.logoContainerAspectRatio
var body: some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.padding(Design.Spacing.small)
} else {
Image(systemName: "building.2")
.font(.system(size: Design.BaseFontSize.title))
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: Design.CardSize.avatarLarge * aspectRatio, height: Design.CardSize.avatarLarge)
.background(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: - Image Action Buttons Row // MARK: - Image Action Buttons Row
private struct ImageActionButtonsRow: View { private struct ImageActionButtonsRow: View {

View File

@ -51,7 +51,6 @@ struct HeaderLayoutPickerView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
// Layout carousel
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.large) { HStack(spacing: Design.Spacing.large) {
ForEach(CardHeaderLayout.allCases) { layout in ForEach(CardHeaderLayout.allCases) { layout in
@ -63,10 +62,7 @@ struct HeaderLayoutPickerView: View {
coverPhotoData: coverPhotoData, coverPhotoData: coverPhotoData,
logoData: logoData, logoData: logoData,
avatarSystemName: avatarSystemName, avatarSystemName: avatarSystemName,
theme: theme, theme: theme
displayName: displayName,
role: role,
company: company
) { ) {
withAnimation(.snappy(duration: Design.Animation.quick)) { withAnimation(.snappy(duration: Design.Animation.quick)) {
currentLayout = layout currentLayout = layout
@ -81,7 +77,6 @@ struct HeaderLayoutPickerView: View {
Spacer() Spacer()
// Confirm button
Button { Button {
selectedLayout = currentLayout selectedLayout = currentLayout
dismiss() dismiss()
@ -126,68 +121,48 @@ private struct LayoutPreviewCard: View {
let logoData: Data? let logoData: Data?
let avatarSystemName: String let avatarSystemName: String
let theme: CardTheme let theme: CardTheme
let displayName: String
let role: String
let company: String
let onSelect: () -> Void let onSelect: () -> Void
// Layout constants
private let cardWidth: CGFloat = 200 private let cardWidth: CGFloat = 200
private let cardHeight: CGFloat = 280 private let cardHeight: CGFloat = 280
private let bannerHeight: CGFloat = 100 private let bannerHeight: CGFloat = 100
private let avatarSize: CGFloat = 56 private let avatarSize: CGFloat = 56
private let avatarSmall: CGFloat = 44 private let logoRectWidth: CGFloat = 84 // 56 * 1.5 aspect ratio
private let logoSize: CGFloat = 48
private var needsMoreImages: Bool { private var needsMoreImages: Bool {
(layout.requiresCoverPhoto && coverPhotoData == nil) || !layout.hasAllRequiredImages(
(layout.benefitsFromLogo && logoData == nil) hasProfile: photoData != nil,
hasCover: coverPhotoData != nil,
hasLogo: logoData != nil
)
} }
/// Whether avatar overlaps from banner to content private var hasOverlappingContent: Bool {
private var showsAvatarInContent: Bool { layout.hasOverlappingContent
layout.showsAvatarInContent
}
/// Whether this is the side-by-side layout
private var isSideBySideLayout: Bool {
layout == .avatarAndLogoSideBySide
} }
var body: some View { var body: some View {
Button(action: onSelect) { Button(action: onSelect) {
VStack(spacing: 0) { VStack(spacing: 0) {
// Badge overlay
ZStack(alignment: .top) { ZStack(alignment: .top) {
// Card preview
VStack(spacing: 0) { VStack(spacing: 0) {
// Banner (just the background, no overlapping elements)
bannerContent bannerContent
.frame(height: bannerHeight) .frame(height: bannerHeight)
.clipped() .clipped()
// Content area with overlapping avatar contentArea
contentWithAvatar .offset(y: hasOverlappingContent ? -avatarSize / 2 : 0)
.offset(y: showsAvatarInContent ? -avatarSize / 2 : 0) .padding(.bottom, hasOverlappingContent ? -avatarSize / 2 : 0)
.padding(.bottom, showsAvatarInContent ? -avatarSize / 2 : 0)
} }
.frame(width: cardWidth, height: cardHeight) .frame(width: cardWidth, height: cardHeight)
.background(Color.AppBackground.elevated) .background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large) RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.stroke( .stroke(isSelected ? theme.primaryColor : .clear, lineWidth: Design.LineWidth.thick)
isSelected ? theme.primaryColor : .clear,
lineWidth: Design.LineWidth.thick
)
)
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.subtle),
radius: Design.Shadow.radiusMedium,
y: Design.Shadow.offsetMedium
) )
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.subtle), radius: Design.Shadow.radiusMedium, y: Design.Shadow.offsetMedium)
// Badges
badgeOverlay badgeOverlay
.offset(y: -Design.Spacing.small) .offset(y: -Design.Spacing.small)
} }
@ -200,118 +175,125 @@ private struct LayoutPreviewCard: View {
.accessibilityAddTraits(isSelected ? [.isSelected] : []) .accessibilityAddTraits(isSelected ? [.isSelected] : [])
} }
// MARK: - Banner Content (no avatar overlay - that's in content area) // MARK: - Banner Content
@ViewBuilder @ViewBuilder
private var bannerContent: some View { private var bannerContent: some View {
switch layout { switch layout.bannerContent {
case .profileBanner: case .profile:
profileBannerContent profileBannerPreview
case .coverWithAvatar: case .logo:
coverBackground logoBannerPreview
case .coverWithCenteredLogo: case .cover:
coverWithCenteredLogoContent coverBannerPreview
case .coverWithLogoBadge:
coverWithLogoBadgeContent
case .avatarAndLogoSideBySide:
coverBackground
} }
} }
/// Profile photo fills the entire banner private var profileBannerPreview: some View {
private var profileBannerContent: some View {
ZStack { ZStack {
if let photoData, let uiImage = UIImage(data: photoData) { if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
} else { } else {
// Fallback gradient with icon LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing)
LinearGradient(
colors: [theme.primaryColor, theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: avatarSystemName) VStack(spacing: Design.Spacing.xxSmall) {
.font(.system(size: Design.BaseFontSize.display)) Image(systemName: "person.fill")
.foregroundStyle(theme.textColor.opacity(Design.Opacity.medium)) .font(.system(size: Design.BaseFontSize.title))
Text("Profile")
.font(.caption)
.bold()
}
.foregroundStyle(theme.textColor.opacity(Design.Opacity.medium))
} }
} }
} }
/// Cover image with logo centered private var logoBannerPreview: some View {
private var coverWithCenteredLogoContent: some View {
ZStack { ZStack {
coverBackground LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing)
// Logo centered
if let logoData, let uiImage = UIImage(data: logoData) { if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFill()
.frame(height: logoSize)
} else { } else {
// Logo placeholder VStack(spacing: Design.Spacing.xxSmall) {
RoundedRectangle(cornerRadius: Design.CornerRadius.small) Image(systemName: "building.2.fill")
.fill(theme.accentColor) .font(.system(size: Design.BaseFontSize.title))
.frame(width: logoSize, height: logoSize) Text("Logo")
.overlay( .font(.caption)
Image(systemName: "building.2") .bold()
.font(.title2) }
.foregroundStyle(theme.textColor) .foregroundStyle(theme.textColor.opacity(Design.Opacity.medium))
)
} }
} }
} }
/// Cover image with small logo badge in corner private var coverBannerPreview: some View {
private var coverWithLogoBadgeContent: some View { Group {
ZStack(alignment: .bottomTrailing) { if let coverData = coverPhotoData, let uiImage = UIImage(data: coverData) {
coverBackground
// Logo badge in corner
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFill()
.frame(height: logoSize / 1.5)
.padding(Design.Spacing.xSmall)
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
.padding(Design.Spacing.xSmall)
} else { } else {
RoundedRectangle(cornerRadius: Design.CornerRadius.small) ZStack {
.fill(theme.accentColor) LinearGradient(colors: [theme.primaryColor.opacity(Design.Opacity.light), theme.secondaryColor.opacity(Design.Opacity.light)], startPoint: .topLeading, endPoint: .bottomTrailing)
.frame(width: logoSize / 1.5, height: logoSize / 1.5)
.overlay(
Image(systemName: "building.2")
.font(.caption)
.foregroundStyle(theme.textColor)
)
.padding(Design.Spacing.xSmall)
}
}
}
// MARK: - Content With Avatar
/// Content area with avatar overlapping from banner
private var contentWithAvatar: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Avatar row (for layouts that show avatar in content)
if showsAvatarInContent {
HStack(spacing: Design.Spacing.small) {
profileAvatar(size: avatarSize)
// Side-by-side: show logo badge next to avatar VStack(spacing: Design.Spacing.xxSmall) {
if isSideBySideLayout { Image(systemName: "photo.fill")
logoBadge(size: avatarSize) .font(.system(size: Design.BaseFontSize.title))
Text("Cover")
.font(.caption)
.bold()
} }
.foregroundStyle(Color.Text.tertiary)
} }
} }
}
// Placeholder text lines .clipped()
}
// MARK: - Content Area
private var contentArea: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
contentOverlay
placeholderTextLines
Spacer()
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.top, Design.Spacing.medium)
.padding(.bottom, Design.Spacing.small)
}
@ViewBuilder
private var contentOverlay: some View {
switch layout.contentOverlay {
case .none:
EmptyView()
case .avatar:
HStack {
profileAvatar
Spacer()
}
case .logoRectangle:
HStack {
logoRectangle
Spacer()
}
case .avatarAndLogo:
HStack {
profileAvatar
Spacer()
logoRectangle
}
}
}
private var placeholderTextLines: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
RoundedRectangle(cornerRadius: Design.CornerRadius.small) RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.hint)) .fill(Color.Text.tertiary.opacity(Design.Opacity.hint))
.frame(height: Design.Spacing.medium) .frame(height: Design.Spacing.medium)
@ -329,69 +311,12 @@ private struct LayoutPreviewCard: View {
.frame(height: Design.Spacing.small) .frame(height: Design.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xLarge) .padding(.trailing, Design.Spacing.xLarge)
Spacer()
} }
.padding(.horizontal, Design.Spacing.medium)
.padding(.top, Design.Spacing.medium)
.padding(.bottom, Design.Spacing.small)
} }
/// Logo badge for side-by-side layout // MARK: - Overlay Components
private func logoBadge(size: CGFloat) -> some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.padding(Design.Spacing.xSmall)
} else {
Image(systemName: "building.2")
.font(.system(size: size / 2.5))
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: size, height: size)
.background(theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
)
}
// MARK: - Helper Views private var profileAvatar: some View {
private var coverBackground: some View {
Group {
if let coverData = coverPhotoData, let uiImage = UIImage(data: coverData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
// Placeholder with "Cover" indicator
LinearGradient(
colors: [theme.primaryColor.opacity(Design.Opacity.light), theme.secondaryColor.opacity(Design.Opacity.light)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "photo.fill")
.font(.title2)
.foregroundStyle(Color.Text.tertiary)
Text("Cover")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
}
}
}
.clipped()
}
private func profileAvatar(size: CGFloat) -> some View {
Group { Group {
if let photoData, let uiImage = UIImage(data: photoData) { if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
@ -399,38 +324,73 @@ private struct LayoutPreviewCard: View {
.scaledToFill() .scaledToFill()
} else { } else {
Image(systemName: avatarSystemName) Image(systemName: avatarSystemName)
.font(.system(size: size / 2.5)) .font(.system(size: avatarSize / 2.5))
.foregroundStyle(theme.textColor) .foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.accentColor) .background(theme.accentColor)
} }
} }
.frame(width: size, height: size) .frame(width: avatarSize, height: avatarSize)
.clipShape(.circle) .clipShape(.circle)
.overlay( .overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium))
Circle()
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
)
} }
// MARK: - Body Content private var logoBadge: some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
VStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "building.2")
.font(.caption)
Text("Logo")
.font(.system(size: 8))
}
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: avatarSize, height: avatarSize)
.background(theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium))
}
private var logoRectangle: some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
VStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "building.2")
.font(.caption)
Text("Logo")
.font(.system(size: 8))
}
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: logoRectWidth, height: avatarSize)
.background(theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium))
}
// MARK: - Badges // MARK: - Badges
@ViewBuilder @ViewBuilder
private var badgeOverlay: some View { private var badgeOverlay: some View {
if needsMoreImages { if needsMoreImages {
LayoutBadge( LayoutBadge(text: "More images required", iconName: "lock.fill", backgroundColor: Color.Text.primary.opacity(Design.Opacity.strong))
text: "More images required",
iconName: "lock.fill",
backgroundColor: Color.Text.primary.opacity(Design.Opacity.strong)
)
} else if isSuggested && !isSelected { } else if isSuggested && !isSelected {
LayoutBadge( LayoutBadge(text: "Suggested", iconName: "star.fill", backgroundColor: Color.Badge.star)
text: "Suggested",
iconName: "star.fill",
backgroundColor: Color.Badge.star
)
} }
} }
} }