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 // Photos @State private var photoData: Data? @State private var coverPhotoData: Data? @State private var logoData: Data? // Photo picker state @State private var pendingImageData: Data? // For camera flow only @State private var pendingImageType: ImageType? // For showing PhotoSourcePicker @State private var activeImageType: ImageType? // Tracks which type we're editing through the full flow @State private var pendingAction: PendingPhotoAction? // Action to take after source picker dismisses @State private var showingPhotoPicker = false @State private var showingCamera = false @State private var showingPhotoCropper = false // For camera flow only private enum PendingPhotoAction { case library case camera } @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") } } } 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 { // Card Style section Section { CardStylePicker(selectedTheme: $selectedTheme) } // Images & Layout section Section { ImageLayoutRow( photoData: $photoData, coverPhotoData: $coverPhotoData, logoData: $logoData, avatarSystemName: avatarSystemName, selectedTheme: selectedTheme, onSelectImage: { imageType in pendingImageType = imageType } ) } 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") } // Contact & social fields manager Section { ContactFieldsManagerView(fields: $contactFields) } header: { Text("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: $pendingImageType, onDismiss: { // After source picker dismisses, show the appropriate picker guard let action = pendingAction else { return } pendingAction = nil // Small delay to ensure sheet is fully dismissed Task { @MainActor in try? await Task.sleep(for: .milliseconds(100)) switch action { case .library: showingPhotoPicker = true case .camera: showingCamera = true } } }) { imageType in PhotoSourcePicker( title: imageType.title, hasExistingPhoto: hasExistingPhoto(for: imageType), onSelectFromLibrary: { activeImageType = imageType pendingAction = .library }, onTakePhoto: { activeImageType = imageType pendingAction = .camera }, onRemovePhoto: { removePhoto(for: imageType) } ) } .fullScreenCover(isPresented: $showingPhotoPicker) { NavigationStack { PhotoPickerWithCropper( onSave: { croppedData in savePhoto(croppedData, for: activeImageType) showingPhotoPicker = false activeImageType = nil }, onCancel: { showingPhotoPicker = false activeImageType = nil } ) } } .fullScreenCover(isPresented: $showingCamera) { CameraCaptureView { imageData in if let imageData { pendingImageData = imageData showingPhotoCropper = true } showingCamera = false } } .fullScreenCover(isPresented: $showingPhotoCropper) { if let pendingImageData { PhotoCropperSheet(imageData: pendingImageData) { croppedData in if let croppedData { savePhoto(croppedData, for: activeImageType) } self.pendingImageData = nil self.activeImageType = nil showingPhotoCropper = false } } } .onAppear { loadCardData() } .sheet(isPresented: $showingPreview) { CardPreviewSheet(card: buildPreviewCard()) } } } } // MARK: - Card Style Picker private struct CardStylePicker: View { @Binding var selectedTheme: CardTheme var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Design.Spacing.medium) { 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.name) .accessibilityAddTraits(selectedTheme == theme ? .isSelected : []) } } } } } // 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 onSelectImage: (CardEditorView.ImageType) -> Void var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.medium) { // Card preview with edit buttons ZStack(alignment: .bottomLeading) { // Banner with cover photo or gradient BannerPreviewView( coverPhotoData: coverPhotoData, logoData: logoData, selectedTheme: selectedTheme, onEditCover: { onSelectImage(.cover) }, onEditLogo: { onSelectImage(.logo) } ) // Profile photo with edit button ZStack(alignment: .bottomTrailing) { ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme) Button { onSelectImage(.profile) } label: { Image(systemName: "pencil") .font(.caption2) .padding(Design.Spacing.xSmall) .background(.ultraThinMaterial) .clipShape(.circle) } .buttonStyle(.plain) } .offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap) } .padding(.bottom, Design.CardSize.avatarOverlap) // Photo action buttons ImageActionButtonsRow( photoData: $photoData, coverPhotoData: $coverPhotoData, logoData: $logoData, onSelectImage: onSelectImage ) } } } // MARK: - Banner Preview View private struct BannerPreviewView: View { let coverPhotoData: Data? let logoData: Data? let selectedTheme: CardTheme let onEditCover: () -> Void let onEditLogo: () -> Void var body: some View { ZStack { // Background: cover photo or gradient if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { Image(uiImage: uiImage) .resizable() .scaledToFill() .frame(height: Design.CardSize.bannerHeight) .clipped() } else { LinearGradient( colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing ) } // Company logo overlay if let logoData, let uiImage = UIImage(data: logoData) { Image(uiImage: uiImage) .resizable() .scaledToFit() .frame(height: Design.CardSize.logoSize) } // Edit buttons overlay VStack { HStack { // Edit cover photo button (top-left) Button(action: onEditCover) { Image(systemName: "photo") .font(.caption) .padding(Design.Spacing.small) .background(.ultraThinMaterial) .clipShape(.circle) } .buttonStyle(.plain) .accessibilityLabel(String.localized("Edit cover photo")) Spacer() // Edit logo button (top-right) Button(action: onEditLogo) { Image(systemName: "building.2") .font(.caption) .padding(Design.Spacing.small) .background(.ultraThinMaterial) .clipShape(.circle) } .buttonStyle(.plain) .accessibilityLabel(String.localized("Edit company logo")) } Spacer() } .padding(Design.Spacing.small) } .frame(height: Design.CardSize.bannerHeight) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) } } // 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: - 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 // Load contact fields from the array contactFields = card.orderedContactFields.compactMap { $0.toAddedContactField() } selectedTheme = card.theme selectedLayout = card.layoutStyle 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?) { guard let imageType else { return } switch imageType { case .profile: photoData = data case .cover: coverPhotoData = data case .logo: logoData = data } } func saveCard() { if let existingCard = card { updateCard(existingCard) onSave(existingCard) } else { let newCard = createCard() onSave(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.photoData = photoData 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, 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, 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, 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, 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 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)) }