From 5e774cc778041d3307fdfb8a10c3bd6aa4c47e27 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 9 Jan 2026 13:22:30 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard/Design/DesignConstants.swift | 2 + BusinessCard/Models/BusinessCard.swift | 35 +- BusinessCard/Models/CardHeaderLayout.swift | 127 +++++ BusinessCard/Resources/Localizable.xcstrings | 48 ++ BusinessCard/Services/ColorExtractor.swift | 141 ++++++ BusinessCard/Views/BusinessCardView.swift | 262 +++++++++- BusinessCard/Views/CardEditorView.swift | 317 +++++++++--- BusinessCard/Views/CardsHomeView.swift | 2 +- .../Components/HeaderLayoutPickerView.swift | 477 ++++++++++++++++++ .../Views/Components/ImageEditorFlow.swift | 71 ++- .../Views/Sheets/LogoEditorSheet.swift | 339 +++++++++++++ README.md | 11 +- 12 files changed, 1731 insertions(+), 101 deletions(-) create mode 100644 BusinessCard/Models/CardHeaderLayout.swift create mode 100644 BusinessCard/Services/ColorExtractor.swift create mode 100644 BusinessCard/Views/Components/HeaderLayoutPickerView.swift create mode 100644 BusinessCard/Views/Sheets/LogoEditorSheet.swift diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift index 8cdfb96..8a33c09 100644 --- a/BusinessCard/Design/DesignConstants.swift +++ b/BusinessCard/Design/DesignConstants.swift @@ -31,6 +31,8 @@ extension Design { static let floatingButtonSize: CGFloat = 56 /// Bottom offset for floating button above tab bar. static let floatingButtonBottomOffset: CGFloat = 72 + /// Aspect ratio for logo container (3:2 landscape). + static let logoContainerAspectRatio: CGFloat = 3.0 / 2.0 } } diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index 11078d4..2beef4a 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -12,6 +12,7 @@ final class BusinessCard { var isDefault: Bool var themeName: String var layoutStyleRawValue: String + var headerLayoutRawValue: String var avatarSystemName: String var createdAt: Date var updatedAt: Date @@ -52,6 +53,7 @@ final class BusinessCard { isDefault: Bool = false, themeName: String = "Coral", layoutStyleRawValue: String = "stacked", + headerLayoutRawValue: String = "profileBanner", avatarSystemName: String = "person.crop.circle", createdAt: Date = .now, updatedAt: Date = .now, @@ -79,6 +81,7 @@ final class BusinessCard { self.isDefault = isDefault self.themeName = themeName self.layoutStyleRawValue = layoutStyleRawValue + self.headerLayoutRawValue = headerLayoutRawValue self.avatarSystemName = avatarSystemName self.createdAt = createdAt self.updatedAt = updatedAt @@ -109,6 +112,29 @@ final class BusinessCard { get { CardLayoutStyle(rawValue: layoutStyleRawValue) ?? .stacked } 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 { let base = URL(string: "https://cards.example") ?? URL.documentsDirectory @@ -348,7 +374,7 @@ final class BusinessCard { extension BusinessCard { @MainActor static func createSamples(in context: ModelContext) { - // Sample 1: Property Developer + // Sample 1: Property Developer - Uses coverWithCenteredLogo layout let sample1 = BusinessCard( displayName: "Daniel Sullivan", role: "Property Developer", @@ -357,6 +383,7 @@ extension BusinessCard { isDefault: true, themeName: "Coral", layoutStyleRawValue: "split", + headerLayoutRawValue: "coverWithCenteredLogo", avatarSystemName: "person.crop.circle", pronouns: "he/him", bio: "Building the future of Dallas real estate" @@ -368,7 +395,7 @@ extension BusinessCard { sample1.addContactField(.address, value: "Dallas, TX", title: "Work") sample1.addContactField(.linkedIn, value: "linkedin.com/in/danielsullivan", title: "") - // Sample 2: Creative Lead + // Sample 2: Creative Lead - Uses coverWithAvatar layout let sample2 = BusinessCard( displayName: "Maya Chen", role: "Creative Lead", @@ -377,6 +404,7 @@ extension BusinessCard { isDefault: false, themeName: "Midnight", layoutStyleRawValue: "stacked", + headerLayoutRawValue: "coverWithAvatar", avatarSystemName: "sparkles", pronouns: "she/her", bio: "Designing experiences that matter" @@ -389,7 +417,7 @@ extension BusinessCard { sample2.addContactField(.twitter, value: "twitter.com/mayachen", 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( displayName: "DJ Michaels", role: "DJ", @@ -398,6 +426,7 @@ extension BusinessCard { isDefault: false, themeName: "Ocean", layoutStyleRawValue: "photo", + headerLayoutRawValue: "profileBanner", avatarSystemName: "music.mic", bio: "Bringing the beats to your events" ) diff --git a/BusinessCard/Models/CardHeaderLayout.swift b/BusinessCard/Models/CardHeaderLayout.swift new file mode 100644 index 0000000..7d04453 --- /dev/null +++ b/BusinessCard/Models/CardHeaderLayout.swift @@ -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 + } + } +} diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 7e70beb..a8ac083 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -106,6 +106,9 @@ }, "Calendly Link" : { + }, + "Cancel" : { + }, "Card Found!" : { @@ -138,6 +141,9 @@ }, "Cell" : { + }, + "Change background color" : { + }, "Change image layout" : { "extractionState" : "stale", @@ -164,18 +170,27 @@ }, "Choose a card in the My Cards tab to start sharing." : { + }, + "Choose a layout" : { + }, "Choose your color" : { }, "City" : { + }, + "Color swatch" : { + }, "Company" : { }, "Company Website" : { + }, + "Confirm layout" : { + }, "Connection details" : { @@ -188,6 +203,9 @@ }, "Country (optional)" : { + }, + "Cover" : { + }, "Create multiple business cards" : { "extractionState" : "stale", @@ -214,6 +232,9 @@ }, "Create your first card" : { + }, + "Custom color" : { + }, "Customize your card" : { "extractionState" : "stale", @@ -252,6 +273,9 @@ }, "Developer" : { + }, + "Done" : { + }, "Drag to reorder. Swipe to delete." : { @@ -264,6 +288,9 @@ }, "Edit %@" : { + }, + "Edit company logo" : { + }, "Email" : { @@ -273,6 +300,12 @@ }, "First Name" : { + }, + "Header Layout" : { + + }, + "Header Layout: %@" : { + }, "Headline" : { @@ -519,6 +552,12 @@ }, "Scheduling" : { + }, + "Select a color" : { + + }, + "Selected" : { + }, "Share card offline" : { @@ -585,6 +624,9 @@ }, "Suffix (e.g. Jr., III)" : { + }, + "Suggested" : { + }, "Support & Funding" : { @@ -594,6 +636,9 @@ }, "Tap a field below to add it" : { + }, + "Tap to choose how images appear in the card header" : { + }, "Tap to share" : { "localizations" : { @@ -748,6 +793,9 @@ }, "ZIP Code" : { + }, + "Zoom in/out" : { + } }, "version" : "1.1" diff --git a/BusinessCard/Services/ColorExtractor.swift b/BusinessCard/Services/ColorExtractor.swift new file mode 100644 index 0000000..1b34eca --- /dev/null +++ b/BusinessCard/Services/ColorExtractor.swift @@ -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.. 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 + } +} diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index 2dc5d14..0f949e3 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -6,15 +6,20 @@ struct BusinessCardView: View { let card: BusinessCard var isCompact: Bool = false + /// Whether the current header layout includes an avatar that overlaps from banner to content + private var hasAvatarOverlap: Bool { + card.headerLayout.showsAvatarInContent + } + var body: some View { VStack(spacing: 0) { // Banner with logo CardBannerView(card: card) - // Content area with avatar overlapping + // Content area with avatar overlapping (conditional based on layout) CardContentView(card: card, isCompact: isCompact) - .offset(y: -Design.CardSize.avatarOverlap) - .padding(.bottom, -Design.CardSize.avatarOverlap) + .offset(y: hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0) + .padding(.bottom, hasAvatarOverlap ? -Design.CardSize.avatarOverlap : 0) } .background(Color.AppBackground.elevated) .clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge)) @@ -35,14 +40,43 @@ struct BusinessCardView: View { private struct CardBannerView: View { 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 { ZStack { - // Background: cover photo or gradient - if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { + // Background: profile photo or cover photo or gradient + if let photoData = card.photoData, let uiImage = UIImage(data: photoData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .clipped() + } else if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { Image(uiImage: uiImage) .resizable() .scaledToFill() - .frame(height: Design.CardSize.bannerHeight) .clipped() } else { // 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) { Image(uiImage: uiImage) .resizable() .scaledToFit() .frame(height: Design.CardSize.logoSize) - } else if card.coverPhotoData == nil && !card.company.isEmpty { - // Only show company initial if no cover photo and no logo + } else if !card.company.isEmpty { Text(card.company.prefix(1).uppercased()) .font(.system(size: Design.BaseFontSize.display, weight: .bold, design: .rounded)) .foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium)) } } - .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 } + /// Whether to show the avatar in the content area + private var showsAvatarInContent: Bool { + card.headerLayout.showsAvatarInContent + } + + /// Whether this is the side-by-side layout + private var isSideBySideLayout: Bool { + card.headerLayout == .avatarAndLogoSideBySide + } + var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.medium) { - // Avatar and label row - HStack(alignment: .bottom) { - ProfileAvatarView(card: card) - Spacer() - LabelBadgeView(label: card.label, accentColor: card.theme.accentColor, textColor: card.theme.textColor) - .padding(.bottom, Design.CardSize.avatarOverlap) + // Avatar row (conditional based on layout) + if showsAvatarInContent { + HStack(alignment: .bottom, spacing: Design.Spacing.small) { + ProfileAvatarView(card: card) + + // Side-by-side: show logo next to avatar + if isSideBySideLayout { + LogoBadgeView(card: card) + } + } } // 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 private struct ContactFieldsListView: View { @@ -234,7 +415,7 @@ private struct ContactFieldRowView: View { // MARK: - Preview -#Preview { +#Preview("Cover + Avatar Layout") { @Previewable @State var card: BusinessCard = { let card = BusinessCard( displayName: "Matt Bruce", @@ -242,6 +423,7 @@ private struct ContactFieldRowView: View { company: "Toyota", themeName: "Coral", layoutStyleRawValue: "stacked", + headerLayoutRawValue: "coverWithAvatar", headline: "Building the future of mobility" ) @@ -271,3 +453,49 @@ private struct ContactFieldRowView: View { .padding() .background(Color.AppBackground.base) } + +#Preview("Profile Banner Layout") { + @Previewable @State var card: BusinessCard = { + let card = BusinessCard( + displayName: "Matt Bruce", + role: "Lead iOS Developer", + company: "Toyota", + themeName: "Ocean", + layoutStyleRawValue: "stacked", + headerLayoutRawValue: "profileBanner", + headline: "Building the future of mobility" + ) + + let emailField = ContactField(typeId: "email", value: "matt.bruce@toyota.com", title: "Work", orderIndex: 0) + card.contactFields = [emailField] + + return card + }() + + BusinessCardView(card: card) + .padding() + .background(Color.AppBackground.base) +} + +#Preview("Cover + Logo + Avatar Layout") { + @Previewable @State var card: BusinessCard = { + let card = BusinessCard( + displayName: "Matt Bruce", + role: "Lead iOS Developer", + company: "Toyota", + themeName: "Midnight", + layoutStyleRawValue: "stacked", + headerLayoutRawValue: "coverWithCenteredLogo", + headline: "Building the future of mobility" + ) + + let emailField = ContactField(typeId: "email", value: "matt.bruce@toyota.com", title: "Work", orderIndex: 0) + card.contactFields = [emailField] + + return card + }() + + BusinessCardView(card: card) + .padding() + .background(Color.AppBackground.base) +} diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index 2a59ace..e9a07ac 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -38,6 +38,7 @@ struct CardEditorView: View { @State private var avatarSystemName = "person.crop.circle" @State private var selectedTheme: CardTheme = .coral @State private var selectedLayout: CardLayoutStyle = .stacked + @State private var selectedHeaderLayout: CardHeaderLayout = .profileBanner // Photos @State private var photoData: Data? @@ -50,6 +51,9 @@ struct CardEditorView: View { // Photo editor state - just one variable! @State private var editingImageType: ImageType? + // Layout picker state + @State private var showingLayoutPicker = false + // Contact field editor state @State private var selectedFieldTypeForAdd: ContactFieldType? @State private var fieldToEdit: AddedContactField? @@ -135,8 +139,12 @@ struct CardEditorView: View { logoData: $logoData, avatarSystemName: avatarSystemName, selectedTheme: selectedTheme, + selectedHeaderLayout: selectedHeaderLayout, onSelectImage: { imageType in editingImageType = imageType + }, + onSelectLayout: { + showingLayoutPicker = true } ) } header: { @@ -298,6 +306,19 @@ struct CardEditorView: View { .sheet(isPresented: $showingPreview) { 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? let avatarSystemName: String let selectedTheme: CardTheme + let selectedHeaderLayout: CardHeaderLayout 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 { VStack(alignment: .leading, spacing: Design.Spacing.medium) { - // Card preview with edit buttons + // Live card preview based on selected layout ZStack(alignment: .bottomLeading) { - // Banner with cover photo or gradient - BannerPreviewView( + // Banner preview based on layout + EditorBannerPreviewView( + photoData: photoData, coverPhotoData: coverPhotoData, logoData: logoData, + avatarSystemName: avatarSystemName, selectedTheme: selectedTheme, - onEditCover: { onSelectImage(.cover) }, - onEditLogo: { onSelectImage(.logo) } + selectedHeaderLayout: selectedHeaderLayout ) - // Profile photo with edit button - ZStack(alignment: .bottomTrailing) { - ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme) - - Button { - onSelectImage(.profile) - } label: { - Image(systemName: "pencil") - .font(.caption2) - .padding(Design.Spacing.xSmall) - .background(.ultraThinMaterial) - .clipShape(.circle) + // Avatar overlay (for layouts that show avatar in content) + if showsAvatarInContent { + HStack(spacing: Design.Spacing.small) { + ProfilePhotoView( + photoData: photoData, + avatarSystemName: avatarSystemName, + theme: selectedTheme + ) + + // Side-by-side: show logo badge next to avatar + if isSideBySideLayout { + EditorLogoBadgeView( + logoData: logoData, + theme: selectedTheme + ) + } } - .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( photoData: $photoData, 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 logoData: Data? + let avatarSystemName: String let selectedTheme: CardTheme - let onEditCover: () -> Void - let onEditLogo: () -> Void + let selectedHeaderLayout: CardHeaderLayout var body: some View { - ZStack { - // Background: cover photo or gradient + Group { + 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) { Image(uiImage: uiImage) .resizable() .scaledToFill() - .frame(height: Design.CardSize.bannerHeight) .clipped() } else { - LinearGradient( - colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + themeGradient } - - // Company logo overlay - if let logoData, let uiImage = UIImage(data: logoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .frame(height: Design.CardSize.logoSize) - } - - // Edit buttons overlay - VStack { - HStack { - // Edit cover photo button (top-left) - Button(action: onEditCover) { - Image(systemName: "photo") - .font(.caption) - .padding(Design.Spacing.small) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.plain) - .accessibilityLabel(String.localized("Edit cover photo")) - - Spacer() - - // Edit logo button (top-right) - Button(action: onEditLogo) { - Image(systemName: "building.2") - .font(.caption) - .padding(Design.Spacing.small) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.plain) - .accessibilityLabel(String.localized("Edit company logo")) - } - Spacer() - } - .padding(Design.Spacing.small) - } - .frame(height: Design.CardSize.bannerHeight) - .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + } + } + + private var themeGradient: some View { + LinearGradient( + colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} + +// MARK: - Editor Logo Badge View + +/// Logo badge for side-by-side layout in editor +private struct EditorLogoBadgeView: View { + let logoData: Data? + let theme: CardTheme + + 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.logoSize, height: Design.CardSize.logoSize) + .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 + ) } } @@ -925,6 +1088,7 @@ private extension CardEditorView { contactFields = card.orderedContactFields.compactMap { $0.toAddedContactField() } selectedTheme = card.theme selectedLayout = card.layoutStyle + selectedHeaderLayout = card.headerLayout photoData = card.photoData coverPhotoData = card.coverPhotoData logoData = card.logoData @@ -999,6 +1163,7 @@ private extension CardEditorView { card.avatarSystemName = avatarSystemName card.theme = selectedTheme card.layoutStyle = selectedLayout + card.headerLayout = selectedHeaderLayout card.photoData = photoData card.coverPhotoData = coverPhotoData card.logoData = logoData @@ -1016,6 +1181,7 @@ private extension CardEditorView { isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue, + headerLayoutRawValue: selectedHeaderLayout.rawValue, avatarSystemName: avatarSystemName, prefix: prefix, firstName: firstName, @@ -1049,6 +1215,7 @@ private extension CardEditorView { isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue, + headerLayoutRawValue: selectedHeaderLayout.rawValue, avatarSystemName: avatarSystemName, prefix: prefix, firstName: firstName, diff --git a/BusinessCard/Views/CardsHomeView.swift b/BusinessCard/Views/CardsHomeView.swift index 5982afe..d5dee4f 100644 --- a/BusinessCard/Views/CardsHomeView.swift +++ b/BusinessCard/Views/CardsHomeView.swift @@ -35,7 +35,7 @@ struct CardsHomeView: View { .indexViewStyle(.page(backgroundDisplayMode: .automatic)) } } - .navigationTitle(cardStore.selectedCard?.label ?? String.localized("My Cards")) + .navigationTitle(String.localized("My Cards")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { diff --git a/BusinessCard/Views/Components/HeaderLayoutPickerView.swift b/BusinessCard/Views/Components/HeaderLayoutPickerView.swift new file mode 100644 index 0000000..f4158ce --- /dev/null +++ b/BusinessCard/Views/Components/HeaderLayoutPickerView.swift @@ -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, + 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" + ) +} diff --git a/BusinessCard/Views/Components/ImageEditorFlow.swift b/BusinessCard/Views/Components/ImageEditorFlow.swift index 52c4367..e0b8482 100644 --- a/BusinessCard/Views/Components/ImageEditorFlow.swift +++ b/BusinessCard/Views/Components/ImageEditorFlow.swift @@ -5,6 +5,7 @@ import Bedrock /// A self-contained image editor flow. /// Shows source picker first, then presents photo picker or camera as a full-screen cover. /// The aspect ratio is determined by the imageType. +/// For logos, an additional LogoEditorSheet is shown after cropping. struct ImageEditorFlow: View { @Environment(\.dismiss) private var dismiss @@ -30,6 +31,11 @@ struct ImageEditorFlow: View { imageType == .logo } + /// Whether this is a logo image (needs extra editing step) + private var isLogoImage: Bool { + imageType == .logo + } + var body: some View { // Source picker is the base content of this sheet sourcePickerView @@ -37,6 +43,7 @@ struct ImageEditorFlow: View { PhotoPickerFlow( aspectRatio: aspectRatio, allowAspectRatioSelection: allowAspectRatioSelection, + isLogoImage: isLogoImage, onComplete: { imageData in showingFullScreenPicker = false if let imageData { @@ -50,6 +57,7 @@ struct ImageEditorFlow: View { CameraFlow( aspectRatio: aspectRatio, allowAspectRatioSelection: allowAspectRatioSelection, + isLogoImage: isLogoImage, onComplete: { imageData in showingFullScreenCamera = false if let imageData { @@ -129,11 +137,14 @@ struct ImageEditorFlow: View { private struct PhotoPickerFlow: View { let aspectRatio: CropAspectRatio let allowAspectRatioSelection: Bool + let isLogoImage: Bool let onComplete: (Data?) -> Void @State private var selectedPhotoItem: PhotosPickerItem? @State private var imageData: Data? @State private var showingCropper = false + @State private var showingLogoEditor = false + @State private var croppedLogoImage: UIImage? @State private var pickerID = UUID() var body: some View { @@ -175,7 +186,14 @@ private struct PhotoPickerFlow: View { shouldDismissOnComplete: false ) { croppedData in 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 { // Go back to picker showingCropper = false @@ -186,8 +204,26 @@ private struct PhotoPickerFlow: View { } .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: showingLogoEditor) } } @@ -196,16 +232,19 @@ private struct PhotoPickerFlow: View { private struct CameraFlow: View { let aspectRatio: CropAspectRatio let allowAspectRatioSelection: Bool + let isLogoImage: Bool let onComplete: (Data?) -> Void @State private var capturedImageData: Data? @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 var body: some View { ZStack { - // Camera - only show when not cropping - if !showingCropper { + // Camera - only show when not cropping or editing + if !showingCropper && !showingLogoEditor { CameraCaptureView(shouldDismissOnCapture: false) { imageData in if let imageData { capturedImageData = imageData @@ -229,7 +268,14 @@ private struct CameraFlow: View { shouldDismissOnComplete: false ) { croppedData in 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 { // User cancelled cropper - go back to camera for retake showingCropper = false @@ -239,8 +285,25 @@ private struct CameraFlow: View { } .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: showingLogoEditor) } } diff --git a/BusinessCard/Views/Sheets/LogoEditorSheet.swift b/BusinessCard/Views/Sheets/LogoEditorSheet.swift new file mode 100644 index 0000000..5b9e64d --- /dev/null +++ b/BusinessCard/Views/Sheets/LogoEditorSheet.swift @@ -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") + } +} diff --git a/README.md b/README.md index c284b69..ba54fb2 100644 --- a/README.md +++ b/README.md @@ -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 - Set a default card for sharing - **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 - **Cover photos**: Add a custom banner background from library or camera - **Company logos**: Upload a logo from library or camera @@ -138,6 +146,7 @@ BusinessCard/ ├── Localization/ # String helpers ├── Models/ │ ├── BusinessCard.swift # Main card model +│ ├── CardHeaderLayout.swift # Header layout options (profile banner, cover+avatar, etc.) │ ├── Contact.swift # Received contacts │ ├── ContactField.swift # Dynamic contact fields (SwiftData) │ └── ContactFieldType.swift # Field type definitions with icons & URLs @@ -146,7 +155,7 @@ BusinessCard/ ├── Services/ # Share link service, watch sync ├── State/ # Observable stores (CardStore, ContactsStore) └── Views/ - ├── Components/ # Reusable UI (ContactFieldPickerView, AddedContactFieldsView, etc.) + ├── Components/ # Reusable UI (ContactFieldPickerView, HeaderLayoutPickerView, etc.) ├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.) └── [Feature].swift # Feature screens