Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
dff3e51b61
commit
5e774cc778
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
127
BusinessCard/Models/CardHeaderLayout.swift
Normal file
127
BusinessCard/Models/CardHeaderLayout.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
141
BusinessCard/Services/ColorExtractor.swift
Normal file
141
BusinessCard/Services/ColorExtractor.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
477
BusinessCard/Views/Components/HeaderLayoutPickerView.swift
Normal file
477
BusinessCard/Views/Components/HeaderLayoutPickerView.swift
Normal 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
339
BusinessCard/Views/Sheets/LogoEditorSheet.swift
Normal file
339
BusinessCard/Views/Sheets/LogoEditorSheet.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
11
README.md
11
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
|
- 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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user