BusinessCard/BusinessCard/Views/CardEditorView.swift

536 lines
20 KiB
Swift

import SwiftUI
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
// Basic info
@State private var displayName: String = ""
@State private var role: String = ""
@State private var company: String = ""
@State private var label: String = "Work"
@State private var pronouns: String = ""
@State private var bio: String = ""
// Contact details
@State private var email: String = ""
@State private var phone: String = ""
@State private var website: String = ""
@State private var location: String = ""
// Social media
@State private var linkedIn: String = ""
@State private var twitter: String = ""
@State private var instagram: String = ""
@State private var facebook: String = ""
@State private var tiktok: String = ""
@State private var github: String = ""
// Custom links
@State private var customLink1Title: String = ""
@State private var customLink1URL: String = ""
@State private var customLink2Title: String = ""
@State private var customLink2URL: String = ""
// Appearance
@State private var avatarSystemName: String = "person.crop.circle"
@State private var selectedTheme: CardTheme = .coral
@State private var selectedLayout: CardLayoutStyle = .stacked
// Photo
@State private var selectedPhoto: PhotosPickerItem?
@State private var photoData: Data?
private var isEditing: Bool { card != nil }
private var isFormValid: Bool {
!displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
Form {
Section {
CardPreviewSection(
displayName: displayName.isEmpty ? String.localized("Your Name") : displayName,
role: role.isEmpty ? String.localized("Your Role") : role,
company: company.isEmpty ? String.localized("Company") : company,
label: label,
avatarSystemName: avatarSystemName,
theme: selectedTheme,
layoutStyle: selectedLayout,
photoData: photoData
)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
Section(String.localized("Photo")) {
PhotoPickerRow(
selectedPhoto: $selectedPhoto,
photoData: $photoData,
avatarSystemName: avatarSystemName
)
}
Section(String.localized("Personal Information")) {
TextField(String.localized("Full Name"), text: $displayName)
.textContentType(.name)
TextField(String.localized("Pronouns"), text: $pronouns)
.accessibilityHint(String.localized("e.g. she/her, he/him, they/them"))
TextField(String.localized("Role / Title"), text: $role)
.textContentType(.jobTitle)
TextField(String.localized("Company"), text: $company)
.textContentType(.organizationName)
TextField(String.localized("Card Label"), text: $label)
.accessibilityHint(String.localized("A short label like Work or Personal"))
TextField(String.localized("Bio"), text: $bio, axis: .vertical)
.lineLimit(3...6)
.accessibilityHint(String.localized("A short description about yourself"))
}
Section(String.localized("Contact Details")) {
TextField(String.localized("Email"), text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
TextField(String.localized("Phone"), text: $phone)
.textContentType(.telephoneNumber)
.keyboardType(.phonePad)
TextField(String.localized("Website"), text: $website)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
TextField(String.localized("Location"), text: $location)
.textContentType(.fullStreetAddress)
}
Section(String.localized("Social Media")) {
SocialLinkField(
title: "LinkedIn",
placeholder: "linkedin.com/in/username",
systemImage: "link",
text: $linkedIn
)
SocialLinkField(
title: "Twitter / X",
placeholder: "twitter.com/username",
systemImage: "at",
text: $twitter
)
SocialLinkField(
title: "Instagram",
placeholder: "instagram.com/username",
systemImage: "camera",
text: $instagram
)
SocialLinkField(
title: "Facebook",
placeholder: "facebook.com/username",
systemImage: "person.2",
text: $facebook
)
SocialLinkField(
title: "TikTok",
placeholder: "tiktok.com/@username",
systemImage: "play.rectangle",
text: $tiktok
)
SocialLinkField(
title: "GitHub",
placeholder: "github.com/username",
systemImage: "chevron.left.forwardslash.chevron.right",
text: $github
)
}
Section(String.localized("Custom Links")) {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
TextField(String.localized("Link 1 Title"), text: $customLink1Title)
TextField(String.localized("Link 1 URL"), text: $customLink1URL)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
}
VStack(alignment: .leading, spacing: Design.Spacing.small) {
TextField(String.localized("Link 2 Title"), text: $customLink2Title)
TextField(String.localized("Link 2 URL"), text: $customLink2URL)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
}
}
Section(String.localized("Appearance")) {
AvatarPickerRow(selection: $avatarSystemName)
Picker(String.localized("Theme"), selection: $selectedTheme) {
ForEach(CardTheme.all) { theme in
HStack {
Circle()
.fill(theme.primaryColor)
.frame(width: Design.Spacing.large, height: Design.Spacing.large)
Text(theme.localizedName)
}
.tag(theme)
}
}
Picker(String.localized("Layout"), selection: $selectedLayout) {
ForEach(CardLayoutStyle.allCases) { layout in
Text(layout.displayName)
.tag(layout)
}
}
}
}
.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()
}
.disabled(!isFormValid)
}
}
.onChange(of: selectedPhoto) { _, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self) {
photoData = data
}
}
}
.onAppear {
loadCardData()
}
}
}
private func loadCardData() {
guard let card else { return }
displayName = card.displayName
role = card.role
company = card.company
label = card.label
pronouns = card.pronouns
bio = card.bio
email = card.email
phone = card.phone
website = card.website
location = card.location
linkedIn = card.linkedIn
twitter = card.twitter
instagram = card.instagram
facebook = card.facebook
tiktok = card.tiktok
github = card.github
customLink1Title = card.customLink1Title
customLink1URL = card.customLink1URL
customLink2Title = card.customLink2Title
customLink2URL = card.customLink2URL
avatarSystemName = card.avatarSystemName
selectedTheme = card.theme
selectedLayout = card.layoutStyle
photoData = card.photoData
}
private func saveCard() {
if let existingCard = card {
updateExistingCard(existingCard)
onSave(existingCard)
} else {
let newCard = createNewCard()
onSave(newCard)
}
dismiss()
}
private func updateExistingCard(_ card: BusinessCard) {
card.displayName = displayName
card.role = role
card.company = company
card.label = label
card.pronouns = pronouns
card.bio = bio
card.email = email
card.phone = phone
card.website = website
card.location = location
card.linkedIn = linkedIn
card.twitter = twitter
card.instagram = instagram
card.facebook = facebook
card.tiktok = tiktok
card.github = github
card.customLink1Title = customLink1Title
card.customLink1URL = customLink1URL
card.customLink2Title = customLink2Title
card.customLink2URL = customLink2URL
card.avatarSystemName = avatarSystemName
card.theme = selectedTheme
card.layoutStyle = selectedLayout
card.photoData = photoData
}
private func createNewCard() -> BusinessCard {
BusinessCard(
displayName: displayName,
role: role,
company: company,
label: label,
email: email,
phone: phone,
website: website,
location: location,
isDefault: false,
themeName: selectedTheme.name,
layoutStyleRawValue: selectedLayout.rawValue,
avatarSystemName: avatarSystemName,
pronouns: pronouns,
bio: bio,
linkedIn: linkedIn,
twitter: twitter,
instagram: instagram,
facebook: facebook,
tiktok: tiktok,
github: github,
customLink1Title: customLink1Title,
customLink1URL: customLink1URL,
customLink2Title: customLink2Title,
customLink2URL: customLink2URL,
photoData: photoData
)
}
}
private struct PhotoPickerRow: View {
@Binding var selectedPhoto: PhotosPickerItem?
@Binding var photoData: Data?
let avatarSystemName: String
var body: some View {
HStack(spacing: Design.Spacing.medium) {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
.clipShape(.circle)
} else {
Image(systemName: avatarSystemName)
.font(.title)
.foregroundStyle(Color.Accent.red)
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
.background(Color.AppBackground.accent)
.clipShape(.circle)
}
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
PhotosPicker(selection: $selectedPhoto, matching: .images) {
Text(photoData == nil ? String.localized("Add Photo") : String.localized("Change Photo"))
.foregroundStyle(Color.Accent.red)
}
if photoData != nil {
Button(String.localized("Remove Photo"), role: .destructive) {
photoData = nil
selectedPhoto = nil
}
.font(.caption)
}
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String.localized("Profile photo"))
}
}
private struct SocialLinkField: View {
let title: String
let placeholder: String
let systemImage: String
@Binding var text: String
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.foregroundStyle(Color.Accent.red)
.frame(width: Design.Spacing.xLarge)
TextField(title, text: $text, prompt: Text(placeholder))
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
}
.accessibilityLabel(title)
}
}
private struct CardPreviewSection: View {
let displayName: String
let role: String
let company: String
let label: String
let avatarSystemName: String
let theme: CardTheme
let layoutStyle: CardLayoutStyle
let photoData: Data?
var body: some View {
VStack(spacing: Design.Spacing.medium) {
previewCard
}
.padding(.vertical, Design.Spacing.medium)
}
private var previewCard: some View {
VStack(spacing: Design.Spacing.medium) {
HStack(spacing: Design.Spacing.medium) {
avatarView
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(displayName)
.font(.headline)
.bold()
.foregroundStyle(Color.Text.inverted)
Text(role)
.font(.subheadline)
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull))
Text(company)
.font(.caption)
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium))
}
Spacer(minLength: Design.Spacing.small)
Text(String.localized(label))
.font(.caption)
.bold()
.foregroundStyle(Color.Text.inverted)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall)
.background(theme.accentColor.opacity(Design.Opacity.medium))
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity)
.background(
LinearGradient(
colors: [theme.primaryColor, theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.hint),
radius: Design.Shadow.radiusLarge,
x: Design.Shadow.offsetNone,
y: Design.Shadow.offsetMedium
)
}
@ViewBuilder
private var avatarView: some View {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
.clipShape(.circle)
.overlay(
Circle()
.stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
)
} else {
Circle()
.fill(Color.Text.inverted)
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
.overlay(
Image(systemName: avatarSystemName)
.foregroundStyle(theme.accentColor)
)
.overlay(
Circle()
.stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
)
}
}
}
private struct AvatarPickerRow: View {
@Binding var selection: String
private let avatarOptions = [
"person.crop.circle",
"person.crop.circle.fill",
"person.crop.square",
"person.circle",
"sparkles",
"music.mic",
"briefcase.fill",
"building.2.fill",
"star.fill",
"bolt.fill"
]
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text("Icon (if no photo)")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.small) {
ForEach(avatarOptions, id: \.self) { icon in
Button {
selection = icon
} label: {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(selection == icon ? Color.Accent.red : Color.Text.secondary)
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
.background(selection == icon ? Color.AppBackground.accent : Color.AppBackground.base)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.buttonStyle(.plain)
.accessibilityLabel(icon)
.accessibilityAddTraits(selection == icon ? .isSelected : [])
}
}
}
}
}
#Preview("New Card") {
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
return CardEditorView(card: nil) { _ in }
.environment(AppState(modelContext: container.mainContext))
}