๐Ÿ“‡ Contacts

iOS ์—ฐ๋ฝ์ฒ˜ ๋ฐ์ดํ„ฐ์— ์•ˆ์ „ํ•˜๊ฒŒ ์ ‘๊ทผํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๊ธฐ

iOS 9+

โœจ Contacts Framework๋ž€?

Contacts ํ”„๋ ˆ์ž„์›Œํฌ๋Š” ์‚ฌ์šฉ์ž์˜ ์—ฐ๋ฝ์ฒ˜ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์•ˆ์ „ํ•˜๊ฒŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” API๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์—ฐ๋ฝ์ฒ˜ ์กฐํšŒ, ์ƒ์„ฑ, ์ˆ˜์ •, ์‚ญ์ œ๋ฅผ ์ง€์›ํ•˜๋ฉฐ, CNContactStore๋ฅผ ํ†ตํ•ด ์ค‘์•™ ์ง‘์ค‘์‹์œผ๋กœ ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž ๊ถŒํ•œ์ด ํ•„์ˆ˜์ด๋ฉฐ, ๊ฐœ์ธ์ •๋ณด ๋ณดํ˜ธ๋ฅผ ์ตœ์šฐ์„ ์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ’ก ํ•ต์‹ฌ ๊ธฐ๋Šฅ: ์—ฐ๋ฝ์ฒ˜ ์กฐํšŒ ยท ์—ฐ๋ฝ์ฒ˜ ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ ยท ์—ฐ๋ฝ์ฒ˜ ๊ทธ๋ฃน ๊ด€๋ฆฌ ยท ์—ฐ๋ฝ์ฒ˜ ํฌ๋งทํŒ… ยท ์—ฐ๋ฝ์ฒ˜ ์„ ํƒ๊ธฐ UI ยท ๋ณ€๊ฒฝ ์‚ฌํ•ญ ๊ด€์ฐฐ ยท ๋ฐฐ์น˜ ์ €์žฅ

๐Ÿ”‘ 1. ๊ถŒํ•œ ์š”์ฒญ

์—ฐ๋ฝ์ฒ˜ ์ ‘๊ทผ์„ ์œ„ํ•ด์„œ๋Š” ์‚ฌ์šฉ์ž ๊ถŒํ•œ์ด ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค. Info.plist ์„ค์ •๊ณผ ๋Ÿฐํƒ€์ž„ ๊ถŒํ•œ ์š”์ฒญ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

Info.plist โ€” ๊ถŒํ•œ ์„ค๋ช… ์ถ”๊ฐ€
// Info.plist์— ์ถ”๊ฐ€
NSContactsUsageDescription
"์—ฐ๋ฝ์ฒ˜์— ์ ‘๊ทผํ•˜์—ฌ ์นœ๊ตฌ๋ฅผ ์ดˆ๋Œ€ํ•˜๊ณ  ์ •๋ณด๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค"
ContactsManager.swift โ€” ๊ถŒํ•œ ๊ด€๋ฆฌ
import Contacts

@Observable
class ContactsManager {
    let store = CNContactStore()
    var authorizationStatus: CNAuthorizationStatus = .notDetermined

    // ํ˜„์žฌ ๊ถŒํ•œ ์ƒํƒœ ํ™•์ธ
    func checkAuthorizationStatus() {
        authorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
    }

    // ๊ถŒํ•œ ์š”์ฒญ
    func requestAccess() async -> Bool {
        do {
            let granted = try await store.requestAccess(for: .contacts)
            await MainActor.run {
                authorizationStatus = granted ? .authorized : .denied
            }
            return granted
        } catch {
            print("๊ถŒํ•œ ์š”์ฒญ ์˜ค๋ฅ˜: \(error)")
            return false
        }
    }

    // ๊ถŒํ•œ ์ƒํƒœ์— ๋”ฐ๋ฅธ ์ฒ˜๋ฆฌ
    func ensureAuthorization() async -> Bool {
        checkAuthorizationStatus()

        switch authorizationStatus {
        case .authorized:
            return true
        case .notDetermined:
            return await requestAccess()
        case .denied, .restricted:
            return false
        @unknown default:
            return false
        }
    }
}

๐Ÿ” 2. ์—ฐ๋ฝ์ฒ˜ ์กฐํšŒ

๋‹ค์–‘ํ•œ ๋ฐฉ๋ฒ•์œผ๋กœ ์—ฐ๋ฝ์ฒ˜๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ณ  ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.

ContactsFetcher.swift โ€” ์—ฐ๋ฝ์ฒ˜ ์กฐํšŒ
import Contacts

extension ContactsManager {
    // ๋ชจ๋“  ์—ฐ๋ฝ์ฒ˜ ๊ฐ€์ ธ์˜ค๊ธฐ
    func fetchAllContacts() throws -> [CNContact] {
        let keysToFetch: [CNKeyDescriptor] = [
            CNContactGivenNameKey as CNKeyDescriptor,
            CNContactFamilyNameKey as CNKeyDescriptor,
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor,
            CNContactImageDataKey as CNKeyDescriptor,
            CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
        ]

        let request = CNContactFetchRequest(keysToFetch: keysToFetch)
        var contacts: [CNContact] = []

        try store.enumerateContacts(with: request) { contact, stop in
            contacts.append(contact)
        }

        return contacts
    }

    // ์ด๋ฆ„์œผ๋กœ ์—ฐ๋ฝ์ฒ˜ ๊ฒ€์ƒ‰
    func searchContacts(name: String) throws -> [CNContact] {
        let keysToFetch: [CNKeyDescriptor] = [
            CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor
        ]

        let predicate = CNContact.predicateForContacts(matchingName: name)
        return try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
    }

    // ์‹๋ณ„์ž๋กœ ์—ฐ๋ฝ์ฒ˜ ๊ฐ€์ ธ์˜ค๊ธฐ
    func fetchContact(identifier: String) throws -> CNContact {
        let keysToFetch: [CNKeyDescriptor] = [
            CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor,
            CNContactPostalAddressesKey as CNKeyDescriptor,
            CNContactBirthdayKey as CNKeyDescriptor,
            CNContactImageDataKey as CNKeyDescriptor
        ]

        let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
        let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)

        guard let contact = contacts.first else {
            throw ContactsError.contactNotFound
        }

        return contact
    }

    // ์ „ํ™”๋ฒˆํ˜ธ๋กœ ์—ฐ๋ฝ์ฒ˜ ๊ฒ€์ƒ‰
    func searchByPhoneNumber(_ phoneNumber: String) throws -> [CNContact] {
        let keysToFetch: [CNKeyDescriptor] = [
            CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
            CNContactPhoneNumbersKey as CNKeyDescriptor
        ]

        let allContacts = try fetchAllContacts()

        return allContacts.filter { contact in
            contact.phoneNumbers.contains { labeledValue in
                labeledValue.value.stringValue.contains(phoneNumber)
            }
        }
    }
}

enum ContactsError: Error {
    case contactNotFound
    case unauthorized
    case saveFailed
}

โž• 3. ์—ฐ๋ฝ์ฒ˜ ์ƒ์„ฑ

์ƒˆ๋กœ์šด ์—ฐ๋ฝ์ฒ˜๋ฅผ ๋งŒ๋“ค๊ณ  ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

ContactsCreator.swift โ€” ์—ฐ๋ฝ์ฒ˜ ์ƒ์„ฑ
import Contacts

extension ContactsManager {
    // ์ƒˆ ์—ฐ๋ฝ์ฒ˜ ์ƒ์„ฑ
    func createContact(
        givenName: String,
        familyName: String,
        phoneNumbers: [String] = [],
        emails: [String] = []
    ) throws {
        let contact = CNMutableContact()

        // ์ด๋ฆ„ ์„ค์ •
        contact.givenName = givenName
        contact.familyName = familyName

        // ์ „ํ™”๋ฒˆํ˜ธ ์ถ”๊ฐ€
        contact.phoneNumbers = phoneNumbers.map { number in
            CNLabeledValue(
                label: CNLabelPhoneNumberMobile,
                value: CNPhoneNumber(stringValue: number)
            )
        }

        // ์ด๋ฉ”์ผ ์ถ”๊ฐ€
        contact.emailAddresses = emails.map { email in
            CNLabeledValue(
                label: CNLabelHome,
                value: email as NSString
            )
        }

        // ์—ฐ๋ฝ์ฒ˜ ์ €์žฅ
        let saveRequest = CNSaveRequest()
        saveRequest.add(contact, toContainerWithIdentifier: nil)

        try store.execute(saveRequest)
    }

    // ์ƒ์„ธ ์ •๋ณด์™€ ํ•จ๊ป˜ ์—ฐ๋ฝ์ฒ˜ ์ƒ์„ฑ
    func createDetailedContact(
        givenName: String,
        familyName: String,
        organizationName: String? = nil,
        jobTitle: String? = nil,
        phoneNumbers: [(String, String)] = [], // (label, number)
        emails: [(String, String)] = [], // (label, email)
        postalAddress: CNPostalAddress? = nil,
        birthday: DateComponents? = nil,
        note: String? = nil
    ) throws {
        let contact = CNMutableContact()

        // ๊ธฐ๋ณธ ์ •๋ณด
        contact.givenName = givenName
        contact.familyName = familyName
        contact.organizationName = organizationName ?? ""
        contact.jobTitle = jobTitle ?? ""

        // ์ „ํ™”๋ฒˆํ˜ธ
        contact.phoneNumbers = phoneNumbers.map { label, number in
            CNLabeledValue(
                label: label,
                value: CNPhoneNumber(stringValue: number)
            )
        }

        // ์ด๋ฉ”์ผ
        contact.emailAddresses = emails.map { label, email in
            CNLabeledValue(
                label: label,
                value: email as NSString
            )
        }

        // ์ฃผ์†Œ
        if let address = postalAddress {
            contact.postalAddresses = [
                CNLabeledValue(label: CNLabelHome, value: address)
            ]
        }

        // ์ƒ์ผ
        contact.birthday = birthday

        // ๋ฉ”๋ชจ
        contact.note = note ?? ""

        // ์ €์žฅ
        let saveRequest = CNSaveRequest()
        saveRequest.add(contact, toContainerWithIdentifier: nil)
        try store.execute(saveRequest)
    }
}

โœ๏ธ 4. ์—ฐ๋ฝ์ฒ˜ ์ˆ˜์ • ๋ฐ ์‚ญ์ œ

๊ธฐ์กด ์—ฐ๋ฝ์ฒ˜๋ฅผ ์ˆ˜์ •ํ•˜๊ฑฐ๋‚˜ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

ContactsEditor.swift โ€” ์—ฐ๋ฝ์ฒ˜ ์ˆ˜์ •/์‚ญ์ œ
import Contacts

extension ContactsManager {
    // ์—ฐ๋ฝ์ฒ˜ ์ˆ˜์ •
    func updateContact(
        identifier: String,
        givenName: String? = nil,
        familyName: String? = nil,
        phoneNumbers: [String]? = nil
    ) throws {
        // ๊ธฐ์กด ์—ฐ๋ฝ์ฒ˜ ๊ฐ€์ ธ์˜ค๊ธฐ
        let keysToFetch: [CNKeyDescriptor] = [
            CNContactGivenNameKey as CNKeyDescriptor,
            CNContactFamilyNameKey as CNKeyDescriptor,
            CNContactPhoneNumbersKey as CNKeyDescriptor
        ]

        let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
        let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)

        guard let existingContact = contacts.first else {
            throw ContactsError.contactNotFound
        }

        // ๋ณ€๊ฒฝ ๊ฐ€๋Šฅํ•œ ๋ณต์‚ฌ๋ณธ ๋งŒ๋“ค๊ธฐ
        let mutableContact = existingContact.mutableCopy() as! CNMutableContact

        // ์ˆ˜์ •
        if let givenName = givenName {
            mutableContact.givenName = givenName
        }

        if let familyName = familyName {
            mutableContact.familyName = familyName
        }

        if let phoneNumbers = phoneNumbers {
            mutableContact.phoneNumbers = phoneNumbers.map { number in
                CNLabeledValue(
                    label: CNLabelPhoneNumberMobile,
                    value: CNPhoneNumber(stringValue: number)
                )
            }
        }

        // ์ €์žฅ
        let saveRequest = CNSaveRequest()
        saveRequest.update(mutableContact)
        try store.execute(saveRequest)
    }

    // ์—ฐ๋ฝ์ฒ˜ ์‚ญ์ œ
    func deleteContact(identifier: String) throws {
        let keysToFetch: [CNKeyDescriptor] = [
            CNContactIdentifierKey as CNKeyDescriptor
        ]

        let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
        let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)

        guard let contact = contacts.first else {
            throw ContactsError.contactNotFound
        }

        let mutableContact = contact.mutableCopy() as! CNMutableContact

        let saveRequest = CNSaveRequest()
        saveRequest.delete(mutableContact)
        try store.execute(saveRequest)
    }

    // ์ „ํ™”๋ฒˆํ˜ธ ์ถ”๊ฐ€
    func addPhoneNumber(to identifier: String, phoneNumber: String, label: String) throws {
        let keysToFetch: [CNKeyDescriptor] = [
            CNContactPhoneNumbersKey as CNKeyDescriptor
        ]

        let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
        let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)

        guard let contact = contacts.first else {
            throw ContactsError.contactNotFound
        }

        let mutableContact = contact.mutableCopy() as! CNMutableContact

        // ์ƒˆ ์ „ํ™”๋ฒˆํ˜ธ ์ถ”๊ฐ€
        let newPhoneNumber = CNLabeledValue(
            label: label,
            value: CNPhoneNumber(stringValue: phoneNumber)
        )
        mutableContact.phoneNumbers.append(newPhoneNumber)

        let saveRequest = CNSaveRequest()
        saveRequest.update(mutableContact)
        try store.execute(saveRequest)
    }
}

๐Ÿ“ฑ 5. SwiftUI ํ†ตํ•ฉ - ์—ฐ๋ฝ์ฒ˜ ์„ ํƒ๊ธฐ

์‹œ์Šคํ…œ ์—ฐ๋ฝ์ฒ˜ ์„ ํƒ๊ธฐ๋ฅผ SwiftUI์—์„œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

ContactPickerView.swift โ€” ์—ฐ๋ฝ์ฒ˜ ์„ ํƒ๊ธฐ
import SwiftUI
import ContactsUI

struct ContactPickerView: UIViewControllerRepresentable {
    @Binding var selectedContact: CNContact?
    @Environment(\.dismiss) var dismiss

    func makeUIViewController(context: Context) -> CNContactPickerViewController {
        let picker = CNContactPickerViewController()
        picker.delegate = context.coordinator

        // ํ‘œ์‹œํ•  ์†์„ฑ ์ง€์ •
        picker.displayedPropertyKeys = [
            CNContactPhoneNumbersKey,
            CNContactEmailAddressesKey
        ]

        return picker
    }

    func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(selectedContact: $selectedContact, dismiss: dismiss)
    }

    class Coordinator: NSObject, CNContactPickerDelegate {
        @Binding var selectedContact: CNContact?
        let dismiss: DismissAction

        init(selectedContact: Binding<CNContact?>, dismiss: DismissAction) {
            _selectedContact = selectedContact
            self.dismiss = dismiss
        }

        // ์—ฐ๋ฝ์ฒ˜ ์„ ํƒ ์™„๋ฃŒ
        func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
            selectedContact = contact
            dismiss()
        }

        // ์ทจ์†Œ
        func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
            dismiss()
        }
    }
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
struct ContactSelectorView: View {
    @State private var selectedContact: CNContact?
    @State private var showPicker = false

    var body: some View {
        VStack(spacing: 20) {
            if let contact = selectedContact {
                VStack(alignment: .leading, spacing: 8) {
                    Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "์•Œ ์ˆ˜ ์—†์Œ")
                        .font(.headline)

                    if let phoneNumber = contact.phoneNumbers.first?.value.stringValue {
                        Text(phoneNumber)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(8)
            } else {
                Text("์—ฐ๋ฝ์ฒ˜๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค")
                    .foregroundStyle(.secondary)
            }

            Button("์—ฐ๋ฝ์ฒ˜ ์„ ํƒ") {
                showPicker = true
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .sheet(isPresented: $showPicker) {
            ContactPickerView(selectedContact: $selectedContact)
        }
    }
}

๐ŸŽจ 6. ์—ฐ๋ฝ์ฒ˜ ํฌ๋งทํŒ…

CNContactFormatter๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์—ฐ๋ฝ์ฒ˜ ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

ContactFormatter.swift โ€” ์—ฐ๋ฝ์ฒ˜ ํฌ๋งทํŒ…
import Contacts

class ContactFormatterHelper {
    // ์ „์ฒด ์ด๋ฆ„ ํฌ๋งท
    func formatFullName(_ contact: CNContact) -> String {
        return CNContactFormatter.string(from: contact, style: .fullName) ?? "์•Œ ์ˆ˜ ์—†์Œ"
    }

    // ์ „ํ™”๋ฒˆํ˜ธ ํฌ๋งท
    func formatPhoneNumber(_ phoneNumber: CNPhoneNumber) -> String {
        let formatter = CNPhoneNumberFormatter()
        return formatter.string(from: phoneNumber)
    }

    // ์ฃผ์†Œ ํฌ๋งท
    func formatPostalAddress(_ address: CNPostalAddress) -> String {
        let formatter = CNPostalAddressFormatter()
        return formatter.string(from: address)
    }

    // ์—ฐ๋ฝ์ฒ˜ ๋ ˆ์ด๋ธ” ํ˜„์ง€ํ™”
    func localizedLabel(for label: String) -> String {
        return CNLabeledValue<NSString>.localizedString(forLabel: label)
    }
}

๐Ÿ“ฑ ์ข…ํ•ฉ ์˜ˆ์ œ - ์—ฐ๋ฝ์ฒ˜ ๊ด€๋ฆฌ ์•ฑ

ContactsAppView.swift โ€” ์—ฐ๋ฝ์ฒ˜ ๊ด€๋ฆฌ ์•ฑ
import SwiftUI
import Contacts

struct ContactsAppView: View {
    @State private var manager = ContactsManager()
    @State private var contacts: [CNContact] = []
    @State private var searchText = ""
    @State private var showingAddContact = false
    @State private var errorMessage: String?

    var filteredContacts: [CNContact] {
        if searchText.isEmpty {
            return contacts
        } else {
            return contacts.filter { contact in
                let fullName = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
                return fullName.localizedCaseInsensitiveContains(searchText)
            }
        }
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(filteredContacts, id: \.identifier) { contact in
                    NavigationLink {
                        ContactDetailView(contact: contact, manager: manager)
                    } label: {
                        ContactRow(contact: contact)
                    }
                }
                .onDelete(perform: deleteContacts)
            }
            .navigationTitle("์—ฐ๋ฝ์ฒ˜")
            .searchable(text: $searchText, prompt: "์—ฐ๋ฝ์ฒ˜ ๊ฒ€์ƒ‰")
            .toolbar {
                Button {
                    showingAddContact = true
                } label: {
                    Image(systemName: "plus")
                }
            }
            .sheet(isPresented: $showingAddContact) {
                AddContactView(manager: manager) {
                    Task {
                        await loadContacts()
                    }
                }
            }
            .alert("์˜ค๋ฅ˜", isPresented: .constant(errorMessage != nil)) {
                Button("ํ™•์ธ") {
                    errorMessage = nil
                }
            } message: {
                Text(errorMessage ?? "")
            }
            .task {
                await loadContacts()
            }
        }
    }

    func loadContacts() async {
        let authorized = await manager.ensureAuthorization()

        guard authorized else {
            errorMessage = "์—ฐ๋ฝ์ฒ˜ ์ ‘๊ทผ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
            return
        }

        do {
            let fetchedContacts = try manager.fetchAllContacts()
            await MainActor.run {
                contacts = fetchedContacts.sorted { c1, c2 in
                    let name1 = CNContactFormatter.string(from: c1, style: .fullName) ?? ""
                    let name2 = CNContactFormatter.string(from: c2, style: .fullName) ?? ""
                    return name1 < name2
                }
            }
        } catch {
            errorMessage = "์—ฐ๋ฝ์ฒ˜๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"
        }
    }

    func deleteContacts(at offsets: IndexSet) {
        for index in offsets {
            let contact = filteredContacts[index]
            do {
                try manager.deleteContact(identifier: contact.identifier)
            } catch {
                errorMessage = "์—ฐ๋ฝ์ฒ˜ ์‚ญ์ œ ์‹คํŒจ"
            }
        }

        Task {
            await loadContacts()
        }
    }
}

struct ContactRow: View {
    let contact: CNContact

    var body: some View {
        HStack {
            if let imageData = contact.imageData,
               let uiImage = UIImage(data: imageData) {
                Image(uiImage: uiImage)
                    .resizable()
                    .scaledToFill()
                    .frame(width: 40, height: 40)
                    .clipShape(Circle())
            } else {
                Image(systemName: "person.circle.fill")
                    .resizable()
                    .frame(width: 40, height: 40)
                    .foregroundStyle(.gray)
            }

            VStack(alignment: .leading) {
                Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "์•Œ ์ˆ˜ ์—†์Œ")
                    .font(.headline)

                if let phoneNumber = contact.phoneNumbers.first?.value.stringValue {
                    Text(phoneNumber)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }
        }
    }
}

struct ContactDetailView: View {
    let contact: CNContact
    let manager: ContactsManager

    var body: some View {
        List {
            Section("์ „ํ™”๋ฒˆํ˜ธ") {
                ForEach(contact.phoneNumbers, id: \.identifier) { labeledValue in
                    HStack {
                        Text(CNLabeledValue<NSString>.localizedString(forLabel: labeledValue.label ?? ""))
                            .foregroundStyle(.secondary)
                        Spacer()
                        Text(labeledValue.value.stringValue)
                    }
                }
            }

            Section("์ด๋ฉ”์ผ") {
                ForEach(contact.emailAddresses, id: \.identifier) { labeledValue in
                    HStack {
                        Text(CNLabeledValue<NSString>.localizedString(forLabel: labeledValue.label ?? ""))
                            .foregroundStyle(.secondary)
                        Spacer()
                        Text(labeledValue.value as String)
                    }
                }
            }
        }
        .navigationTitle(CNContactFormatter.string(from: contact, style: .fullName) ?? "์—ฐ๋ฝ์ฒ˜")
    }
}

struct AddContactView: View {
    let manager: ContactsManager
    let onSave: () -> Void

    @State private var givenName = ""
    @State private var familyName = ""
    @State private var phoneNumber = ""
    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationStack {
            Form {
                TextField("์ด๋ฆ„", text: $givenName)
                TextField("์„ฑ", text: $familyName)
                TextField("์ „ํ™”๋ฒˆํ˜ธ", text: $phoneNumber)
                    .keyboardType(.phonePad)
            }
            .navigationTitle("์ƒˆ ์—ฐ๋ฝ์ฒ˜")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("์ทจ์†Œ") {
                        dismiss()
                    }
                }

                ToolbarItem(placement: .confirmationAction) {
                    Button("์ €์žฅ") {
                        saveContact()
                    }
                    .disabled(givenName.isEmpty || familyName.isEmpty)
                }
            }
        }
    }

    func saveContact() {
        do {
            try manager.createContact(
                givenName: givenName,
                familyName: familyName,
                phoneNumbers: phoneNumber.isEmpty ? [] : [phoneNumber]
            )
            onSave()
            dismiss()
        } catch {
            print("์—ฐ๋ฝ์ฒ˜ ์ €์žฅ ์‹คํŒจ: \(error)")
        }
    }
}

๐Ÿ’ก HIG ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ์„ฑ๋Šฅ ํŒ: ์—ฐ๋ฝ์ฒ˜ ์กฐํšŒ ์‹œ ํ•„์š”ํ•œ ํ‚ค๋งŒ keysToFetch์— ํฌํ•จ์‹œํ‚ค์„ธ์š”. ๋ชจ๋“  ํ‚ค๋ฅผ ๊ฐ€์ ธ์˜ค๋ฉด ์„ฑ๋Šฅ์ด ์ €ํ•˜๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํŠนํžˆ ์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๋Š” ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—๋งŒ ์š”์ฒญํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.