BusinessCard/BusinessCard/Views/Sheets/AddContactSheet.swift

450 lines
17 KiB
Swift

import SwiftUI
import SwiftData
import Bedrock
import PhotosUI
struct AddContactSheet: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
// Photo
@State private var photoData: Data?
@State private var showingPhotoSourcePicker = false
@State private var showingPhotoPicker = false
@State private var showingCamera = false
@State private var pendingAction: PendingPhotoAction?
private enum PendingPhotoAction {
case library
case camera
}
// Name fields
@State private var firstName = ""
@State private var lastName = ""
// Professional fields
@State private var jobTitle = ""
@State private var company = ""
// Contact fields with labels (multiple allowed)
@State private var phoneEntries: [LabeledEntry] = [LabeledEntry(label: "Cell", value: "")]
@State private var emailEntries: [LabeledEntry] = [LabeledEntry(label: "Work", value: "")]
@State private var linkEntries: [LabeledEntry] = [LabeledEntry(label: "Website", value: "")]
// Notes
@State private var notes = ""
private var canSave: Bool {
let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return hasName && !hasInvalidPhoneEntry && !hasInvalidEmailEntry && !hasInvalidLinkEntry
}
private var hasInvalidPhoneEntry: Bool {
phoneEntries.contains { entry in
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmed.isEmpty && !PhoneNumberText.isValid(trimmed)
}
}
private var hasInvalidEmailEntry: Bool {
emailEntries.contains { entry in
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmed.isEmpty && !EmailText.isValid(trimmed)
}
}
private var hasInvalidLinkEntry: Bool {
linkEntries.contains { entry in
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmed.isEmpty && !WebLinkText.isValid(trimmed)
}
}
private var fullName: String {
[firstName, lastName]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: " ")
}
var body: some View {
NavigationStack {
Form {
// Photo section
Section {
ContactPhotoRow(
photoData: $photoData,
onTap: { showingPhotoSourcePicker = true }
)
} header: {
Text("Photo")
}
// Name section
Section {
TextField(String.localized("First name"), text: $firstName)
.textContentType(.givenName)
TextField(String.localized("Last name"), text: $lastName)
.textContentType(.familyName)
}
// Professional section (moved before contact)
Section {
TextField(String.localized("Job title"), text: $jobTitle)
.textContentType(.jobTitle)
TextField(String.localized("Company"), text: $company)
.textContentType(.organizationName)
}
// Phone numbers section
Section {
ForEach($phoneEntries) { $entry in
LabeledFieldRow(
entry: $entry,
valuePlaceholder: "+1 (555) 123-4567",
labelSuggestions: ["Cell", "Work", "Home", "Main"],
keyboardType: .phonePad,
autocapitalization: .never,
formatValue: PhoneNumberText.formatted,
isValueValid: PhoneNumberText.isValid,
validationMessage: String.localized("Enter a valid phone number")
)
}
.onDelete { indexSet in
phoneEntries.remove(atOffsets: indexSet)
}
Button {
phoneEntries.append(LabeledEntry(label: "Cell", value: ""))
} label: {
Label(String.localized("Add phone"), systemImage: "plus.circle.fill")
}
} header: {
Text("Phone")
}
// Email section
Section {
ForEach($emailEntries) { $entry in
LabeledFieldRow(
entry: $entry,
valuePlaceholder: "email@example.com",
labelSuggestions: ["Work", "Personal", "Other"],
keyboardType: .emailAddress,
autocapitalization: .never,
isValueValid: EmailText.isValid,
validationMessage: String.localized("Enter a valid email address")
)
}
.onDelete { indexSet in
emailEntries.remove(atOffsets: indexSet)
}
Button {
emailEntries.append(LabeledEntry(label: "Work", value: ""))
} label: {
Label(String.localized("Add email"), systemImage: "plus.circle.fill")
}
} header: {
Text("Email")
}
// Links section
Section {
ForEach($linkEntries) { $entry in
LabeledFieldRow(
entry: $entry,
valuePlaceholder: "https://example.com",
labelSuggestions: ["Website", "Portfolio", "LinkedIn", "Other"],
keyboardType: .URL,
autocapitalization: .never,
isValueValid: WebLinkText.isValid,
validationMessage: String.localized("Enter a valid web link")
)
}
.onDelete { indexSet in
linkEntries.remove(atOffsets: indexSet)
}
Button {
linkEntries.append(LabeledEntry(label: "Website", value: ""))
} label: {
Label(String.localized("Add link"), systemImage: "plus.circle.fill")
}
} header: {
Text("Links")
}
// Notes section
Section {
TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical)
.lineLimit(3...8)
} header: {
Text("Notes")
}
}
.navigationTitle(String.localized("New contact"))
.navigationBarTitleDisplayMode(.inline)
.keyboardDismissable()
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
guard let action = pendingAction else { return }
pendingAction = nil
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
switch action {
case .library:
showingPhotoPicker = true
case .camera:
showingCamera = true
}
}
}) {
PhotoSourcePicker(
title: String.localized("Add profile picture"),
hasExistingPhoto: photoData != nil,
onSelectFromLibrary: {
pendingAction = .library
},
onTakePhoto: {
pendingAction = .camera
},
onRemovePhoto: {
photoData = nil
}
)
}
.fullScreenCover(isPresented: $showingPhotoPicker) {
NavigationStack {
PhotoPickerWithCropper(
onSave: { croppedData in
photoData = croppedData
showingPhotoPicker = false
},
onCancel: {
showingPhotoPicker = false
}
)
}
}
.fullScreenCover(isPresented: $showingCamera) {
CameraWithCropper(
onSave: { croppedData in
photoData = croppedData
showingCamera = false
},
onCancel: {
showingCamera = false
}
)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Save")) {
saveContact()
}
.disabled(!canSave)
}
}
}
}
private func saveContact() {
// Build contact fields from entries
var contactFields: [ContactField] = []
var orderIndex = 0
// Add phone entries
for entry in phoneEntries {
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard PhoneNumberText.isValid(trimmed) else { continue }
let field = ContactField(
typeId: "phone",
value: PhoneNumberText.normalizedForStorage(trimmed),
title: entry.label,
orderIndex: orderIndex
)
contactFields.append(field)
orderIndex += 1
}
// Add email entries
for entry in emailEntries {
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard EmailText.isValid(trimmed) else { continue }
let field = ContactField(
typeId: "email",
value: EmailText.normalizedForStorage(trimmed),
title: entry.label,
orderIndex: orderIndex
)
contactFields.append(field)
orderIndex += 1
}
// Add link entries
for entry in linkEntries {
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard WebLinkText.isValid(trimmed) else { continue }
let field = ContactField(
typeId: "customLink",
value: WebLinkText.normalizedForStorage(trimmed),
title: entry.label,
orderIndex: orderIndex
)
contactFields.append(field)
orderIndex += 1
}
appState.contactsStore.createContact(
name: fullName,
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
contactFields: contactFields,
photoData: photoData
)
dismiss()
}
}
// MARK: - Contact Photo Row
private struct ContactPhotoRow: View {
@Binding var photoData: Data?
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.medium) {
// Photo preview
Group {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Image(systemName: "person.crop.circle.fill")
.typography(.title2)
.foregroundStyle(Color.Text.tertiary)
}
}
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
.clipShape(.circle)
.overlay(Circle().stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin))
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text("Profile Photo")
.typography(.subheading)
.foregroundStyle(Color.Text.primary)
Text(photoData == nil ? String.localized("Add a photo") : String.localized("Tap to change"))
.typography(.caption)
.foregroundStyle(Color.Text.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.typography(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.padding(.vertical, Design.Spacing.xSmall)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
// MARK: - Labeled Entry Model
private struct LabeledEntry: Identifiable {
let id = UUID()
var label: String
var value: String
}
// MARK: - Labeled Field Row
private struct LabeledFieldRow: View {
@Binding var entry: LabeledEntry
let valuePlaceholder: String
let labelSuggestions: [String]
var keyboardType: UIKeyboardType = .default
var autocapitalization: TextInputAutocapitalization = .sentences
var formatValue: ((String) -> String)?
var isValueValid: ((String) -> Bool)?
var validationMessage: String?
private var trimmedValue: String {
entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var showsValidationError: Bool {
guard let isValueValid else { return false }
guard !trimmedValue.isEmpty else { return false }
return !isValueValid(trimmedValue)
}
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
HStack(spacing: Design.Spacing.medium) {
// Label picker
Menu {
ForEach(labelSuggestions, id: \.self) { suggestion in
Button(suggestion) {
entry.label = suggestion
}
}
} label: {
HStack(spacing: Design.Spacing.xSmall) {
Text(entry.label)
.foregroundStyle(Color.accentColor)
Image(systemName: "chevron.up.chevron.down")
.typography(.caption2)
.foregroundStyle(Color.secondary)
}
}
.frame(width: 80, alignment: .leading)
// Value field
TextField(valuePlaceholder, text: $entry.value)
.keyboardType(keyboardType)
.textInputAutocapitalization(autocapitalization)
.onChange(of: entry.value) { _, newValue in
guard let formatValue else { return }
let formatted = formatValue(newValue)
if formatted != newValue {
entry.value = formatted
}
}
}
if showsValidationError, let validationMessage {
Text(validationMessage)
.typography(.caption)
.foregroundStyle(Color.Accent.red)
.padding(.leading, 80 + Design.Spacing.medium)
}
}
}
}
#Preview {
AddContactSheet()
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
}