BusinessCard/BusinessCard/Views/CardEditorView.swift

893 lines
32 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
// 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))
}