450 lines
17 KiB
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))
|
|
}
|