536 lines
20 KiB
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))
|
|
}
|