YearlyQuest started life as a tap-based habit tracker: open the app, find today's square, log a value. That is quick, but it still asks you to open the app. I was curious whether I could log a trend by voice, hands free, with no taps at all. The path there was an App Intent, and the part I most enjoyed was teaching that intent to share one database with an app it does not actually live inside.
An intent lives outside the app
The first thing that surprised me about App Intents is how isolated they are. SetTrendDataIntent can be invoked by Siri or Shortcuts while the app is not running, and it never enters the normal SwiftUI lifecycle. There is no @Environment(\.modelContext) to borrow, because there is no SwiftUI environment to read from. The intent has to stand up its own access to the data.
What unblocked me was remembering that SwiftData is, underneath, just a store on disk described by a schema. So the intent builds its own ModelContainer over the exact same schema the app declares: the Trend and TrendData models, with isStoredInMemoryOnly: false so it resolves to the same persistent store. The app's YearlyApp and the intent are then two clients of one file. Because the app uses an iCloud container and CloudKit, the on-disk store is the synced one too, which is why both sides declare the identical schema rather than a convenient subset.
// The intent stands up its own container over the same schema.
let schema = Schema([Trend.self, TrendData.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
let container = try ModelContainer(for: schema, configurations: [config])
let modelContext = container.mainContext
One detail that follows from CloudKit: every stored property needs a default value. That is why the models read a little defensively, with year: Int = 1999 and color: String = "990000". CloudKit cannot express required fields, so SwiftData enforces optionality-or-defaults across the schema, and the intent inherits exactly those rules.
Making trends something Siri can name
Voice logging only works if Siri can talk about a user's trends by name. A trend like "Sleep" or "Workout" is data the user defined, not anything I hardcoded, so I had to expose those values to the intents system at runtime. I did that with a TrendNameEntity conforming to AppEntity, backed by a TrendNameEntityQuery: EntityQuery. The entity gives each trend a stable id and a display name; the query is how the system fetches the current set so it can suggest them, match them against speech, and disambiguate.
The query is where a small but real impedance mismatch showed up. My Trend model stores its identifier as a String (uuid: String), but AppEntity wants a Hashable id, and a real UUID is the honest type. So the query round-trips through UUID(uuidString:) in both directions, and quietly drops anything that fails to parse with compactMap. It also fetches with a predicate so deleted trends never reach Siri at all:
let descriptor = FetchDescriptor<Trend>(
predicate: #Predicate { $0.removedByUser == false },
sortBy: [SortDescriptor(\.name)]
)
let trends = try modelContext.fetch(descriptor)
return trends.compactMap { trend in
guard let id = UUID(uuidString: trend.uuid) else { return nil }
return TrendNameEntity(id: id, name: trend.name)
}
The removedByUser flag matters here. YearlyQuest soft-deletes trends rather than tearing out their history, so a removed trend still exists in the store. Filtering it out of suggestedEntities() means Siri will never offer to log a trend the user thinks they got rid of.
Asking for only what is missing
A good voice flow should not demand that you front-load every detail. Sometimes you say the whole thing, and sometimes you just say "Update YearlyQuest." Both parameters, the trend and the value, are optional, and the intent fills the gaps with requestDisambiguation. If no trend was named, it fetches the active trends and asks "Which trend would you like to update?" If a trend was named but no value, it pulls that specific trend's options and asks for one of them by description.
That second prompt is the nicer half, because the choices are not generic. Each Trend owns an array of Option values, a tiny { color, description } pair, and the disambiguation offers exactly those descriptions:
let optionsList = trend.options.map { $0.description }
selectedOption = try await $trendOption.requestDisambiguation(
among: optionsList,
dialog: "What is the \(trend.name) value for today?"
)
So for Sleep you might hear "Great, Okay, Poor," and for Workout something else entirely, all derived from how the user set the trend up. The dialog only surfaces the questions that are genuinely unanswered, so the fast path stays one breath long and the slower path stays clear.
Never logging a duplicate day
The correctness rule that matters most for YearlyQuest is that each day holds at most one entry per trend. The whole year heatmap is built on that assumption, and a stray duplicate would quietly muddy a square's color. The interesting wrinkle is how the model stores a date. A TrendData row does not keep a single Date; it splits it into three integers, year, month, and day, populated in the initializer from Calendar.current. That keeps the heatmap addressable by calendar cell and sidesteps time-zone drift from a stored timestamp.
Because the key is three integers, "today's entry" is a small componentized lookup rather than a date comparison. The intent decomposes the current date the same way the model does, then does find-or-update:
let today = Calendar.current.dateComponents([.year, .month, .day], from: Date())
let existing = trendData.first {
$0.year == today.year && $0.month == today.month && $0.day == today.day
}
if existing == nil {
let value = trend.options.firstIndex { $0.description == selectedOption.description } ?? 0
let new = TrendData(
uuid: UUID().uuidString, trendUUID: trend.uuid, date: Date(),
color: selectedOption.color, optionDescription: selectedOption.description,
note: "", value: value, maxValue: trend.options.count - 1
)
modelContext.insert(new)
} else {
existing?.color = selectedOption.color
existing?.optionDescription = selectedOption.description
}
A couple of real details live in that snippet. The stored value is not the spoken text; it is the option's index within trend.options, with maxValue set to options.count - 1. That index-and-max pairing is what lets the heatmap normalize any trend, two-state or five-state, onto the same color ramp. And the write only commits when there is something to commit: the intent checks modelContext.hasChanges before calling save(), so re-logging the same value is a no-op rather than a redundant disk write.
The win was not Siri's vocabulary. It was making the two ways of logging a day, by tap and by voice, resolve to the same row in the same store, so they can never disagree.
A little confirmation
To close the loop, perform() returns some IntentResult & ProvidesDialog & ShowsSnippetView. The dialog speaks back "Updated Sleep to Great for today," and the snippet renders a small week heatmap, the app's own WeekView reused with an isSiri: true flag so it lays out compactly. The intent first folds the fetched rows into a dictionary keyed by "year-month-day" so the view can look up each square in constant time. After you speak, you see a few colored squares confirming the day landed where you expected. It is a tiny thing, but it turns a blind voice command into something you can verify at a glance.
What I took away
- One store, two clients. An App Intent has no SwiftUI environment, so it opens its own
ModelContainerover the identicalSchema; matching the app's CloudKit-backed store means matching its every-field-has-a-default constraint too. - Expose user data as an
AppEntity. ATrendNameEntityplus anEntityQuerylet Siri suggest and match trends by name, and aremovedByUserpredicate keeps deleted trends out of the conversation. - Mind the id types. The model's
Stringuuid and the entity'sUUIDid need an explicit, fallible round-trip;compactMapmakes the parse failures harmless. - Disambiguate per trend.
requestDisambiguationfills only the missing parameter, and the value options come straight from that trend's ownOptionlist. - Make duplicates impossible. Storing the date as
year/month/dayintegers turns the dedup rule into a simple componentized find-or-update, andhasChangeskeeps a re-log from touching disk.
What began as a tap-per-day tracker is now something I can update from a walk without breaking stride. The lesson that stuck: when you add a second front door to an app, the work is mostly plumbing, making both doors open onto the same single source of truth.