1317 lines
47 KiB
Swift
1317 lines
47 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) {
|
|
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 { 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 ?? "",
|
|
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,
|
|
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 shows avatar in content area
|
|
private var showsAvatarInContent: Bool {
|
|
selectedHeaderLayout.showsAvatarInContent
|
|
}
|
|
|
|
/// Whether the selected layout is side-by-side
|
|
private var isSideBySideLayout: Bool {
|
|
selectedHeaderLayout == .avatarAndLogoSideBySide
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
// Live card preview based on selected layout
|
|
ZStack(alignment: .bottomLeading) {
|
|
// Banner preview based on layout
|
|
EditorBannerPreviewView(
|
|
photoData: photoData,
|
|
coverPhotoData: coverPhotoData,
|
|
logoData: logoData,
|
|
avatarSystemName: avatarSystemName,
|
|
selectedTheme: selectedTheme,
|
|
selectedHeaderLayout: selectedHeaderLayout
|
|
)
|
|
|
|
// Avatar overlay (for layouts that show avatar in content)
|
|
if showsAvatarInContent {
|
|
HStack(spacing: Design.Spacing.small) {
|
|
ProfilePhotoView(
|
|
photoData: photoData,
|
|
avatarSystemName: avatarSystemName,
|
|
theme: selectedTheme
|
|
)
|
|
|
|
// Side-by-side: show logo badge next to avatar
|
|
if isSideBySideLayout {
|
|
EditorLogoBadgeView(
|
|
logoData: logoData,
|
|
theme: selectedTheme
|
|
)
|
|
}
|
|
}
|
|
.offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap)
|
|
}
|
|
}
|
|
.padding(.bottom, showsAvatarInContent ? Design.CardSize.avatarOverlap : 0)
|
|
|
|
// 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 {
|
|
Group {
|
|
switch selectedHeaderLayout {
|
|
case .profileBanner:
|
|
profileBannerPreview
|
|
case .coverWithAvatar:
|
|
coverOnlyPreview
|
|
case .coverWithCenteredLogo:
|
|
coverWithCenteredLogoPreview
|
|
case .coverWithLogoBadge:
|
|
coverWithLogoBadgePreview
|
|
case .avatarAndLogoSideBySide:
|
|
coverOnlyPreview
|
|
}
|
|
}
|
|
.frame(height: Design.CardSize.bannerHeight)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
}
|
|
|
|
// MARK: - Layout Previews
|
|
|
|
/// Profile photo fills the banner
|
|
private var profileBannerPreview: some View {
|
|
ZStack {
|
|
if let photoData, let uiImage = UIImage(data: photoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.clipped()
|
|
} else if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.clipped()
|
|
} else {
|
|
themeGradient
|
|
}
|
|
|
|
// Show logo if no profile photo
|
|
if photoData == nil {
|
|
if let logoData, let uiImage = UIImage(data: logoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(height: Design.CardSize.logoSize)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cover image only (avatar overlaps into content)
|
|
private var coverOnlyPreview: some View {
|
|
coverBackground
|
|
}
|
|
|
|
/// Cover with centered logo
|
|
private var coverWithCenteredLogoPreview: some View {
|
|
ZStack {
|
|
coverBackground
|
|
|
|
if let logoData, let uiImage = UIImage(data: logoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(height: Design.CardSize.logoSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cover with small logo badge in corner
|
|
private var coverWithLogoBadgePreview: some View {
|
|
ZStack {
|
|
coverBackground
|
|
|
|
if let logoData, let uiImage = UIImage(data: logoData) {
|
|
VStack {
|
|
Spacer()
|
|
HStack {
|
|
Spacer()
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(height: Design.CardSize.logoSize / 1.5)
|
|
.padding(Design.Spacing.small)
|
|
.background(.ultraThinMaterial)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
|
.padding(Design.Spacing.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var coverBackground: some View {
|
|
Group {
|
|
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.clipped()
|
|
} else {
|
|
themeGradient
|
|
}
|
|
}
|
|
}
|
|
|
|
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: - 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 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(field.fieldType.iconColor)
|
|
.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))
|
|
}
|