From aed2f62918f6f1f50072cf4df03bfb4326b0aae1 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 9 Jan 2026 14:21:20 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard/Models/BusinessCard.swift | 13 +- BusinessCard/Models/CardHeaderLayout.swift | 239 ++++++++--- BusinessCard/Resources/Localizable.xcstrings | 6 + BusinessCard/Views/BusinessCardView.swift | 401 +++++++----------- BusinessCard/Views/CardEditorView.swift | 213 ++++++---- .../Components/HeaderLayoutPickerView.swift | 346 +++++++-------- 6 files changed, 619 insertions(+), 599 deletions(-) diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index 2beef4a..5aea122 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -374,7 +374,8 @@ final class BusinessCard { extension BusinessCard { @MainActor 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( displayName: "Daniel Sullivan", role: "Property Developer", @@ -383,7 +384,7 @@ extension BusinessCard { isDefault: true, themeName: "Coral", layoutStyleRawValue: "split", - headerLayoutRawValue: "coverWithCenteredLogo", + headerLayoutRawValue: "coverWithAvatarAndLogo", avatarSystemName: "person.crop.circle", pronouns: "he/him", bio: "Building the future of Dallas real estate" @@ -395,7 +396,8 @@ extension BusinessCard { sample1.addContactField(.address, value: "Dallas, TX", title: "Work") 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( displayName: "Maya Chen", role: "Creative Lead", @@ -404,7 +406,7 @@ extension BusinessCard { isDefault: false, themeName: "Midnight", layoutStyleRawValue: "stacked", - headerLayoutRawValue: "coverWithAvatar", + headerLayoutRawValue: "logoBanner", avatarSystemName: "sparkles", pronouns: "she/her", bio: "Designing experiences that matter" @@ -417,7 +419,8 @@ extension BusinessCard { sample2.addContactField(.twitter, value: "twitter.com/mayachen", 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( displayName: "DJ Michaels", role: "DJ", diff --git a/BusinessCard/Models/CardHeaderLayout.swift b/BusinessCard/Models/CardHeaderLayout.swift index 7d04453..185f648 100644 --- a/BusinessCard/Models/CardHeaderLayout.swift +++ b/BusinessCard/Models/CardHeaderLayout.swift @@ -1,41 +1,95 @@ 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. +/// These 8 layouts match the reference app exactly. 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 - /// Cover image as banner with profile avatar overlapping at bottom-left. - /// Best when: User has both cover and profile photos, no logo. + /// 2. Logo (3:2 landscape) fills the entire banner area, no overlay. + /// 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 - /// Cover image with company logo centered in banner, profile avatar overlapping below. - /// Best when: User has all three images and wants logo prominently displayed. - case coverWithCenteredLogo + /// 7. Cover photo fills banner, logo rectangle overlaps in content. + /// Banner: Cover | Overlay: Logo Rectangle + case coverWithLogo - /// Cover image with small logo badge in corner, profile avatar overlapping. - /// Best when: User wants subtle logo presence with prominent avatar. - case coverWithLogoBadge - - /// 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 + /// 8. Cover photo fills banner, avatar and logo side-by-side overlap in content. + /// Banner: Cover | Overlay: Avatar + Logo + case coverWithAvatarAndLogo + // MARK: - Identifiable + var id: String { rawValue } + // MARK: - Display Properties + var displayName: String { switch self { case .profileBanner: 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: return String.localized("Cover + Avatar") - case .coverWithCenteredLogo: - return String.localized("Centered Logo") - case .coverWithLogoBadge: - return String.localized("Logo Badge") - case .avatarAndLogoSideBySide: - return String.localized("Side by Side") + case .coverWithLogo: + return String.localized("Cover + Logo") + case .coverWithAvatarAndLogo: + return String.localized("Cover + Both") } } @@ -43,14 +97,20 @@ enum CardHeaderLayout: String, CaseIterable, Identifiable, Hashable, Sendable { switch self { case .profileBanner: 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: return String.localized("Cover with avatar overlay") - case .coverWithCenteredLogo: - return String.localized("Logo centered, avatar below") - case .coverWithLogoBadge: - return String.localized("Small logo badge in corner") - case .avatarAndLogoSideBySide: - return String.localized("Avatar and logo together") + case .coverWithLogo: + return String.localized("Cover with logo overlay") + case .coverWithAvatarAndLogo: + return String.localized("Cover with avatar and logo") } } @@ -59,68 +119,127 @@ enum CardHeaderLayout: String, CaseIterable, Identifiable, Hashable, Sendable { switch self { case .profileBanner: 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: return "person.crop.rectangle.stack.fill" - case .coverWithCenteredLogo: - return "building.2.fill" - case .coverWithLogoBadge: - return "person.crop.square.badge.camera" - case .avatarAndLogoSideBySide: + case .coverWithLogo: + return "rectangle.stack.fill" + case .coverWithAvatarAndLogo: 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 { + bannerContent == .cover + } + + /// Whether this layout requires a company logo. + var requiresLogo: Bool { switch self { - case .profileBanner: - return false - case .coverWithAvatar, .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide: + case .logoBanner, .profileWithLogoBadge, .logoWithAvatar, .coverWithLogo, .coverWithAvatarAndLogo: return true + case .profileBanner, .coverOnly, .coverWithAvatar: + return false } } - /// Whether this layout benefits from a company logo. - var benefitsFromLogo: Bool { + /// Whether this layout requires a profile photo. + var requiresProfilePhoto: Bool { switch self { - case .profileBanner, .coverWithAvatar: - return false - case .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide: + case .profileBanner, .profileWithLogoBadge: return true + case .logoBanner, .coverOnly, .logoWithAvatar, .coverWithAvatar, .coverWithLogo, .coverWithAvatarAndLogo: + return false } } - /// Whether this layout shows the avatar in the content area (overlapping from banner). - var showsAvatarInContent: Bool { - switch self { - case .profileBanner: - return false - case .coverWithAvatar, .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide: - return true + /// Returns images that are missing for this layout to render properly. + func missingImages(hasProfile: Bool, hasCover: Bool, hasLogo: Bool) -> [String] { + var missing: [String] = [] + + if requiresCoverPhoto && !hasCover { + missing.append(String.localized("Cover")) } + 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. - /// This is a pure function that doesn't require actor isolation. nonisolated static func suggested(hasProfile: Bool, hasCover: Bool, hasLogo: Bool) -> CardHeaderLayout { switch (hasCover, hasLogo, hasProfile) { case (true, true, true): - // All images available - use centered logo layout - return .coverWithCenteredLogo + return .coverWithAvatarAndLogo case (true, true, false): - // Cover and logo but no profile - return .coverWithCenteredLogo + return .coverWithLogo case (true, false, true): - // Cover and profile - show cover with avatar overlay return .coverWithAvatar - case (false, _, true): - // Only profile - make it the banner - return .profileBanner case (true, false, false): - // Only cover - still use cover with avatar (will show placeholder) - return .coverWithAvatar - default: - // Default fallback + return .coverOnly + case (false, true, true): + return .logoWithAvatar + case (false, true, false): + return .logoBanner + case (false, false, true): + return .profileBanner + case (false, false, false): return .profileBanner } } diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index a8ac083..d346410 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -371,6 +371,9 @@ }, "Links" : { + }, + "Logo" : { + }, "Maiden Name" : { @@ -506,6 +509,9 @@ }, "Preview card" : { + }, + "Profile" : { + }, "Profile Link" : { diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index 0f949e3..f60a643 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -6,20 +6,17 @@ struct BusinessCardView: View { let card: BusinessCard var isCompact: Bool = false - /// Whether the current header layout includes an avatar that overlaps from banner to content - private var hasAvatarOverlap: Bool { - card.headerLayout.showsAvatarInContent + private var hasOverlappingContent: Bool { + card.headerLayout.hasOverlappingContent } var body: some View { VStack(spacing: 0) { - // Banner with logo CardBannerView(card: card) - // Content area with avatar overlapping (conditional based on layout) CardContentView(card: card, isCompact: isCompact) - .offset(y: hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0) - .padding(.bottom, hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0) + .offset(y: hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0) + .padding(.bottom, hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0) } .background(Color.AppBackground.elevated) .clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge)) @@ -42,162 +39,112 @@ private struct CardBannerView: View { var body: some View { Group { - switch card.headerLayout { - case .profileBanner: - ProfileBannerLayout(card: card) - case .coverWithAvatar: - CoverWithAvatarBannerLayout(card: card) - case .coverWithCenteredLogo: - CoverWithCenteredLogoLayout(card: card) - case .coverWithLogoBadge: - CoverWithLogoBadgeLayout(card: card) - case .avatarAndLogoSideBySide: - CoverOnlyBannerLayout(card: card) + 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 Layout +// MARK: - Profile Banner Content -/// Profile photo fills the entire banner -private struct ProfileBannerLayout: View { +private struct ProfileBannerContent: View { let card: BusinessCard var body: some View { ZStack { - // Background: profile photo or cover photo or gradient if let photoData = card.photoData, let uiImage = UIImage(data: photoData) { Image(uiImage: uiImage) .resizable() .scaledToFill() .clipped() - } else if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - .clipped() } else { - // Fallback gradient LinearGradient( colors: [card.theme.primaryColor, card.theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing ) - } - - // Company logo overlay (if no profile photo) - if card.photoData == nil { - if let logoData = card.logoData, let uiImage = UIImage(data: logoData) { - Image(uiImage: uiImage) - .resizable() - .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)) + + 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: - Cover With Avatar Banner Layout +// MARK: - Logo Banner Content -/// Cover image as banner, avatar overlaps into content area -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 { +private struct LogoBannerContent: View { let card: BusinessCard var body: some View { 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) { Image(uiImage: uiImage) .resizable() - .scaledToFit() - .frame(height: Design.CardSize.logoSize) - } else if !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)) - } - } - } -} - -// 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) - } + .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 Only Banner Layout +// MARK: - Cover Banner Content -/// Cover image only (for side-by-side layout where avatar/logo are in content) -private struct CoverOnlyBannerLayout: View { +private struct CoverBannerContent: View { let card: BusinessCard var body: some View { - coverBackground(for: card) - } -} - -// MARK: - Helper Function - -/// Shared cover background view -@ViewBuilder -private func coverBackground(for card: BusinessCard) -> some View { - if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - .clipped() - } else { - LinearGradient( - colors: [card.theme.primaryColor, card.theme.secondaryColor], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + 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)) + } + } } } @@ -209,31 +156,10 @@ private struct CardContentView: View { 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 { VStack(alignment: .leading, spacing: Design.Spacing.medium) { - // Avatar row (conditional based on layout) - 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) - } - } - } + contentOverlay - // Name and title VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { HStack(spacing: Design.Spacing.xSmall) { Text(card.effectiveDisplayName) @@ -268,13 +194,36 @@ private struct CardContentView: View { Divider() .padding(.vertical, Design.Spacing.xSmall) - // Contact fields from array (preferred) or legacy properties 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 @@ -298,22 +247,13 @@ private struct ProfileAvatarView: View { } .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 - ) + .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 -/// Logo displayed as a rounded rectangle badge (for side-by-side layout) private struct LogoBadgeView: View { let card: BusinessCard @@ -322,28 +262,57 @@ private struct LogoBadgeView: View { if let logoData = card.logoData, let uiImage = UIImage(data: logoData) { Image(uiImage: uiImage) .resizable() - .scaledToFit() - .padding(Design.Spacing.small) + .scaledToFill() + .clipped() } else { - Image(systemName: "building.2") - .font(.system(size: Design.BaseFontSize.title)) - .foregroundStyle(card.theme.textColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) + 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.logoSize, height: Design.CardSize.logoSize) + .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 - ) + .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) } } @@ -355,7 +324,6 @@ private struct ContactFieldsListView: View { var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.small) { - // New contact fields array (preferred) ForEach(card.orderedContactFields) { field in ContactFieldRowView(field: field) { if let url = field.buildURL() { @@ -363,12 +331,10 @@ private struct ContactFieldsListView: View { } } } - } } } -/// A clickable row for a contact field private struct ContactFieldRowView: View { let field: ContactField let action: () -> Void @@ -376,7 +342,6 @@ private struct ContactFieldRowView: View { var body: some View { Button(action: action) { HStack(alignment: .top, spacing: Design.Spacing.medium) { - // Icon with brand color field.iconImage() .font(.body) .foregroundStyle(.white) @@ -385,13 +350,11 @@ private struct ContactFieldRowView: View { .clipShape(.circle) VStack(alignment: .leading, spacing: 0) { - // Value (uses displayValue for formatted output, e.g., multi-line addresses) Text(field.displayValue) .font(.subheadline) .foregroundStyle(Color.Text.primary) .multilineTextAlignment(.leading) - // Title/Label Text(field.title.isEmpty ? field.displayName : field.title) .font(.caption) .foregroundStyle(Color.Text.secondary) @@ -400,7 +363,6 @@ private struct ContactFieldRowView: View { Spacer() - // Action indicator Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Color.Text.tertiary) @@ -413,87 +375,30 @@ private struct ContactFieldRowView: View { } } -// MARK: - Preview +// MARK: - Previews -#Preview("Cover + Avatar Layout") { - @Previewable @State var card: BusinessCard = { - let card = BusinessCard( - displayName: "Matt Bruce", - role: "Lead iOS Developer", - company: "Toyota", - themeName: "Coral", - 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 - }() +#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("Profile Banner Layout") { - @Previewable @State var card: BusinessCard = { - let card = BusinessCard( - displayName: "Matt Bruce", - role: "Lead iOS Developer", - company: "Toyota", - themeName: "Ocean", - 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 - }() +#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() diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index e9a07ac..9ea6dc0 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -523,14 +523,33 @@ private struct ImageLayoutRow: View { let onSelectImage: (CardEditorView.ImageType) -> Void let onSelectLayout: () -> Void - /// Whether the selected layout shows avatar in content area - private var showsAvatarInContent: Bool { - selectedHeaderLayout.showsAvatarInContent + /// Whether the selected layout has overlapping content + private var hasOverlappingContent: Bool { + selectedHeaderLayout.hasOverlappingContent } - /// Whether the selected layout is side-by-side - private var isSideBySideLayout: Bool { - selectedHeaderLayout == .avatarAndLogoSideBySide + @ViewBuilder + private var overlayContent: some View { + 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 { @@ -547,27 +566,13 @@ private struct ImageLayoutRow: View { selectedHeaderLayout: selectedHeaderLayout ) - // Avatar overlay (for layouts that show avatar in content) - if showsAvatarInContent { - HStack(spacing: Design.Spacing.small) { - ProfilePhotoView( - 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) + // Overlay content based on layout + if hasOverlappingContent { + overlayContent + .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 Button(action: onSelectLayout) { @@ -624,17 +629,13 @@ private struct EditorBannerPreviewView: View { var body: some View { Group { - switch selectedHeaderLayout { - case .profileBanner: + switch selectedHeaderLayout.bannerContent { + case .profile: profileBannerPreview - case .coverWithAvatar: - coverOnlyPreview - case .coverWithCenteredLogo: - coverWithCenteredLogoPreview - case .coverWithLogoBadge: - coverWithLogoBadgePreview - case .avatarAndLogoSideBySide: - coverOnlyPreview + case .logo: + logoBannerPreview + case .cover: + coverBannerPreview } } .frame(height: Design.CardSize.bannerHeight) @@ -651,73 +652,46 @@ private struct EditorBannerPreviewView: View { .resizable() .scaledToFill() .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) .resizable() .scaledToFill() .clipped() } else { - themeGradient - } - - // Show logo if no profile photo - if photoData == nil { - if let logoData, let uiImage = UIImage(data: logoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .frame(height: Design.CardSize.logoSize) + VStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "building.2.fill") + .font(.system(size: Design.BaseFontSize.display, weight: .bold)) + Text("Logo") + .font(.title3) + .bold() } + .foregroundStyle(selectedTheme.textColor.opacity(Design.Opacity.medium)) } } } - /// Cover image only (avatar overlaps into content) - private var coverOnlyPreview: 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 { + /// Cover photo fills the banner + private var coverBannerPreview: some View { Group { if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { Image(uiImage: uiImage) @@ -725,11 +699,24 @@ private struct EditorBannerPreviewView: View { .scaledToFill() .clipped() } 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 { LinearGradient( colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], @@ -739,6 +726,7 @@ private struct EditorBannerPreviewView: View { } } + // MARK: - Editor Logo Badge View /// 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 private struct ImageActionButtonsRow: View { diff --git a/BusinessCard/Views/Components/HeaderLayoutPickerView.swift b/BusinessCard/Views/Components/HeaderLayoutPickerView.swift index f4158ce..03914d8 100644 --- a/BusinessCard/Views/Components/HeaderLayoutPickerView.swift +++ b/BusinessCard/Views/Components/HeaderLayoutPickerView.swift @@ -51,7 +51,6 @@ struct HeaderLayoutPickerView: View { var body: some View { NavigationStack { VStack(spacing: 0) { - // Layout carousel ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Design.Spacing.large) { ForEach(CardHeaderLayout.allCases) { layout in @@ -63,10 +62,7 @@ struct HeaderLayoutPickerView: View { coverPhotoData: coverPhotoData, logoData: logoData, avatarSystemName: avatarSystemName, - theme: theme, - displayName: displayName, - role: role, - company: company + theme: theme ) { withAnimation(.snappy(duration: Design.Animation.quick)) { currentLayout = layout @@ -81,7 +77,6 @@ struct HeaderLayoutPickerView: View { Spacer() - // Confirm button Button { selectedLayout = currentLayout dismiss() @@ -126,68 +121,48 @@ private struct LayoutPreviewCard: View { let logoData: Data? let avatarSystemName: String let theme: CardTheme - let displayName: String - let role: String - let company: String let onSelect: () -> Void - // Layout constants private let cardWidth: CGFloat = 200 private let cardHeight: CGFloat = 280 private let bannerHeight: CGFloat = 100 private let avatarSize: CGFloat = 56 - private let avatarSmall: CGFloat = 44 - private let logoSize: CGFloat = 48 + private let logoRectWidth: CGFloat = 84 // 56 * 1.5 aspect ratio private var needsMoreImages: Bool { - (layout.requiresCoverPhoto && coverPhotoData == nil) || - (layout.benefitsFromLogo && logoData == nil) + !layout.hasAllRequiredImages( + hasProfile: photoData != nil, + hasCover: coverPhotoData != nil, + hasLogo: logoData != nil + ) } - /// Whether avatar overlaps from banner to content - private var showsAvatarInContent: Bool { - layout.showsAvatarInContent - } - - /// Whether this is the side-by-side layout - private var isSideBySideLayout: Bool { - layout == .avatarAndLogoSideBySide + private var hasOverlappingContent: Bool { + layout.hasOverlappingContent } var body: some View { Button(action: onSelect) { VStack(spacing: 0) { - // Badge overlay ZStack(alignment: .top) { - // Card preview VStack(spacing: 0) { - // Banner (just the background, no overlapping elements) bannerContent .frame(height: bannerHeight) .clipped() - // Content area with overlapping avatar - contentWithAvatar - .offset(y: showsAvatarInContent ? -avatarSize / 2 : 0) - .padding(.bottom, showsAvatarInContent ? -avatarSize / 2 : 0) + contentArea + .offset(y: hasOverlappingContent ? -avatarSize / 2 : 0) + .padding(.bottom, hasOverlappingContent ? -avatarSize / 2 : 0) } .frame(width: cardWidth, height: cardHeight) .background(Color.AppBackground.elevated) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .stroke( - 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 + .stroke(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) - // Badges badgeOverlay .offset(y: -Design.Spacing.small) } @@ -200,118 +175,125 @@ private struct LayoutPreviewCard: View { .accessibilityAddTraits(isSelected ? [.isSelected] : []) } - // MARK: - Banner Content (no avatar overlay - that's in content area) + // MARK: - Banner Content @ViewBuilder private var bannerContent: some View { - switch layout { - case .profileBanner: - profileBannerContent - case .coverWithAvatar: - coverBackground - case .coverWithCenteredLogo: - coverWithCenteredLogoContent - case .coverWithLogoBadge: - coverWithLogoBadgeContent - case .avatarAndLogoSideBySide: - coverBackground + switch layout.bannerContent { + case .profile: + profileBannerPreview + case .logo: + logoBannerPreview + case .cover: + coverBannerPreview } } - /// Profile photo fills the entire banner - private var profileBannerContent: some View { + private var profileBannerPreview: some View { ZStack { if let photoData, let uiImage = UIImage(data: photoData) { Image(uiImage: uiImage) .resizable() .scaledToFill() } 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) - .font(.system(size: Design.BaseFontSize.display)) - .foregroundStyle(theme.textColor.opacity(Design.Opacity.medium)) + VStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: "person.fill") + .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 coverWithCenteredLogoContent: some View { + private var logoBannerPreview: some View { ZStack { - coverBackground + LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing) - // Logo centered if let logoData, let uiImage = UIImage(data: logoData) { Image(uiImage: uiImage) .resizable() - .scaledToFit() - .frame(height: logoSize) + .scaledToFill() } else { - // Logo placeholder - RoundedRectangle(cornerRadius: Design.CornerRadius.small) - .fill(theme.accentColor) - .frame(width: logoSize, height: logoSize) - .overlay( - Image(systemName: "building.2") - .font(.title2) - .foregroundStyle(theme.textColor) - ) + VStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: "building.2.fill") + .font(.system(size: Design.BaseFontSize.title)) + Text("Logo") + .font(.caption) + .bold() + } + .foregroundStyle(theme.textColor.opacity(Design.Opacity.medium)) } } } - /// Cover image with small logo badge in corner - private var coverWithLogoBadgeContent: some View { - ZStack(alignment: .bottomTrailing) { - coverBackground - - // Logo badge in corner - if let logoData, let uiImage = UIImage(data: logoData) { + private var coverBannerPreview: some View { + Group { + if let coverData = coverPhotoData, let uiImage = UIImage(data: coverData) { Image(uiImage: uiImage) .resizable() - .scaledToFit() - .frame(height: logoSize / 1.5) - .padding(Design.Spacing.xSmall) - .background(.ultraThinMaterial) - .clipShape(.rect(cornerRadius: Design.CornerRadius.small)) - .padding(Design.Spacing.xSmall) + .scaledToFill() } else { - RoundedRectangle(cornerRadius: Design.CornerRadius.small) - .fill(theme.accentColor) - .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) + ZStack { + LinearGradient(colors: [theme.primaryColor.opacity(Design.Opacity.light), theme.secondaryColor.opacity(Design.Opacity.light)], startPoint: .topLeading, endPoint: .bottomTrailing) - // Side-by-side: show logo badge next to avatar - if isSideBySideLayout { - logoBadge(size: avatarSize) + VStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: "photo.fill") + .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) .fill(Color.Text.tertiary.opacity(Design.Opacity.hint)) .frame(height: Design.Spacing.medium) @@ -329,69 +311,12 @@ private struct LayoutPreviewCard: View { .frame(height: Design.Spacing.small) .frame(maxWidth: .infinity, alignment: .leading) .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 - 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: - Overlay Components - // MARK: - Helper Views - - 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 { + private var profileAvatar: some View { Group { if let photoData, let uiImage = UIImage(data: photoData) { Image(uiImage: uiImage) @@ -399,38 +324,73 @@ private struct LayoutPreviewCard: View { .scaledToFill() } else { Image(systemName: avatarSystemName) - .font(.system(size: size / 2.5)) + .font(.system(size: avatarSize / 2.5)) .foregroundStyle(theme.textColor) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(theme.accentColor) } } - .frame(width: size, height: size) + .frame(width: avatarSize, height: avatarSize) .clipShape(.circle) - .overlay( - Circle() - .stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium) - ) + .overlay(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 @ViewBuilder private var badgeOverlay: some View { if needsMoreImages { - LayoutBadge( - text: "More images required", - iconName: "lock.fill", - backgroundColor: Color.Text.primary.opacity(Design.Opacity.strong) - ) + LayoutBadge(text: "More images required", iconName: "lock.fill", backgroundColor: Color.Text.primary.opacity(Design.Opacity.strong)) } else if isSuggested && !isSelected { - LayoutBadge( - text: "Suggested", - iconName: "star.fill", - backgroundColor: Color.Badge.star - ) + LayoutBadge(text: "Suggested", iconName: "star.fill", backgroundColor: Color.Badge.star) } } }