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.
- App entry:
My_Person_CRMApp.swift—@mainscene with the SwiftDataModelContainerwired for the two model classes. - Root:
ContentView.swift— the tracked-peopleListwith swipe-to-delete, toolbar Add-Person and About buttons, and aNavigationStackthat pushes to per-person detail. - Detail:
PersonDetailView.swift— per-person view with a secondListfor the interaction log filtered by that person's identifier, a toolbar Edit + contextual menu, and an Add-Interaction button that presentsAddInteractionSheet. - Add Interaction:
AddInteractionSheet.swift—.sheet(isPresented:)presentation.DatePickerfor the date,TextEditorfor the note. Cancel and Save toolbar items. - Contact picker:
PersonPickerView.swift—UIViewControllerRepresentablewrappingCNContactPickerViewController. The canonical "system picker as intermediary" pattern referenced in Book 06. - Service stores:
ContactsStore.swift(Contacts framework wrapper),CalendarStore.swift(EventKit wrapper for adding interactions to the system calendar). - Data model:
Models.swift— the two SwiftData@Modelclasses:TrackedPersonandInteractionwith the relationship between them. - About:
AboutView.swift— standard about page sheet from the toolbar info button.
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:
- User taps the Add-Person toolbar button.
- The app presents
PersonPickerViewas a.sheet. CNContactPickerViewControllershows the system's Contacts browser. The app cannot read the contacts list itself — only the system can.- User picks a contact (or cancels). The delegate fires with the chosen
CNContact. - The app receives only the picked contact — not the rest of the user's address book.
- 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:
- People list view — Add-Person button (leading? trailing? — check source for the exact placement) and About button.
- Per-person detail view — Edit button and a contextual menu with additional actions.
- Add Interaction sheet — Cancel and Save toolbar items.
- 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
- App name: My Person CRM.
- Platforms: iPhone primary. iPad and Mac via Designed for iPad.
- Frameworks: SwiftUI, SwiftData (persistence), Contacts (system picker), ContactsUI (the picker UI), EventKit (Calendar integration), UIKit (the wrapped
UIViewController). - Required Info.plist keys:
NSContactsUsageDescriptiononly required if the app accesses contacts directly; the picker pattern avoids this.NSCalendarsUsageDescriptionrequired for the EventKit integration when the user opts in. - Privacy: Tracked-people data and interaction logs stay in SwiftData on-device. The picker pattern means the app never sees the user's full address book.
- Distribution: V1 in development. App Store is bonus tier per the book's distribution rule; GitHub clone-and-build is the default path.
Known gotchas
- The picker pattern is permission-free.
CNContactPickerViewControllerdoesn't requireNSContactsUsageDescriptionbecause the system mediates. If the app ever needs to read contacts directly (sync the tracked-people list against the user's address book, for example), the Contacts permission becomes mandatory and a new flow appears. - Contact identifiers can disappear. If the user deletes the underlying contact in Contacts.app,
CNContactStore.unifiedContact(withIdentifier:)returns nil. The app falls back to the stored snapshot but should not crash on the missing identifier. - SwiftData cascade delete on TrackedPerson. Deleting a tracked person deletes their interactions. Intentional; the interaction log is meaningless without the person it's about.
- CRM is in the public name on purpose. Don't auto-shorten to "My Person" in marketing or App Store copy.
Sources
- Project source: github.com/fluhartyml/My-Person-CRM (verified 2026-05-10).
- Build-Along chapter: Part VII Build-Along 07.
- Book 06 cross-reference:
CNContactPickerViewControllersystem-picker pattern. - Book 07 cross-reference:
.toolbar+ToolbarItemin four presentation contexts. - Book 08 cross-reference:
Listfor tracked-people index + swipe-to-delete + per-person interaction log. - Book 12 cross-reference:
.sheet(isPresented:)for Add Interaction; UIKit picker bridged into SwiftUI's sheet system. - Book 13 cross-reference:
NavigationStack+NavigationLinkdrill-in pattern. - Book 15 cross-reference: SwiftData two-model pattern (TrackedPerson + Interaction with relationship + cascade delete).