298 lines
12 KiB
Swift
298 lines
12 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct CardEditorView: View {
|
|
@Environment(AppState.self) private var appState
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
let card: BusinessCard?
|
|
let onSave: (BusinessCard) -> Void
|
|
|
|
@State private var displayName: String = ""
|
|
@State private var role: String = ""
|
|
@State private var company: String = ""
|
|
@State private var label: String = "Work"
|
|
@State private var email: String = ""
|
|
@State private var phone: String = ""
|
|
@State private var website: String = ""
|
|
@State private var location: String = ""
|
|
@State private var avatarSystemName: String = "person.crop.circle"
|
|
@State private var selectedTheme: CardTheme = .coral
|
|
@State private var selectedLayout: CardLayoutStyle = .stacked
|
|
|
|
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
|
|
)
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowInsets(EdgeInsets())
|
|
|
|
Section(String.localized("Personal Information")) {
|
|
TextField(String.localized("Full Name"), text: $displayName)
|
|
.textContentType(.name)
|
|
.accessibilityLabel(String.localized("Full Name"))
|
|
|
|
TextField(String.localized("Role / Title"), text: $role)
|
|
.textContentType(.jobTitle)
|
|
.accessibilityLabel(String.localized("Role"))
|
|
|
|
TextField(String.localized("Company"), text: $company)
|
|
.textContentType(.organizationName)
|
|
.accessibilityLabel(String.localized("Company"))
|
|
|
|
TextField(String.localized("Card Label"), text: $label)
|
|
.accessibilityLabel(String.localized("Card Label"))
|
|
.accessibilityHint(String.localized("A short label like Work or Personal"))
|
|
}
|
|
|
|
Section(String.localized("Contact Details")) {
|
|
TextField(String.localized("Email"), text: $email)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.textInputAutocapitalization(.never)
|
|
.accessibilityLabel(String.localized("Email"))
|
|
|
|
TextField(String.localized("Phone"), text: $phone)
|
|
.textContentType(.telephoneNumber)
|
|
.keyboardType(.phonePad)
|
|
.accessibilityLabel(String.localized("Phone"))
|
|
|
|
TextField(String.localized("Website"), text: $website)
|
|
.textContentType(.URL)
|
|
.keyboardType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
.accessibilityLabel(String.localized("Website"))
|
|
|
|
TextField(String.localized("Location"), text: $location)
|
|
.textContentType(.fullStreetAddress)
|
|
.accessibilityLabel(String.localized("Location"))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
.onAppear {
|
|
if let card {
|
|
displayName = card.displayName
|
|
role = card.role
|
|
company = card.company
|
|
label = card.label
|
|
email = card.email
|
|
phone = card.phone
|
|
website = card.website
|
|
location = card.location
|
|
avatarSystemName = card.avatarSystemName
|
|
selectedTheme = card.theme
|
|
selectedLayout = card.layoutStyle
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveCard() {
|
|
if let existingCard = card {
|
|
existingCard.displayName = displayName
|
|
existingCard.role = role
|
|
existingCard.company = company
|
|
existingCard.label = label
|
|
existingCard.email = email
|
|
existingCard.phone = phone
|
|
existingCard.website = website
|
|
existingCard.location = location
|
|
existingCard.avatarSystemName = avatarSystemName
|
|
existingCard.theme = selectedTheme
|
|
existingCard.layoutStyle = selectedLayout
|
|
onSave(existingCard)
|
|
} else {
|
|
let newCard = 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
|
|
)
|
|
onSave(newCard)
|
|
}
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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) {
|
|
Circle()
|
|
.fill(Color.Text.inverted)
|
|
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
|
|
.overlay(
|
|
Image(systemName: avatarSystemName)
|
|
.foregroundStyle(theme.accentColor)
|
|
)
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
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")
|
|
.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))
|
|
}
|