BusinessCard/BusinessCard/Views/CardEditorView.swift

1361 lines
49 KiB
Swift

import SwiftUI
import Bedrock
import SwiftData
import PhotosUI
struct CardEditorView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
let card: BusinessCard?
let onSave: (BusinessCard) -> Void
// Name fields
@State private var displayName = ""
@State private var prefix = ""
@State private var firstName = ""
@State private var middleName = ""
@State private var lastName = ""
@State private var suffix = ""
@State private var maidenName = ""
@State private var preferredName = ""
@State private var pronouns = ""
@State private var showNameDetails = false
// Professional info
@State private var role = ""
@State private var department = ""
@State private var company = ""
@State private var headline = ""
@State private var label = "Work"
@State private var bio = ""
@State private var accreditations = ""
// Contact fields (unified list for picker-based UI)
@State private var contactFields: [AddedContactField] = []
// Appearance
@State private var avatarSystemName = "person.crop.circle"
@State private var selectedTheme: CardTheme = .coral
@State private var selectedLayout: CardLayoutStyle = .stacked
@State private var selectedHeaderLayout: CardHeaderLayout = .profileBanner
// Photos
@State private var photoData: Data?
@State private var coverPhotoData: Data?
@State private var logoData: Data?
// Default card
@State private var isDefault = false
// Photo editor state - just one variable!
@State private var editingImageType: ImageType?
// Layout picker state
@State private var showingLayoutPicker = false
// Contact field editor state
@State private var selectedFieldTypeForAdd: ContactFieldType?
@State private var fieldToEdit: AddedContactField?
@State private var showingPreview = false
enum ImageType: String, Identifiable {
case profile
case cover
case logo
var id: String { rawValue }
var title: String {
switch self {
case .profile: return String.localized("Add profile picture")
case .cover: return String.localized("Add cover photo")
case .logo: return String.localized("Add company logo")
}
}
var cropAspectRatio: CropAspectRatio {
switch self {
case .profile: return .square
case .cover: return .banner
case .logo: return .square
}
}
}
private var isEditing: Bool { card != nil }
private var isFormValid: Bool {
!effectiveDisplayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Simple name for validation and storage (without quotes/parentheses formatting)
private var effectiveDisplayName: String {
if !displayName.isEmpty { return displayName }
let parts = [prefix, firstName, middleName, lastName, suffix].filter { !$0.isEmpty }
return parts.joined(separator: " ")
}
/// Formatted name for display with special formatting
private var formattedDisplayName: String {
if !displayName.isEmpty { return displayName }
var parts: [String] = []
if !prefix.isEmpty { parts.append(prefix) }
if !firstName.isEmpty { parts.append(firstName) }
if !preferredName.isEmpty { parts.append("\"\(preferredName)\"") }
if !middleName.isEmpty { parts.append(middleName) }
if !lastName.isEmpty { parts.append(lastName) }
if !suffix.isEmpty { parts.append(suffix) }
if !maidenName.isEmpty { parts.append("(\(maidenName))") }
if !pronouns.isEmpty { parts.append("(\(pronouns))") }
return parts.joined(separator: " ")
}
var body: some View {
NavigationStack {
Form {
// Default card toggle
Section {
Toggle(isOn: $isDefault) {
Label(String.localized("Default Card"), systemImage: "checkmark.seal.fill")
}
} footer: {
Text("The default card is used for sharing and widgets.")
}
// Card Style section
Section {
CardStylePicker(selectedTheme: $selectedTheme)
}
// Images & Layout section
Section {
ImageLayoutRow(
photoData: $photoData,
coverPhotoData: $coverPhotoData,
logoData: $logoData,
avatarSystemName: avatarSystemName,
selectedTheme: selectedTheme,
selectedHeaderLayout: selectedHeaderLayout,
onSelectImage: { imageType in
editingImageType = imageType
},
onSelectLayout: {
showingLayoutPicker = true
}
)
} header: {
Text("Images & layout")
}
// Personal details section
Section {
// Name row with expand button
Button {
withAnimation { showNameDetails.toggle() }
} label: {
HStack {
Text(formattedDisplayName.isEmpty ? "Full Name" : formattedDisplayName)
.foregroundStyle(formattedDisplayName.isEmpty ? Color.secondary : Color.primary)
Spacer()
Image(systemName: showNameDetails ? "chevron.up" : "chevron.down")
.foregroundStyle(Color.accentColor)
}
}
.tint(.primary)
if showNameDetails {
TextField("Prefix (e.g. Dr., Mr., Ms.)", text: $prefix)
TextField("First Name", text: $firstName)
TextField("Middle Name", text: $middleName)
TextField("Last Name", text: $lastName)
TextField("Suffix (e.g. Jr., III)", text: $suffix)
TextField("Maiden Name", text: $maidenName)
TextField("Preferred Name", text: $preferredName)
TextField("Pronouns (e.g. she/her)", text: $pronouns)
}
} header: {
Text("Personal details")
}
// Professional section
Section {
TextField("Job Title", text: $role)
TextField("Department", text: $department)
TextField("Company", text: $company)
TextField("Headline", text: $headline)
}
// Accreditations
Section {
AccreditationsRow(accreditations: $accreditations)
} header: {
Text("Accreditations")
}
// Card Label
Section {
Picker("Card Label", selection: $label) {
ForEach(["Work", "Personal", "Creative", "Other"], id: \.self) { option in
Text(option).tag(option)
}
}
.pickerStyle(.segmented)
} header: {
Text("Card Label")
}
// Current contact fields (reorderable list)
if !contactFields.isEmpty {
Section {
ForEach(contactFields) { field in
ContactFieldRowView(field: field, themeColor: selectedTheme.primaryColor) {
fieldToEdit = field
}
}
.onMove { from, to in
contactFields.move(fromOffsets: from, toOffset: to)
}
.onDelete { indexSet in
contactFields.remove(atOffsets: indexSet)
}
} header: {
Text("Your Contact Fields")
} footer: {
Text("Drag to reorder. Swipe to delete.")
}
}
// Add new contact fields
Section {
ContactFieldPickerView(themeColor: selectedTheme.primaryColor) { fieldType in
selectedFieldTypeForAdd = fieldType
}
} header: {
Text("Add Contact Fields")
}
// Bio section
Section {
TextField("Tell people about yourself...", text: $bio, axis: .vertical)
.lineLimit(3...8)
} header: {
Text("About")
}
}
.safeAreaInset(edge: .bottom) {
PreviewCardButton { showingPreview = true }
}
.navigationTitle(isEditing ? String.localized("Edit Card") : String.localized("New Card"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Save")) { saveCard() }
.bold()
.disabled(!isFormValid)
}
}
.sheet(item: $editingImageType) { imageType in
ImageEditorFlow(
imageType: imageType,
hasExistingImage: hasExistingPhoto(for: imageType)
) { imageData in
if let imageData {
if imageData.isEmpty {
// Empty data = remove photo
removePhoto(for: imageType)
} else {
// Save the cropped image
savePhoto(imageData, for: imageType)
}
}
// nil = cancelled, do nothing
editingImageType = nil
}
}
.sheet(item: $selectedFieldTypeForAdd) { fieldType in
ContactFieldEditorSheet(
fieldType: fieldType,
initialValue: "",
initialTitle: fieldType.titleSuggestions.first ?? "",
themeColor: selectedTheme.primaryColor,
onSave: { value, title in
addContactField(fieldType: fieldType, value: value, title: title)
}
)
}
.sheet(item: $fieldToEdit) { field in
ContactFieldEditorSheet(
fieldType: field.fieldType,
initialValue: field.value,
initialTitle: field.title,
themeColor: selectedTheme.primaryColor,
onSave: { value, title in
updateContactField(id: field.id, value: value, title: title)
},
onDelete: {
deleteContactField(id: field.id)
}
)
}
.onAppear { loadCardData() }
.sheet(isPresented: $showingPreview) {
CardPreviewSheet(card: buildPreviewCard())
}
.sheet(isPresented: $showingLayoutPicker) {
HeaderLayoutPickerView(
selectedLayout: $selectedHeaderLayout,
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData,
avatarSystemName: avatarSystemName,
theme: selectedTheme,
displayName: effectiveDisplayName,
role: role,
company: company
)
}
}
}
}
// MARK: - Card Style Picker
private struct CardStylePicker: View {
@Binding var selectedTheme: CardTheme
@State private var showingColorPicker = false
@State private var customColor: Color = .blue
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.medium) {
// Preset themes
ForEach(CardTheme.all) { theme in
Button {
selectedTheme = theme
} label: {
Circle()
.fill(theme.primaryColor)
.frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
.overlay(
Circle()
.stroke(selectedTheme == theme ? Color.primary : .clear, lineWidth: Design.LineWidth.medium)
.padding(Design.Spacing.xxSmall)
)
}
.buttonStyle(.plain)
.accessibilityLabel(theme.localizedName)
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
}
// Custom color option
Button {
showingColorPicker = true
} label: {
CustomColorSwatch(
isSelected: selectedTheme.isCustom,
customColor: selectedTheme.isCustom ? selectedTheme.primaryColor : nil
)
}
.buttonStyle(.plain)
.accessibilityLabel(String.localized("Custom color"))
.accessibilityHint(String.localized("Opens color picker to choose a custom color"))
.accessibilityAddTraits(selectedTheme.isCustom ? .isSelected : [])
}
}
.sheet(isPresented: $showingColorPicker) {
CustomColorPickerSheet(
initialColor: selectedTheme.isCustom ? selectedTheme.primaryColor : .blue
) { color in
// Convert Color to RGB components
if let components = color.rgbComponents {
selectedTheme = CardTheme(
customRed: components.red,
customGreen: components.green,
customBlue: components.blue
)
}
}
}
.onAppear {
if selectedTheme.isCustom {
customColor = selectedTheme.primaryColor
}
}
}
}
// MARK: - Custom Color Swatch
private struct CustomColorSwatch: View {
let isSelected: Bool
let customColor: Color?
private let rainbowGradient = AngularGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
center: .center
)
var body: some View {
ZStack {
if let customColor {
// Show the selected custom color
Circle()
.fill(customColor)
} else {
// Show rainbow gradient to indicate "pick any color"
Circle()
.fill(rainbowGradient)
}
// Center icon to indicate it's a picker
if customColor == nil {
Image(systemName: "eyedropper")
.font(.caption)
.foregroundStyle(.white)
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusSmall)
}
}
.frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
.overlay(
Circle()
.stroke(isSelected ? Color.primary : .clear, lineWidth: Design.LineWidth.medium)
.padding(Design.Spacing.xxSmall)
)
}
}
// MARK: - Custom Color Picker Sheet
private struct CustomColorPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedColor: Color
let onSelectColor: (Color) -> Void
init(initialColor: Color, onSelectColor: @escaping (Color) -> Void) {
self._selectedColor = State(initialValue: initialColor)
self.onSelectColor = onSelectColor
}
var body: some View {
NavigationStack {
VStack(spacing: Design.Spacing.xLarge) {
// Preview of selected color
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(selectedColor)
.frame(height: Design.CardSize.bannerHeight)
.overlay(
Text("Preview")
.font(.headline)
.foregroundStyle(selectedColor.contrastingTextColor)
)
.padding(.horizontal, Design.Spacing.large)
// Color picker
ColorPicker("Choose your color", selection: $selectedColor, supportsOpacity: false)
.labelsHidden()
.scaleEffect(1.5)
.frame(height: Design.CardSize.avatarLarge)
Spacer()
}
.padding(.top, Design.Spacing.xLarge)
.navigationTitle(String.localized("Custom Color"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Done")) {
onSelectColor(selectedColor)
dismiss()
}
.bold()
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - Color Extensions for RGB Extraction
private extension Color {
/// Extracts RGB components from a Color (0.0-1.0 range)
var rgbComponents: (red: Double, green: Double, blue: Double)? {
let uiColor = UIColor(self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return nil
}
return (Double(red), Double(green), Double(blue))
}
/// Returns a contrasting text color (white or black) based on luminance
var contrastingTextColor: Color {
guard let components = rgbComponents else { return .white }
let luminance = 0.299 * components.red + 0.587 * components.green + 0.114 * components.blue
return luminance > 0.5 ? .black : .white
}
}
// MARK: - Image Layout Row
private struct ImageLayoutRow: View {
@Binding var photoData: Data?
@Binding var coverPhotoData: Data?
@Binding var logoData: Data?
let avatarSystemName: String
let selectedTheme: CardTheme
let selectedHeaderLayout: CardHeaderLayout
let onSelectImage: (CardEditorView.ImageType) -> Void
let onSelectLayout: () -> Void
/// Whether the selected layout has overlapping content
private var hasOverlappingContent: Bool {
selectedHeaderLayout.hasOverlappingContent
}
@ViewBuilder
private var overlayContent: some View {
switch selectedHeaderLayout.contentOverlay {
case .none:
EmptyView()
case .avatar:
HStack {
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
Spacer()
}
case .logoRectangle:
HStack {
EditorLogoRectangleView(logoData: logoData, theme: selectedTheme)
Spacer()
}
case .avatarAndLogo:
HStack {
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
Spacer()
EditorLogoRectangleView(logoData: logoData, theme: selectedTheme)
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
// Live card preview based on selected layout
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
// Banner preview based on layout
EditorBannerPreviewView(
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData,
avatarSystemName: avatarSystemName,
selectedTheme: selectedTheme,
selectedHeaderLayout: selectedHeaderLayout
)
// Overlay content based on layout
if hasOverlappingContent {
overlayContent
.padding(.leading, Design.Spacing.large)
.padding(.trailing, Design.Spacing.large)
.offset(y: Design.CardSize.avatarOverlap)
}
}
// Spacer to make room for overlapping content
if hasOverlappingContent {
Spacer()
.frame(height: Design.CardSize.avatarOverlap)
}
}
// Layout selector button
Button(action: onSelectLayout) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: selectedHeaderLayout.iconName)
.font(.title3)
.foregroundStyle(Color.accentColor)
.frame(width: Design.CardSize.socialIconSize)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Header Layout")
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
Text(selectedHeaderLayout.displayName)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.padding(.vertical, Design.Spacing.xSmall)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel("Header Layout: \(selectedHeaderLayout.displayName)")
.accessibilityHint("Tap to choose how images appear in the card header")
// Photo action buttons (these are the only way to edit photos now)
ImageActionButtonsRow(
photoData: $photoData,
coverPhotoData: $coverPhotoData,
logoData: $logoData,
onSelectImage: onSelectImage
)
}
}
}
// MARK: - Editor Banner Preview View
/// Live banner preview in the editor that changes based on selected layout
private struct EditorBannerPreviewView: View {
let photoData: Data?
let coverPhotoData: Data?
let logoData: Data?
let avatarSystemName: String
let selectedTheme: CardTheme
let selectedHeaderLayout: CardHeaderLayout
var body: some View {
GeometryReader { geometry in
Group {
switch selectedHeaderLayout.bannerContent {
case .profile:
profileBannerPreview(size: geometry.size)
case .logo:
logoBannerPreview(size: geometry.size)
case .cover:
coverBannerPreview(size: geometry.size)
}
}
}
.frame(height: Design.CardSize.bannerHeight)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
// MARK: - Layout Previews
/// Profile photo fills the banner
private func profileBannerPreview(size: CGSize) -> some View {
ZStack {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} else {
themeGradient
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "person.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Profile")
.font(.title3)
.bold()
}
.foregroundStyle(selectedTheme.textColor.opacity(Design.Opacity.medium))
}
}
}
/// Logo (3:2) fills the banner
private func logoBannerPreview(size: CGSize) -> some View {
ZStack {
themeGradient
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} else {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "building.2.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Logo")
.font(.title3)
.bold()
}
.foregroundStyle(selectedTheme.textColor.opacity(Design.Opacity.medium))
}
}
}
/// Cover photo fills the banner
private func coverBannerPreview(size: CGSize) -> some View {
Group {
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} else {
ZStack {
themeGradient
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "photo.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Cover")
.font(.title3)
.bold()
}
.foregroundStyle(selectedTheme.textColor.opacity(Design.Opacity.medium))
}
}
}
}
// MARK: - Helpers
private var themeGradient: some View {
LinearGradient(
colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
// MARK: - Editor Logo Badge View
/// Logo badge for side-by-side layout in editor
private struct EditorLogoBadgeView: View {
let logoData: Data?
let theme: CardTheme
var body: some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.padding(Design.Spacing.small)
} else {
Image(systemName: "building.2")
.font(.system(size: Design.BaseFontSize.title))
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: Design.CardSize.logoSize, height: Design.CardSize.logoSize)
.background(theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick)
)
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusSmall,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetSmall
)
}
}
// MARK: - Editor Logo Rectangle View
/// Logo rectangle (3:2) for coverWithLogo and coverWithAvatarAndLogo layouts in editor
private struct EditorLogoRectangleView: View {
let logoData: Data?
let theme: CardTheme
private let aspectRatio: CGFloat = Design.CardSize.logoContainerAspectRatio
var body: some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.padding(Design.Spacing.small)
} else {
Image(systemName: "building.2")
.font(.system(size: Design.BaseFontSize.title))
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: Design.CardSize.avatarLarge * aspectRatio, height: Design.CardSize.avatarLarge)
.background(theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick)
)
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusSmall,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetSmall
)
}
}
// MARK: - Image Action Buttons Row
private struct ImageActionButtonsRow: View {
@Binding var photoData: Data?
@Binding var coverPhotoData: Data?
@Binding var logoData: Data?
let onSelectImage: (CardEditorView.ImageType) -> Void
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Profile photo action
ImageActionRow(
title: String.localized("Profile Photo"),
subtitle: photoData == nil ? String.localized("Add your headshot") : String.localized("Change or remove"),
systemImage: "person.crop.circle",
hasImage: photoData != nil,
onTap: { onSelectImage(.profile) },
onRemove: { photoData = nil }
)
// Cover photo action
ImageActionRow(
title: String.localized("Cover Photo"),
subtitle: coverPhotoData == nil ? String.localized("Add banner background") : String.localized("Change or remove"),
systemImage: "photo.fill",
hasImage: coverPhotoData != nil,
onTap: { onSelectImage(.cover) },
onRemove: { coverPhotoData = nil }
)
// Company logo action
ImageActionRow(
title: String.localized("Company Logo"),
subtitle: logoData == nil ? String.localized("Add your logo") : String.localized("Change or remove"),
systemImage: "building.2",
hasImage: logoData != nil,
onTap: { onSelectImage(.logo) },
onRemove: { logoData = nil }
)
}
}
}
// MARK: - Image Action Row
private struct ImageActionRow: View {
let title: String
let subtitle: String
let systemImage: String
let hasImage: Bool
let onTap: () -> Void
let onRemove: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.font(.title3)
.foregroundStyle(hasImage ? Color.accentColor : Color.Text.secondary)
.frame(width: Design.CardSize.socialIconSize)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
Text(subtitle)
.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("\(title): \(subtitle)")
.contextMenu {
if hasImage {
Button(role: .destructive) {
onRemove()
} label: {
Label(String.localized("Remove"), systemImage: "trash")
}
}
}
}
}
private struct ProfilePhotoView: View {
let photoData: Data?
let avatarSystemName: String
let theme: CardTheme
var body: some View {
Group {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Image(systemName: avatarSystemName)
.font(.title)
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.accentColor)
}
}
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
.clipShape(.circle)
.overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
}
}
// MARK: - Contact Field Row View
private struct ContactFieldRowView: View {
let field: AddedContactField
let themeColor: Color
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.medium) {
// Drag handle
Image(systemName: "line.3.horizontal")
.font(.subheadline)
.foregroundStyle(Color.Text.tertiary)
.accessibilityHidden(true)
// Icon
Circle()
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
field.fieldType.iconImage()
.font(.title3)
.foregroundStyle(.white)
)
// Content
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(field.value.isEmpty ? field.fieldType.valuePlaceholder : field.shortDisplayValue)
.font(.subheadline)
.foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
.lineLimit(1)
Text(field.title.isEmpty ? field.fieldType.displayName : field.title)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
.lineLimit(1)
}
Spacer()
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel("\(field.fieldType.displayName): \(field.shortDisplayValue)")
.accessibilityHint(String.localized("Tap to edit, drag to reorder"))
}
}
// MARK: - Accreditations Row
private struct AccreditationsRow: View {
@Binding var accreditations: String
@State private var accreditationInput = ""
private var accreditationsList: [String] {
accreditations.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Input row
HStack {
TextField("e.g. MBA, CPA, PhD", text: $accreditationInput)
Button {
addAccreditation()
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
.disabled(accreditationInput.trimmingCharacters(in: .whitespaces).isEmpty)
}
// Tag bubbles
if !accreditationsList.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.small) {
ForEach(accreditationsList, id: \.self) { tag in
HStack(spacing: Design.Spacing.xSmall) {
Text(tag)
.font(.subheadline)
Button {
removeAccreditation(tag)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(Color.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(Color.secondary.opacity(Design.Opacity.subtle))
.clipShape(.capsule)
}
}
}
}
}
}
private func addAccreditation() {
let trimmed = accreditationInput.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
var list = accreditationsList
list.append(trimmed)
accreditations = list.joined(separator: ", ")
accreditationInput = ""
}
private func removeAccreditation(_ tag: String) {
var list = accreditationsList
list.removeAll { $0 == tag }
accreditations = list.joined(separator: ", ")
}
}
// MARK: - Supporting Views
private struct PreviewCardButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Text("Preview card")
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
.background(Color.Text.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.background(.ultraThinMaterial)
}
}
private struct CardPreviewSheet: View {
let card: BusinessCard
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
BusinessCardView(card: card)
.padding(Design.Spacing.large)
}
.background(Color.AppBackground.base)
.navigationTitle(String.localized("Preview"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Done")) { dismiss() }
}
}
}
}
}
// MARK: - Data Operations
private extension CardEditorView {
func loadCardData() {
guard let card else { return }
displayName = card.displayName
prefix = card.prefix
firstName = card.firstName
middleName = card.middleName
lastName = card.lastName
suffix = card.suffix
maidenName = card.maidenName
preferredName = card.preferredName
pronouns = card.pronouns
role = card.role
department = card.department
company = card.company
headline = card.headline
label = card.label
bio = card.bio
accreditations = card.accreditations
avatarSystemName = card.avatarSystemName
isDefault = card.isDefault
// Load contact fields from the array
contactFields = card.orderedContactFields.compactMap { $0.toAddedContactField() }
selectedTheme = card.theme
selectedLayout = card.layoutStyle
selectedHeaderLayout = card.headerLayout
photoData = card.photoData
coverPhotoData = card.coverPhotoData
logoData = card.logoData
}
// MARK: - Photo Helpers
func hasExistingPhoto(for imageType: ImageType?) -> Bool {
guard let imageType else { return false }
switch imageType {
case .profile: return photoData != nil
case .cover: return coverPhotoData != nil
case .logo: return logoData != nil
}
}
func removePhoto(for imageType: ImageType?) {
guard let imageType else { return }
switch imageType {
case .profile: photoData = nil
case .cover: coverPhotoData = nil
case .logo: logoData = nil
}
}
func savePhoto(_ data: Data, for imageType: ImageType) {
switch imageType {
case .profile: photoData = data
case .cover: coverPhotoData = data
case .logo: logoData = data
}
}
func saveCard() {
if let existingCard = card {
updateCard(existingCard)
onSave(existingCard)
// Handle default card toggle
if isDefault {
appState.cardStore.setDefaultCard(existingCard)
}
} else {
let newCard = createCard()
onSave(newCard)
// Handle default card toggle for new cards
if isDefault {
appState.cardStore.setDefaultCard(newCard)
}
}
dismiss()
}
func updateCard(_ card: BusinessCard) {
card.displayName = displayName.isEmpty ? effectiveDisplayName : displayName
card.prefix = prefix
card.firstName = firstName
card.middleName = middleName
card.lastName = lastName
card.suffix = suffix
card.maidenName = maidenName
card.preferredName = preferredName
card.pronouns = pronouns
card.role = role
card.department = department
card.company = company
card.headline = headline
card.label = label
card.bio = bio
card.accreditations = accreditations
card.avatarSystemName = avatarSystemName
card.theme = selectedTheme
card.layoutStyle = selectedLayout
card.headerLayout = selectedHeaderLayout
card.photoData = photoData
card.coverPhotoData = coverPhotoData
card.logoData = logoData
// Save contact fields to the model's array
saveContactFieldsToCard(card)
}
func createCard() -> BusinessCard {
let newCard = BusinessCard(
displayName: displayName.isEmpty ? effectiveDisplayName : displayName,
role: role,
company: company,
label: label,
isDefault: false,
themeName: selectedTheme.name,
layoutStyleRawValue: selectedLayout.rawValue,
headerLayoutRawValue: selectedHeaderLayout.rawValue,
avatarSystemName: avatarSystemName,
prefix: prefix,
firstName: firstName,
middleName: middleName,
lastName: lastName,
suffix: suffix,
maidenName: maidenName,
preferredName: preferredName,
pronouns: pronouns,
department: department,
headline: headline,
bio: bio,
accreditations: accreditations,
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData
)
// Save contact fields to the model's array
saveContactFieldsToCard(newCard)
return newCard
}
func buildPreviewCard() -> BusinessCard {
let previewCard = BusinessCard(
displayName: displayName.isEmpty ? effectiveDisplayName : displayName,
role: role,
company: company,
label: label,
isDefault: false,
themeName: selectedTheme.name,
layoutStyleRawValue: selectedLayout.rawValue,
headerLayoutRawValue: selectedHeaderLayout.rawValue,
avatarSystemName: avatarSystemName,
prefix: prefix,
firstName: firstName,
middleName: middleName,
lastName: lastName,
suffix: suffix,
maidenName: maidenName,
preferredName: preferredName,
pronouns: pronouns,
department: department,
headline: headline,
bio: bio,
accreditations: accreditations,
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData
)
// Add contact fields to preview card
for (index, field) in contactFields.enumerated() {
let contactField = ContactField(
typeId: field.fieldType.id,
value: field.value,
title: field.title,
orderIndex: index
)
if previewCard.contactFields == nil {
previewCard.contactFields = []
}
previewCard.contactFields?.append(contactField)
}
return previewCard
}
// MARK: - Contact Field Operations
func addContactField(fieldType: ContactFieldType, value: String, title: String) {
guard !value.isEmpty else { return }
withAnimation {
let newField = AddedContactField(
fieldType: fieldType,
value: value,
title: title
)
contactFields.append(newField)
}
}
func updateContactField(id: UUID, value: String, title: String) {
if let index = contactFields.firstIndex(where: { $0.id == id }) {
withAnimation {
contactFields[index].value = value
contactFields[index].title = title
}
}
}
func deleteContactField(id: UUID) {
withAnimation {
contactFields.removeAll { $0.id == id }
}
}
// MARK: - Contact Fields Sync
/// Saves the contactFields array to the BusinessCard model
func saveContactFieldsToCard(_ card: BusinessCard) {
// Clear existing contact fields
card.contactFields?.removeAll()
// Add new fields from the UI array
for (index, addedField) in contactFields.enumerated() {
let value = addedField.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
let field = ContactField(
typeId: addedField.fieldType.id,
value: value,
title: addedField.title,
orderIndex: index
)
field.card = card
if card.contactFields == nil {
card.contactFields = []
}
card.contactFields?.append(field)
}
}
}
// MARK: - Preview
#Preview("New Card") {
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
return CardEditorView(card: nil) { _ in }
.environment(AppState(modelContext: container.mainContext))
}