Appendix O: My Person CRM (Shop Manual)

Status: V1 in development. GPL v3. Public repo at github.com/fluhartyml/My-Person-CRM. Build-Along chapter: Part VII Build-Along 07. My Person CRM is a small personal CRM for iPhone — a private list of people the user wants to stay in touch with, plus a log of interactions per person. It bridges three Apple frameworks (Contacts, EventKit, SwiftData) without any external service. "CRM" is part of the public name — not abbreviated away in marketing copy.

What it is

A tracked-people index on the root screen. Tap a person, see their per-person detail with an interaction log. Add a new person by picking from the system Contacts via CNContactPickerViewController. Add an interaction by date + free-form note. Everything lives on-device in SwiftData. No network, no sync to a back-end, no analytics. The Contacts integration is read-only — the app stores a reference (an identifier and a snapshot of name + contact info), not the full CNContact.

Architecture overview

SwiftUI + SwiftData app with system Contacts and EventKit bridges. Small file count — the app's surface is the data model plus the two views around it.

Data model

Two SwiftData entities, related:

@Model
final class TrackedPerson {
    var contactIdentifier: String?   // CNContact identifier, optional
    var name: String
    var phone: String?
    var email: String?
    var notes: String

    @Relationship(deleteRule: .cascade) var interactions: [Interaction]

    init(name: String, /* ... */) { /* ... */ }
}

@Model
final class Interaction {
    var date: Date
    var note: String
    var trackedPerson: TrackedPerson?

    init(date: Date, note: String) { /* ... */ }
}

The contactIdentifier on TrackedPerson is the link back to the user's Contacts entry; the app stores only the identifier and a snapshot of name / phone / email at add-time. When the user opens a tracked person, the app can ask Contacts for the current details if the identifier is still valid — or fall back to the stored snapshot if the Contacts entry has been deleted.

The Contacts picker (Book 06 reference)

PersonPickerView wraps Apple's CNContactPickerViewController via UIViewControllerRepresentable. This is the canonical "system picker as intermediary, app receives only what the user chose" pattern. The flow:

  1. User taps the Add-Person toolbar button.
  2. The app presents PersonPickerView as a .sheet.
  3. CNContactPickerViewController shows the system's Contacts browser. The app cannot read the contacts list itself — only the system can.
  4. User picks a contact (or cancels). The delegate fires with the chosen CNContact.
  5. The app receives only the picked contact — not the rest of the user's address book.
  6. The app stores the contact's identifier + snapshot in SwiftData as a new TrackedPerson.

No contacts permission is required for this pattern. The system handles the privacy boundary — the app never sees a contact it didn't ask the user to pick.

Toolbar usage (Book 07 reference)

Four places where .toolbar with ToolbarItem appears in the app:

  1. People list view — Add-Person button (leading? trailing? — check source for the exact placement) and About button.
  2. Per-person detail view — Edit button and a contextual menu with additional actions.
  3. Add Interaction sheet — Cancel and Save toolbar items.
  4. About sheet — Done button.

Each toolbar configuration is a small SwiftUI block; reading all four together is the textbook example of .toolbar patterns at different presentation depths.

EventKit hook (CalendarStore)

The Calendar integration is opt-in per interaction: when adding an interaction, the user can check a box to also add a matching event to the system Calendar. CalendarStore handles the EventKit permission, the EKEvent creation, and the save. If the user denies Calendar permission, the box is hidden and the rest of the Add-Interaction flow works unchanged. The app doesn't read the system Calendar — it only adds events when the user asks.

Build + deployment

Known gotchas

Sources