Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5e774cc778
commit
aed2f62918
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -371,6 +371,9 @@
|
|||||||
},
|
},
|
||||||
"Links" : {
|
"Links" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Logo" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Maiden Name" : {
|
"Maiden Name" : {
|
||||||
|
|
||||||
@ -506,6 +509,9 @@
|
|||||||
},
|
},
|
||||||
"Preview card" : {
|
"Preview card" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Profile" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Profile Link" : {
|
"Profile Link" : {
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user