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

This commit is contained in:
Matt Bruce 2026-01-09 13:22:30 -06:00
parent dff3e51b61
commit 5e774cc778
12 changed files with 1731 additions and 101 deletions

View File

@ -31,6 +31,8 @@ extension Design {
static let floatingButtonSize: CGFloat = 56 static let floatingButtonSize: CGFloat = 56
/// Bottom offset for floating button above tab bar. /// Bottom offset for floating button above tab bar.
static let floatingButtonBottomOffset: CGFloat = 72 static let floatingButtonBottomOffset: CGFloat = 72
/// Aspect ratio for logo container (3:2 landscape).
static let logoContainerAspectRatio: CGFloat = 3.0 / 2.0
} }
} }

View File

@ -12,6 +12,7 @@ final class BusinessCard {
var isDefault: Bool var isDefault: Bool
var themeName: String var themeName: String
var layoutStyleRawValue: String var layoutStyleRawValue: String
var headerLayoutRawValue: String
var avatarSystemName: String var avatarSystemName: String
var createdAt: Date var createdAt: Date
var updatedAt: Date var updatedAt: Date
@ -52,6 +53,7 @@ final class BusinessCard {
isDefault: Bool = false, isDefault: Bool = false,
themeName: String = "Coral", themeName: String = "Coral",
layoutStyleRawValue: String = "stacked", layoutStyleRawValue: String = "stacked",
headerLayoutRawValue: String = "profileBanner",
avatarSystemName: String = "person.crop.circle", avatarSystemName: String = "person.crop.circle",
createdAt: Date = .now, createdAt: Date = .now,
updatedAt: Date = .now, updatedAt: Date = .now,
@ -79,6 +81,7 @@ final class BusinessCard {
self.isDefault = isDefault self.isDefault = isDefault
self.themeName = themeName self.themeName = themeName
self.layoutStyleRawValue = layoutStyleRawValue self.layoutStyleRawValue = layoutStyleRawValue
self.headerLayoutRawValue = headerLayoutRawValue
self.avatarSystemName = avatarSystemName self.avatarSystemName = avatarSystemName
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
@ -109,6 +112,29 @@ final class BusinessCard {
get { CardLayoutStyle(rawValue: layoutStyleRawValue) ?? .stacked } get { CardLayoutStyle(rawValue: layoutStyleRawValue) ?? .stacked }
set { layoutStyleRawValue = newValue.rawValue } set { layoutStyleRawValue = newValue.rawValue }
} }
var headerLayout: CardHeaderLayout {
get { CardHeaderLayout(rawValue: headerLayoutRawValue) ?? .profileBanner }
set { headerLayoutRawValue = newValue.rawValue }
}
/// Returns true if the card has a profile photo.
var hasProfilePhoto: Bool { photoData != nil }
/// Returns true if the card has a cover photo.
var hasCoverPhoto: Bool { coverPhotoData != nil }
/// Returns true if the card has a company logo.
var hasLogo: Bool { logoData != nil }
/// Returns the suggested header layout based on available images.
var suggestedHeaderLayout: CardHeaderLayout {
CardHeaderLayout.suggested(
hasProfile: hasProfilePhoto,
hasCover: hasCoverPhoto,
hasLogo: hasLogo
)
}
var shareURL: URL { var shareURL: URL {
let base = URL(string: "https://cards.example") ?? URL.documentsDirectory let base = URL(string: "https://cards.example") ?? URL.documentsDirectory
@ -348,7 +374,7 @@ 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 // Sample 1: Property Developer - Uses coverWithCenteredLogo layout
let sample1 = BusinessCard( let sample1 = BusinessCard(
displayName: "Daniel Sullivan", displayName: "Daniel Sullivan",
role: "Property Developer", role: "Property Developer",
@ -357,6 +383,7 @@ extension BusinessCard {
isDefault: true, isDefault: true,
themeName: "Coral", themeName: "Coral",
layoutStyleRawValue: "split", layoutStyleRawValue: "split",
headerLayoutRawValue: "coverWithCenteredLogo",
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"
@ -368,7 +395,7 @@ 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 // Sample 2: Creative Lead - Uses coverWithAvatar layout
let sample2 = BusinessCard( let sample2 = BusinessCard(
displayName: "Maya Chen", displayName: "Maya Chen",
role: "Creative Lead", role: "Creative Lead",
@ -377,6 +404,7 @@ extension BusinessCard {
isDefault: false, isDefault: false,
themeName: "Midnight", themeName: "Midnight",
layoutStyleRawValue: "stacked", layoutStyleRawValue: "stacked",
headerLayoutRawValue: "coverWithAvatar",
avatarSystemName: "sparkles", avatarSystemName: "sparkles",
pronouns: "she/her", pronouns: "she/her",
bio: "Designing experiences that matter" bio: "Designing experiences that matter"
@ -389,7 +417,7 @@ 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 // Sample 3: DJ - Uses profileBanner layout (profile photo as banner)
let sample3 = BusinessCard( let sample3 = BusinessCard(
displayName: "DJ Michaels", displayName: "DJ Michaels",
role: "DJ", role: "DJ",
@ -398,6 +426,7 @@ extension BusinessCard {
isDefault: false, isDefault: false,
themeName: "Ocean", themeName: "Ocean",
layoutStyleRawValue: "photo", layoutStyleRawValue: "photo",
headerLayoutRawValue: "profileBanner",
avatarSystemName: "music.mic", avatarSystemName: "music.mic",
bio: "Bringing the beats to your events" bio: "Bringing the beats to your events"
) )

View File

@ -0,0 +1,127 @@
import Foundation
/// Defines how the business card header arranges profile, cover, and logo images.
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.
case profileBanner
/// Cover image as banner with profile avatar overlapping at bottom-left.
/// Best when: User has both cover and profile photos, no logo.
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
/// 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
var id: String { rawValue }
var displayName: String {
switch self {
case .profileBanner:
return String.localized("Profile Banner")
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")
}
}
var description: String {
switch self {
case .profileBanner:
return String.localized("Your photo fills the banner")
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")
}
}
/// Icon for the layout selector
var iconName: String {
switch self {
case .profileBanner:
return "person.crop.rectangle.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:
return "rectangle.split.2x1.fill"
}
}
/// Whether this layout requires a cover photo to look good.
var requiresCoverPhoto: Bool {
switch self {
case .profileBanner:
return false
case .coverWithAvatar, .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide:
return true
}
}
/// Whether this layout benefits from a company logo.
var benefitsFromLogo: Bool {
switch self {
case .profileBanner, .coverWithAvatar:
return false
case .coverWithCenteredLogo, .coverWithLogoBadge, .avatarAndLogoSideBySide:
return true
}
}
/// 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 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
case (true, true, false):
// Cover and logo but no profile
return .coverWithCenteredLogo
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 .profileBanner
}
}
}

View File

@ -106,6 +106,9 @@
}, },
"Calendly Link" : { "Calendly Link" : {
},
"Cancel" : {
}, },
"Card Found!" : { "Card Found!" : {
@ -138,6 +141,9 @@
}, },
"Cell" : { "Cell" : {
},
"Change background color" : {
}, },
"Change image layout" : { "Change image layout" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -164,18 +170,27 @@
}, },
"Choose a card in the My Cards tab to start sharing." : { "Choose a card in the My Cards tab to start sharing." : {
},
"Choose a layout" : {
}, },
"Choose your color" : { "Choose your color" : {
}, },
"City" : { "City" : {
},
"Color swatch" : {
}, },
"Company" : { "Company" : {
}, },
"Company Website" : { "Company Website" : {
},
"Confirm layout" : {
}, },
"Connection details" : { "Connection details" : {
@ -188,6 +203,9 @@
}, },
"Country (optional)" : { "Country (optional)" : {
},
"Cover" : {
}, },
"Create multiple business cards" : { "Create multiple business cards" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -214,6 +232,9 @@
}, },
"Create your first card" : { "Create your first card" : {
},
"Custom color" : {
}, },
"Customize your card" : { "Customize your card" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -252,6 +273,9 @@
}, },
"Developer" : { "Developer" : {
},
"Done" : {
}, },
"Drag to reorder. Swipe to delete." : { "Drag to reorder. Swipe to delete." : {
@ -264,6 +288,9 @@
}, },
"Edit %@" : { "Edit %@" : {
},
"Edit company logo" : {
}, },
"Email" : { "Email" : {
@ -273,6 +300,12 @@
}, },
"First Name" : { "First Name" : {
},
"Header Layout" : {
},
"Header Layout: %@" : {
}, },
"Headline" : { "Headline" : {
@ -519,6 +552,12 @@
}, },
"Scheduling" : { "Scheduling" : {
},
"Select a color" : {
},
"Selected" : {
}, },
"Share card offline" : { "Share card offline" : {
@ -585,6 +624,9 @@
}, },
"Suffix (e.g. Jr., III)" : { "Suffix (e.g. Jr., III)" : {
},
"Suggested" : {
}, },
"Support & Funding" : { "Support & Funding" : {
@ -594,6 +636,9 @@
}, },
"Tap a field below to add it" : { "Tap a field below to add it" : {
},
"Tap to choose how images appear in the card header" : {
}, },
"Tap to share" : { "Tap to share" : {
"localizations" : { "localizations" : {
@ -748,6 +793,9 @@
}, },
"ZIP Code" : { "ZIP Code" : {
},
"Zoom in/out" : {
} }
}, },
"version" : "1.1" "version" : "1.1"

View File

@ -0,0 +1,141 @@
import SwiftUI
import UIKit
extension UIImage {
/// Extracts dominant colors from the image using pixel sampling.
/// - Parameter count: Number of colors to extract (default 3)
/// - Returns: Array of SwiftUI Colors, always includes white and black as fallbacks
func dominantColors(count: Int = 3) -> [Color] {
guard let cgImage = self.cgImage else {
return defaultColors(count: count)
}
let width = cgImage.width
let height = cgImage.height
guard width > 0, height > 0 else {
return defaultColors(count: count)
}
// Create a smaller sample size for performance
let sampleSize = 50
let scaleX = max(1, width / sampleSize)
let scaleY = max(1, height / sampleSize)
guard let context = CGContext(
data: nil,
width: sampleSize,
height: sampleSize,
bitsPerComponent: 8,
bytesPerRow: sampleSize * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return defaultColors(count: count)
}
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: sampleSize, height: sampleSize))
guard let pixelData = context.data else {
return defaultColors(count: count)
}
let data = pixelData.bindMemory(to: UInt8.self, capacity: sampleSize * sampleSize * 4)
// Collect colors with their frequencies
var colorCounts: [ColorBucket: Int] = [:]
for y in 0..<sampleSize {
for x in 0..<sampleSize {
let offset = (y * sampleSize + x) * 4
let r = data[offset]
let g = data[offset + 1]
let b = data[offset + 2]
let a = data[offset + 3]
// Skip transparent or near-transparent pixels
guard a > 128 else { continue }
// Skip near-white and near-black (we'll add those as defaults)
let brightness = (Int(r) + Int(g) + Int(b)) / 3
guard brightness > 30 && brightness < 225 else { continue }
// Bucket colors to reduce noise (group similar colors)
let bucket = ColorBucket(
r: UInt8((Int(r) / 32) * 32),
g: UInt8((Int(g) / 32) * 32),
b: UInt8((Int(b) / 32) * 32)
)
colorCounts[bucket, default: 0] += 1
}
}
// Sort by frequency and take top colors
let sortedColors = colorCounts.sorted { $0.value > $1.value }
var extractedColors: [Color] = []
// Add top colors, ensuring they're distinct
for (bucket, _) in sortedColors {
let color = Color(
red: Double(bucket.r) / 255.0,
green: Double(bucket.g) / 255.0,
blue: Double(bucket.b) / 255.0
)
// Check if this color is distinct enough from existing ones
if !extractedColors.contains(where: { $0.isClose(to: color) }) {
extractedColors.append(color)
}
if extractedColors.count >= count - 1 {
break
}
}
// Always include white as first option
var result: [Color] = [.white]
result.append(contentsOf: extractedColors)
// Add black if we have room
if result.count < count {
result.append(.black)
}
return Array(result.prefix(count))
}
private func defaultColors(count: Int) -> [Color] {
let defaults: [Color] = [.white, .black, Color(red: 0.2, green: 0.2, blue: 0.2)]
return Array(defaults.prefix(count))
}
}
// MARK: - Color Bucket for grouping similar colors
private struct ColorBucket: Hashable {
let r: UInt8
let g: UInt8
let b: UInt8
}
// MARK: - Color Comparison Extension
private extension Color {
/// Checks if two colors are visually similar.
func isClose(to other: Color, threshold: Double = 0.15) -> Bool {
guard let selfComponents = self.cgColor?.components,
let otherComponents = other.cgColor?.components,
selfComponents.count >= 3,
otherComponents.count >= 3 else {
return false
}
let rDiff = abs(selfComponents[0] - otherComponents[0])
let gDiff = abs(selfComponents[1] - otherComponents[1])
let bDiff = abs(selfComponents[2] - otherComponents[2])
return rDiff < threshold && gDiff < threshold && bDiff < threshold
}
}

View File

@ -6,15 +6,20 @@ 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 hasAvatarOverlap: Bool {
card.headerLayout.showsAvatarInContent
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Banner with logo // Banner with logo
CardBannerView(card: card) CardBannerView(card: card)
// Content area with avatar overlapping // Content area with avatar overlapping (conditional based on layout)
CardContentView(card: card, isCompact: isCompact) CardContentView(card: card, isCompact: isCompact)
.offset(y: -Design.CardSize.avatarOverlap) .offset(y: hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0)
.padding(.bottom, -Design.CardSize.avatarOverlap) .padding(.bottom, hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0)
} }
.background(Color.AppBackground.elevated) .background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge)) .clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
@ -35,14 +40,43 @@ struct BusinessCardView: View {
private struct CardBannerView: View { private struct CardBannerView: View {
let card: BusinessCard let card: BusinessCard
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)
}
}
.frame(height: Design.CardSize.bannerHeight)
}
}
// MARK: - Profile Banner Layout
/// Profile photo fills the entire banner
private struct ProfileBannerLayout: View {
let card: BusinessCard
var body: some View { var body: some View {
ZStack { ZStack {
// Background: cover photo or gradient // Background: profile photo or cover photo or gradient
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { 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) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(height: Design.CardSize.bannerHeight)
.clipped() .clipped()
} else { } else {
// Fallback gradient // Fallback gradient
@ -53,20 +87,117 @@ private struct CardBannerView: View {
) )
} }
// Company logo overlay // 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))
}
}
}
}
}
// MARK: - Cover With Avatar Banner Layout
/// 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 {
let card: BusinessCard
var body: some View {
ZStack {
coverBackground(for: card)
// 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() .scaledToFit()
.frame(height: Design.CardSize.logoSize) .frame(height: Design.CardSize.logoSize)
} else if card.coverPhotoData == nil && !card.company.isEmpty { } else if !card.company.isEmpty {
// Only show company initial if no cover photo and no logo
Text(card.company.prefix(1).uppercased()) Text(card.company.prefix(1).uppercased())
.font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded)) .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))
} }
} }
.frame(height: Design.CardSize.bannerHeight) }
}
// 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)
}
}
}
}
}
}
// MARK: - Cover Only Banner Layout
/// Cover image only (for side-by-side layout where avatar/logo are in content)
private struct CoverOnlyBannerLayout: 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
)
} }
} }
@ -78,14 +209,28 @@ 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 and label row // Avatar row (conditional based on layout)
HStack(alignment: .bottom) { if showsAvatarInContent {
ProfileAvatarView(card: card) HStack(alignment: .bottom, spacing: Design.Spacing.small) {
Spacer() ProfileAvatarView(card: card)
LabelBadgeView(label: card.label, accentColor: card.theme.accentColor, textColor: card.theme.textColor)
.padding(.bottom, Design.CardSize.avatarOverlap) // Side-by-side: show logo next to avatar
if isSideBySideLayout {
LogoBadgeView(card: card)
}
}
} }
// Name and title // Name and title
@ -166,6 +311,42 @@ private struct ProfileAvatarView: View {
} }
} }
// MARK: - Logo Badge View
/// Logo displayed as a rounded rectangle badge (for side-by-side layout)
private struct LogoBadgeView: View {
let card: BusinessCard
var body: some View {
Group {
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.padding(Design.Spacing.small)
} else {
Image(systemName: "building.2")
.font(.system(size: Design.BaseFontSize.title))
.foregroundStyle(card.theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: Design.CardSize.logoSize, height: Design.CardSize.logoSize)
.background(card.theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick)
)
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusSmall,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetSmall
)
}
}
// MARK: - Contact Fields List // MARK: - Contact Fields List
private struct ContactFieldsListView: View { private struct ContactFieldsListView: View {
@ -234,7 +415,7 @@ private struct ContactFieldRowView: View {
// MARK: - Preview // MARK: - Preview
#Preview { #Preview("Cover + Avatar Layout") {
@Previewable @State var card: BusinessCard = { @Previewable @State var card: BusinessCard = {
let card = BusinessCard( let card = BusinessCard(
displayName: "Matt Bruce", displayName: "Matt Bruce",
@ -242,6 +423,7 @@ private struct ContactFieldRowView: View {
company: "Toyota", company: "Toyota",
themeName: "Coral", themeName: "Coral",
layoutStyleRawValue: "stacked", layoutStyleRawValue: "stacked",
headerLayoutRawValue: "coverWithAvatar",
headline: "Building the future of mobility" headline: "Building the future of mobility"
) )
@ -271,3 +453,49 @@ private struct ContactFieldRowView: View {
.padding() .padding()
.background(Color.AppBackground.base) .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
}()
BusinessCardView(card: card)
.padding()
.background(Color.AppBackground.base)
}

View File

@ -38,6 +38,7 @@ struct CardEditorView: View {
@State private var avatarSystemName = "person.crop.circle" @State private var avatarSystemName = "person.crop.circle"
@State private var selectedTheme: CardTheme = .coral @State private var selectedTheme: CardTheme = .coral
@State private var selectedLayout: CardLayoutStyle = .stacked @State private var selectedLayout: CardLayoutStyle = .stacked
@State private var selectedHeaderLayout: CardHeaderLayout = .profileBanner
// Photos // Photos
@State private var photoData: Data? @State private var photoData: Data?
@ -50,6 +51,9 @@ struct CardEditorView: View {
// Photo editor state - just one variable! // Photo editor state - just one variable!
@State private var editingImageType: ImageType? @State private var editingImageType: ImageType?
// Layout picker state
@State private var showingLayoutPicker = false
// Contact field editor state // Contact field editor state
@State private var selectedFieldTypeForAdd: ContactFieldType? @State private var selectedFieldTypeForAdd: ContactFieldType?
@State private var fieldToEdit: AddedContactField? @State private var fieldToEdit: AddedContactField?
@ -135,8 +139,12 @@ struct CardEditorView: View {
logoData: $logoData, logoData: $logoData,
avatarSystemName: avatarSystemName, avatarSystemName: avatarSystemName,
selectedTheme: selectedTheme, selectedTheme: selectedTheme,
selectedHeaderLayout: selectedHeaderLayout,
onSelectImage: { imageType in onSelectImage: { imageType in
editingImageType = imageType editingImageType = imageType
},
onSelectLayout: {
showingLayoutPicker = true
} }
) )
} header: { } header: {
@ -298,6 +306,19 @@ struct CardEditorView: View {
.sheet(isPresented: $showingPreview) { .sheet(isPresented: $showingPreview) {
CardPreviewSheet(card: buildPreviewCard()) CardPreviewSheet(card: buildPreviewCard())
} }
.sheet(isPresented: $showingLayoutPicker) {
HeaderLayoutPickerView(
selectedLayout: $selectedHeaderLayout,
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData,
avatarSystemName: avatarSystemName,
theme: selectedTheme,
displayName: effectiveDisplayName,
role: role,
company: company
)
}
} }
} }
} }
@ -498,41 +519,88 @@ private struct ImageLayoutRow: View {
@Binding var logoData: Data? @Binding var logoData: Data?
let avatarSystemName: String let avatarSystemName: String
let selectedTheme: CardTheme let selectedTheme: CardTheme
let selectedHeaderLayout: CardHeaderLayout
let onSelectImage: (CardEditorView.ImageType) -> Void 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 is side-by-side
private var isSideBySideLayout: Bool {
selectedHeaderLayout == .avatarAndLogoSideBySide
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
// Card preview with edit buttons // Live card preview based on selected layout
ZStack(alignment: .bottomLeading) { ZStack(alignment: .bottomLeading) {
// Banner with cover photo or gradient // Banner preview based on layout
BannerPreviewView( EditorBannerPreviewView(
photoData: photoData,
coverPhotoData: coverPhotoData, coverPhotoData: coverPhotoData,
logoData: logoData, logoData: logoData,
avatarSystemName: avatarSystemName,
selectedTheme: selectedTheme, selectedTheme: selectedTheme,
onEditCover: { onSelectImage(.cover) }, selectedHeaderLayout: selectedHeaderLayout
onEditLogo: { onSelectImage(.logo) }
) )
// Profile photo with edit button // Avatar overlay (for layouts that show avatar in content)
ZStack(alignment: .bottomTrailing) { if showsAvatarInContent {
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme) HStack(spacing: Design.Spacing.small) {
ProfilePhotoView(
Button { photoData: photoData,
onSelectImage(.profile) avatarSystemName: avatarSystemName,
} label: { theme: selectedTheme
Image(systemName: "pencil") )
.font(.caption2)
.padding(Design.Spacing.xSmall) // Side-by-side: show logo badge next to avatar
.background(.ultraThinMaterial) if isSideBySideLayout {
.clipShape(.circle) EditorLogoBadgeView(
logoData: logoData,
theme: selectedTheme
)
}
} }
.buttonStyle(.plain) .offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap)
} }
.offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap)
} }
.padding(.bottom, Design.CardSize.avatarOverlap) .padding(.bottom, showsAvatarInContent ? Design.CardSize.avatarOverlap : 0)
// Photo action buttons // Layout selector button
Button(action: onSelectLayout) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: selectedHeaderLayout.iconName)
.font(.title3)
.foregroundStyle(Color.accentColor)
.frame(width: Design.CardSize.socialIconSize)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Header Layout")
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
Text(selectedHeaderLayout.displayName)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.padding(.vertical, Design.Spacing.xSmall)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel("Header Layout: \(selectedHeaderLayout.displayName)")
.accessibilityHint("Tap to choose how images appear in the card header")
// Photo action buttons (these are the only way to edit photos now)
ImageActionButtonsRow( ImageActionButtonsRow(
photoData: $photoData, photoData: $photoData,
coverPhotoData: $coverPhotoData, coverPhotoData: $coverPhotoData,
@ -543,73 +611,168 @@ private struct ImageLayoutRow: View {
} }
} }
// MARK: - Banner Preview View // MARK: - Editor Banner Preview View
private struct BannerPreviewView: View { /// Live banner preview in the editor that changes based on selected layout
private struct EditorBannerPreviewView: View {
let photoData: Data?
let coverPhotoData: Data? let coverPhotoData: Data?
let logoData: Data? let logoData: Data?
let avatarSystemName: String
let selectedTheme: CardTheme let selectedTheme: CardTheme
let onEditCover: () -> Void let selectedHeaderLayout: CardHeaderLayout
let onEditLogo: () -> Void
var body: some View { var body: some View {
ZStack { Group {
// Background: cover photo or gradient switch selectedHeaderLayout {
case .profileBanner:
profileBannerPreview
case .coverWithAvatar:
coverOnlyPreview
case .coverWithCenteredLogo:
coverWithCenteredLogoPreview
case .coverWithLogoBadge:
coverWithLogoBadgePreview
case .avatarAndLogoSideBySide:
coverOnlyPreview
}
}
.frame(height: Design.CardSize.bannerHeight)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
// MARK: - Layout Previews
/// Profile photo fills the banner
private var profileBannerPreview: some View {
ZStack {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
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)
}
}
}
}
/// 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 {
Group {
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(height: Design.CardSize.bannerHeight)
.clipped() .clipped()
} else { } else {
LinearGradient( themeGradient
colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} }
}
// Company logo overlay }
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage) private var themeGradient: some View {
.resizable() LinearGradient(
.scaledToFit() colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor],
.frame(height: Design.CardSize.logoSize) startPoint: .topLeading,
} endPoint: .bottomTrailing
)
// Edit buttons overlay }
VStack { }
HStack {
// Edit cover photo button (top-left) // MARK: - Editor Logo Badge View
Button(action: onEditCover) {
Image(systemName: "photo") /// Logo badge for side-by-side layout in editor
.font(.caption) private struct EditorLogoBadgeView: View {
.padding(Design.Spacing.small) let logoData: Data?
.background(.ultraThinMaterial) let theme: CardTheme
.clipShape(.circle)
} var body: some View {
.buttonStyle(.plain) Group {
.accessibilityLabel(String.localized("Edit cover photo")) if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
Spacer() .resizable()
.scaledToFit()
// Edit logo button (top-right) .padding(Design.Spacing.small)
Button(action: onEditLogo) { } else {
Image(systemName: "building.2") Image(systemName: "building.2")
.font(.caption) .font(.system(size: Design.BaseFontSize.title))
.padding(Design.Spacing.small) .foregroundStyle(theme.textColor)
.background(.ultraThinMaterial) .frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(.circle) }
} }
.buttonStyle(.plain) .frame(width: Design.CardSize.logoSize, height: Design.CardSize.logoSize)
.accessibilityLabel(String.localized("Edit company logo")) .background(theme.accentColor)
} .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
Spacer() .overlay(
} RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.padding(Design.Spacing.small) .stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick)
} )
.frame(height: Design.CardSize.bannerHeight) .shadow(
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusSmall,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetSmall
)
} }
} }
@ -925,6 +1088,7 @@ private extension CardEditorView {
contactFields = card.orderedContactFields.compactMap { $0.toAddedContactField() } contactFields = card.orderedContactFields.compactMap { $0.toAddedContactField() }
selectedTheme = card.theme selectedTheme = card.theme
selectedLayout = card.layoutStyle selectedLayout = card.layoutStyle
selectedHeaderLayout = card.headerLayout
photoData = card.photoData photoData = card.photoData
coverPhotoData = card.coverPhotoData coverPhotoData = card.coverPhotoData
logoData = card.logoData logoData = card.logoData
@ -999,6 +1163,7 @@ private extension CardEditorView {
card.avatarSystemName = avatarSystemName card.avatarSystemName = avatarSystemName
card.theme = selectedTheme card.theme = selectedTheme
card.layoutStyle = selectedLayout card.layoutStyle = selectedLayout
card.headerLayout = selectedHeaderLayout
card.photoData = photoData card.photoData = photoData
card.coverPhotoData = coverPhotoData card.coverPhotoData = coverPhotoData
card.logoData = logoData card.logoData = logoData
@ -1016,6 +1181,7 @@ private extension CardEditorView {
isDefault: false, isDefault: false,
themeName: selectedTheme.name, themeName: selectedTheme.name,
layoutStyleRawValue: selectedLayout.rawValue, layoutStyleRawValue: selectedLayout.rawValue,
headerLayoutRawValue: selectedHeaderLayout.rawValue,
avatarSystemName: avatarSystemName, avatarSystemName: avatarSystemName,
prefix: prefix, prefix: prefix,
firstName: firstName, firstName: firstName,
@ -1049,6 +1215,7 @@ private extension CardEditorView {
isDefault: false, isDefault: false,
themeName: selectedTheme.name, themeName: selectedTheme.name,
layoutStyleRawValue: selectedLayout.rawValue, layoutStyleRawValue: selectedLayout.rawValue,
headerLayoutRawValue: selectedHeaderLayout.rawValue,
avatarSystemName: avatarSystemName, avatarSystemName: avatarSystemName,
prefix: prefix, prefix: prefix,
firstName: firstName, firstName: firstName,

View File

@ -35,7 +35,7 @@ struct CardsHomeView: View {
.indexViewStyle(.page(backgroundDisplayMode: .automatic)) .indexViewStyle(.page(backgroundDisplayMode: .automatic))
} }
} }
.navigationTitle(cardStore.selectedCard?.label ?? String.localized("My Cards")) .navigationTitle(String.localized("My Cards"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {

View File

@ -0,0 +1,477 @@
import SwiftUI
import Bedrock
/// A sheet that displays header layout options as a live preview carousel.
struct HeaderLayoutPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedLayout: CardHeaderLayout
let photoData: Data?
let coverPhotoData: Data?
let logoData: Data?
let avatarSystemName: String
let theme: CardTheme
let displayName: String
let role: String
let company: String
@State private var currentLayout: CardHeaderLayout
init(
selectedLayout: Binding<CardHeaderLayout>,
photoData: Data?,
coverPhotoData: Data?,
logoData: Data?,
avatarSystemName: String,
theme: CardTheme,
displayName: String,
role: String,
company: String
) {
self._selectedLayout = selectedLayout
self.photoData = photoData
self.coverPhotoData = coverPhotoData
self.logoData = logoData
self.avatarSystemName = avatarSystemName
self.theme = theme
self.displayName = displayName
self.role = role
self.company = company
self._currentLayout = State(initialValue: selectedLayout.wrappedValue)
}
private var suggestedLayout: CardHeaderLayout {
CardHeaderLayout.suggested(
hasProfile: photoData != nil,
hasCover: coverPhotoData != nil,
hasLogo: logoData != nil
)
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Layout carousel
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.large) {
ForEach(CardHeaderLayout.allCases) { layout in
LayoutPreviewCard(
layout: layout,
isSelected: currentLayout == layout,
isSuggested: layout == suggestedLayout,
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData,
avatarSystemName: avatarSystemName,
theme: theme,
displayName: displayName,
role: role,
company: company
) {
withAnimation(.snappy(duration: Design.Animation.quick)) {
currentLayout = layout
}
}
}
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.large)
}
.scrollClipDisabled()
Spacer()
// Confirm button
Button {
selectedLayout = currentLayout
dismiss()
} label: {
Text("Confirm layout")
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.large)
.background(.black)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.bottom, Design.Spacing.xLarge)
}
.background(Color.AppBackground.base)
.navigationTitle("Choose a layout")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.body)
.foregroundStyle(Color.Text.primary)
}
}
}
}
}
}
// MARK: - Layout Preview Card
private struct LayoutPreviewCard: View {
let layout: CardHeaderLayout
let isSelected: Bool
let isSuggested: Bool
let photoData: Data?
let coverPhotoData: Data?
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 var needsMoreImages: Bool {
(layout.requiresCoverPhoto && coverPhotoData == nil) ||
(layout.benefitsFromLogo && 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
}
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)
}
.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
)
// Badges
badgeOverlay
.offset(y: -Design.Spacing.small)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(layout.displayName)
.accessibilityValue(isSelected ? String(localized: "Selected") : "")
.accessibilityHint(layout.description)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
// MARK: - Banner Content (no avatar overlay - that's in content area)
@ViewBuilder
private var bannerContent: some View {
switch layout {
case .profileBanner:
profileBannerContent
case .coverWithAvatar:
coverBackground
case .coverWithCenteredLogo:
coverWithCenteredLogoContent
case .coverWithLogoBadge:
coverWithLogoBadgeContent
case .avatarAndLogoSideBySide:
coverBackground
}
}
/// Profile photo fills the entire banner
private var profileBannerContent: 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
)
Image(systemName: avatarSystemName)
.font(.system(size: Design.BaseFontSize.display))
.foregroundStyle(theme.textColor.opacity(Design.Opacity.medium))
}
}
}
/// Cover image with logo centered
private var coverWithCenteredLogoContent: some View {
ZStack {
coverBackground
// Logo centered
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(height: logoSize)
} 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)
)
}
}
}
/// 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) {
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)
} 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)
// Side-by-side: show logo badge next to avatar
if isSideBySideLayout {
logoBadge(size: avatarSize)
}
}
}
// Placeholder text lines
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.hint))
.frame(height: Design.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xLarge)
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.subtle))
.frame(height: Design.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xxLarge)
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.subtle))
.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: - 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 {
Group {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Image(systemName: avatarSystemName)
.font(.system(size: size / 2.5))
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.accentColor)
}
}
.frame(width: size, height: size)
.clipShape(.circle)
.overlay(
Circle()
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
)
}
// MARK: - Body Content
// 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)
)
} else if isSuggested && !isSelected {
LayoutBadge(
text: "Suggested",
iconName: "star.fill",
backgroundColor: Color.Badge.star
)
}
}
}
// MARK: - Layout Badge
private struct LayoutBadge: View {
let text: String
let iconName: String
let backgroundColor: Color
var body: some View {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: iconName)
.font(.caption2)
Text(text)
.font(.caption2)
.bold()
}
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(backgroundColor)
.foregroundStyle(.white)
.clipShape(.capsule)
}
}
// MARK: - Preview
#Preview {
@Previewable @State var selectedLayout: CardHeaderLayout = .profileBanner
HeaderLayoutPickerView(
selectedLayout: $selectedLayout,
photoData: nil,
coverPhotoData: nil,
logoData: nil,
avatarSystemName: "person.crop.circle",
theme: .coral,
displayName: "John Doe",
role: "Developer",
company: "Acme Inc"
)
}

View File

@ -5,6 +5,7 @@ import Bedrock
/// A self-contained image editor flow. /// A self-contained image editor flow.
/// Shows source picker first, then presents photo picker or camera as a full-screen cover. /// Shows source picker first, then presents photo picker or camera as a full-screen cover.
/// The aspect ratio is determined by the imageType. /// The aspect ratio is determined by the imageType.
/// For logos, an additional LogoEditorSheet is shown after cropping.
struct ImageEditorFlow: View { struct ImageEditorFlow: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ -30,6 +31,11 @@ struct ImageEditorFlow: View {
imageType == .logo imageType == .logo
} }
/// Whether this is a logo image (needs extra editing step)
private var isLogoImage: Bool {
imageType == .logo
}
var body: some View { var body: some View {
// Source picker is the base content of this sheet // Source picker is the base content of this sheet
sourcePickerView sourcePickerView
@ -37,6 +43,7 @@ struct ImageEditorFlow: View {
PhotoPickerFlow( PhotoPickerFlow(
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection, allowAspectRatioSelection: allowAspectRatioSelection,
isLogoImage: isLogoImage,
onComplete: { imageData in onComplete: { imageData in
showingFullScreenPicker = false showingFullScreenPicker = false
if let imageData { if let imageData {
@ -50,6 +57,7 @@ struct ImageEditorFlow: View {
CameraFlow( CameraFlow(
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection, allowAspectRatioSelection: allowAspectRatioSelection,
isLogoImage: isLogoImage,
onComplete: { imageData in onComplete: { imageData in
showingFullScreenCamera = false showingFullScreenCamera = false
if let imageData { if let imageData {
@ -129,11 +137,14 @@ struct ImageEditorFlow: View {
private struct PhotoPickerFlow: View { private struct PhotoPickerFlow: View {
let aspectRatio: CropAspectRatio let aspectRatio: CropAspectRatio
let allowAspectRatioSelection: Bool let allowAspectRatioSelection: Bool
let isLogoImage: Bool
let onComplete: (Data?) -> Void let onComplete: (Data?) -> Void
@State private var selectedPhotoItem: PhotosPickerItem? @State private var selectedPhotoItem: PhotosPickerItem?
@State private var imageData: Data? @State private var imageData: Data?
@State private var showingCropper = false @State private var showingCropper = false
@State private var showingLogoEditor = false
@State private var croppedLogoImage: UIImage?
@State private var pickerID = UUID() @State private var pickerID = UUID()
var body: some View { var body: some View {
@ -175,7 +186,14 @@ private struct PhotoPickerFlow: View {
shouldDismissOnComplete: false shouldDismissOnComplete: false
) { croppedData in ) { croppedData in
if let croppedData { if let croppedData {
onComplete(croppedData) // For logos, show the logo editor next
if isLogoImage, let uiImage = UIImage(data: croppedData) {
croppedLogoImage = uiImage
showingCropper = false
showingLogoEditor = true
} else {
onComplete(croppedData)
}
} else { } else {
// Go back to picker // Go back to picker
showingCropper = false showingCropper = false
@ -186,8 +204,26 @@ private struct PhotoPickerFlow: View {
} }
.transition(.move(edge: .trailing)) .transition(.move(edge: .trailing))
} }
// Logo editor overlay
if showingLogoEditor, let logoImage = croppedLogoImage {
LogoEditorSheet(logoImage: logoImage) { finalData in
if let finalData {
onComplete(finalData)
} else {
// User cancelled logo editor, go back to picker
showingLogoEditor = false
croppedLogoImage = nil
self.imageData = nil
self.selectedPhotoItem = nil
pickerID = UUID()
}
}
.transition(.move(edge: .trailing))
}
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper) .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
.animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
} }
} }
@ -196,16 +232,19 @@ private struct PhotoPickerFlow: View {
private struct CameraFlow: View { private struct CameraFlow: View {
let aspectRatio: CropAspectRatio let aspectRatio: CropAspectRatio
let allowAspectRatioSelection: Bool let allowAspectRatioSelection: Bool
let isLogoImage: Bool
let onComplete: (Data?) -> Void let onComplete: (Data?) -> Void
@State private var capturedImageData: Data? @State private var capturedImageData: Data?
@State private var showingCropper = false @State private var showingCropper = false
@State private var showingLogoEditor = false
@State private var croppedLogoImage: UIImage?
@State private var cameraID = UUID() // For resetting camera after cancel @State private var cameraID = UUID() // For resetting camera after cancel
var body: some View { var body: some View {
ZStack { ZStack {
// Camera - only show when not cropping // Camera - only show when not cropping or editing
if !showingCropper { if !showingCropper && !showingLogoEditor {
CameraCaptureView(shouldDismissOnCapture: false) { imageData in CameraCaptureView(shouldDismissOnCapture: false) { imageData in
if let imageData { if let imageData {
capturedImageData = imageData capturedImageData = imageData
@ -229,7 +268,14 @@ private struct CameraFlow: View {
shouldDismissOnComplete: false shouldDismissOnComplete: false
) { croppedData in ) { croppedData in
if let croppedData { if let croppedData {
onComplete(croppedData) // For logos, show the logo editor next
if isLogoImage, let uiImage = UIImage(data: croppedData) {
croppedLogoImage = uiImage
showingCropper = false
showingLogoEditor = true
} else {
onComplete(croppedData)
}
} else { } else {
// User cancelled cropper - go back to camera for retake // User cancelled cropper - go back to camera for retake
showingCropper = false showingCropper = false
@ -239,8 +285,25 @@ private struct CameraFlow: View {
} }
.transition(.move(edge: .trailing)) .transition(.move(edge: .trailing))
} }
// Logo editor overlay
if showingLogoEditor, let logoImage = croppedLogoImage {
LogoEditorSheet(logoImage: logoImage) { finalData in
if let finalData {
onComplete(finalData)
} else {
// User cancelled logo editor, go back to camera
showingLogoEditor = false
croppedLogoImage = nil
capturedImageData = nil
cameraID = UUID()
}
}
.transition(.move(edge: .trailing))
}
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper) .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
.animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
} }
} }

View File

@ -0,0 +1,339 @@
import SwiftUI
import Bedrock
/// Post-crop editor for company logos.
/// Allows resizing the logo within a 3:2 landscape container and selecting a background color.
struct LogoEditorSheet: View {
@Environment(\.dismiss) private var dismiss
let logoImage: UIImage
let onComplete: (Data?) -> Void
@State private var zoomScale: CGFloat = 1.0
@State private var backgroundColor: Color = .white
@State private var suggestedColors: [Color] = [.white, .black]
@State private var showingColorPicker = false
@State private var customColor: Color = .blue
// Zoom range
private let minZoom: CGFloat = 0.5
private let maxZoom: CGFloat = 2.0
var body: some View {
NavigationStack {
VStack(spacing: Design.Spacing.xLarge) {
Spacer()
// Logo preview in 3:2 container
logoPreviewContainer
Spacer()
// Zoom slider
zoomSliderSection
// Background color picker
backgroundColorSection
Spacer()
}
.padding(.horizontal, Design.Spacing.xLarge)
.background(Color.AppBackground.secondary)
.navigationTitle("Edit company logo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
onComplete(nil)
} label: {
Image(systemName: "xmark")
.font(.body.bold())
.foregroundStyle(Color.Text.primary)
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveAndComplete()
}
.bold()
}
}
}
.onAppear {
extractSuggestedColors()
}
.sheet(isPresented: $showingColorPicker) {
CustomColorPickerSheet(initialColor: customColor) { selectedColor in
customColor = selectedColor
backgroundColor = selectedColor
}
}
}
// MARK: - Logo Preview Container
private var logoPreviewContainer: some View {
GeometryReader { geometry in
let containerWidth = geometry.size.width
let containerHeight = containerWidth / Design.CardSize.logoContainerAspectRatio
ZStack {
// Background color
Rectangle()
.fill(backgroundColor)
// Logo at zoom scale
Image(uiImage: logoImage)
.resizable()
.scaledToFit()
.scaleEffect(zoomScale)
}
.frame(width: containerWidth, height: containerHeight)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusMedium,
y: Design.Shadow.offsetSmall
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(Design.CardSize.logoContainerAspectRatio, contentMode: .fit)
}
// MARK: - Zoom Slider Section
private var zoomSliderSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text("Zoom in/out")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "minus.magnifyingglass")
.font(.body)
.foregroundStyle(Color.Text.tertiary)
Slider(value: $zoomScale, in: minZoom...maxZoom)
.tint(Color.accentColor)
Image(systemName: "plus.magnifyingglass")
.font(.body)
.foregroundStyle(Color.Text.tertiary)
}
}
}
// MARK: - Background Color Section
private var backgroundColorSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
Text("Change background color")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
HStack(spacing: Design.Spacing.large) {
// Suggested label and colors
Text("Suggested")
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
HStack(spacing: Design.Spacing.small) {
ForEach(suggestedColors.indices, id: \.self) { index in
ColorSwatchButton(
color: suggestedColors[index],
isSelected: backgroundColor == suggestedColors[index]
) {
backgroundColor = suggestedColors[index]
}
}
}
Spacer()
}
// Custom color row
Button {
showingColorPicker = true
} label: {
HStack(spacing: Design.Spacing.small) {
Text("Custom color")
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
ColorSwatchButton(
color: customColor,
isSelected: backgroundColor == customColor
) {
backgroundColor = customColor
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
// MARK: - Actions
private func extractSuggestedColors() {
let extracted = logoImage.dominantColors(count: 3)
if !extracted.isEmpty {
suggestedColors = extracted
}
}
private func saveAndComplete() {
// Render the final composited image
guard let finalData = renderFinalLogo() else {
onComplete(nil)
return
}
onComplete(finalData)
}
private func renderFinalLogo() -> Data? {
// Determine canvas size (use a reasonable size for the 3:2 container)
let canvasWidth: CGFloat = 600
let canvasHeight = canvasWidth / Design.CardSize.logoContainerAspectRatio
let canvasSize = CGSize(width: canvasWidth, height: canvasHeight)
// Calculate logo size at current zoom
let logoAspect = logoImage.size.width / logoImage.size.height
var logoWidth: CGFloat
var logoHeight: CGFloat
if logoAspect > Design.CardSize.logoContainerAspectRatio {
// Logo is wider than container
logoWidth = canvasWidth * zoomScale
logoHeight = logoWidth / logoAspect
} else {
// Logo is taller than container
logoHeight = canvasHeight * zoomScale
logoWidth = logoHeight * logoAspect
}
let logoRect = CGRect(
x: (canvasWidth - logoWidth) / 2,
y: (canvasHeight - logoHeight) / 2,
width: logoWidth,
height: logoHeight
)
// Render using UIGraphicsImageRenderer
let renderer = UIGraphicsImageRenderer(size: canvasSize)
let finalImage = renderer.image { context in
// Fill background
UIColor(backgroundColor).setFill()
context.fill(CGRect(origin: .zero, size: canvasSize))
// Draw logo centered
logoImage.draw(in: logoRect)
}
return finalImage.pngData()
}
}
// MARK: - Color Swatch Button
private struct ColorSwatchButton: View {
let color: Color
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(color)
.frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
.overlay(
Circle()
.stroke(isSelected ? Color.accentColor : Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin)
)
.overlay(
Circle()
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
.padding(Design.LineWidth.thin)
.opacity(isSelected ? 1 : 0)
)
}
.buttonStyle(.plain)
.accessibilityLabel("Color swatch")
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
}
// MARK: - Custom Color Picker Sheet
private struct CustomColorPickerSheet: View {
@Environment(\.dismiss) private var dismiss
let initialColor: Color
let onSelect: (Color) -> Void
@State private var selectedColor: Color
init(initialColor: Color, onSelect: @escaping (Color) -> Void) {
self.initialColor = initialColor
self.onSelect = onSelect
self._selectedColor = State(initialValue: initialColor)
}
var body: some View {
NavigationStack {
VStack(spacing: Design.Spacing.xLarge) {
ColorPicker("Select a color", selection: $selectedColor, supportsOpacity: false)
.labelsHidden()
.scaleEffect(2.0)
.frame(height: 100)
// Preview
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(selectedColor)
.frame(height: 100)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
)
Spacer()
}
.padding(Design.Spacing.xLarge)
.navigationTitle("Custom color")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onSelect(selectedColor)
dismiss()
}
.bold()
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - Preview
#Preview {
LogoEditorSheet(
logoImage: UIImage(systemName: "building.2.fill")!
) { data in
print(data != nil ? "Saved logo" : "Cancelled")
}
}

View File

@ -17,6 +17,14 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
- Tap the **plus icon** to create a new card - Tap the **plus icon** to create a new card
- Set a default card for sharing - Set a default card for sharing
- **Modern card design**: Banner with optional cover photo, company logo, overlapping profile photo, clean contact rows - **Modern card design**: Banner with optional cover photo, company logo, overlapping profile photo, clean contact rows
- **Header layout picker**: Choose how profile, cover, and logo images are arranged in the card header
- **Profile Banner**: Profile photo fills the entire banner (great for personal branding)
- **Cover + Avatar**: Cover image as banner with profile photo overlapping at bottom
- **Centered Logo**: Cover image with company logo centered and profile avatar smaller
- **Logo Badge**: Cover with small logo badge in corner, profile avatar overlapping
- **Side by Side**: Avatar and logo displayed together in the content area
- **Smart layout suggestions**: The app suggests the best layout based on which images you've added
- **Live layout preview**: See exactly how each layout looks before selecting in both the picker and editor
- **Profile photos**: Add a headshot from library or camera with crop/zoom editor - **Profile photos**: Add a headshot from library or camera with crop/zoom editor
- **Cover photos**: Add a custom banner background from library or camera - **Cover photos**: Add a custom banner background from library or camera
- **Company logos**: Upload a logo from library or camera - **Company logos**: Upload a logo from library or camera
@ -138,6 +146,7 @@ BusinessCard/
├── Localization/ # String helpers ├── Localization/ # String helpers
├── Models/ ├── Models/
│ ├── BusinessCard.swift # Main card model │ ├── BusinessCard.swift # Main card model
│ ├── CardHeaderLayout.swift # Header layout options (profile banner, cover+avatar, etc.)
│ ├── Contact.swift # Received contacts │ ├── Contact.swift # Received contacts
│ ├── ContactField.swift # Dynamic contact fields (SwiftData) │ ├── ContactField.swift # Dynamic contact fields (SwiftData)
│ └── ContactFieldType.swift # Field type definitions with icons & URLs │ └── ContactFieldType.swift # Field type definitions with icons & URLs
@ -146,7 +155,7 @@ BusinessCard/
├── Services/ # Share link service, watch sync ├── Services/ # Share link service, watch sync
├── State/ # Observable stores (CardStore, ContactsStore) ├── State/ # Observable stores (CardStore, ContactsStore)
└── Views/ └── Views/
├── Components/ # Reusable UI (ContactFieldPickerView, AddedContactFieldsView, etc.) ├── Components/ # Reusable UI (ContactFieldPickerView, HeaderLayoutPickerView, etc.)
├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.) ├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.)
└── [Feature].swift # Feature screens └── [Feature].swift # Feature screens