Project 2 · Milestone 2 — the home screen and smart views
Unit 22 · Project 2 · Milestone 2. You build the home screen — smart tiles (Today, Scheduled, Flagged, All) with live counts and your lists below — and generalize Milestone 1's list view so it can render a real list or a smart view. By the end you can navigate the whole app.
Milestone 1 rendered one list off its relationship. Now you need the home: a screen that sees
all reminders (to count what's due today) and all lists (to show them with counts). That's
what @Query is for.
Step 1 — a scope for navigation
Every destination is either a real list or a smart filter. Model that as one Hashable value
so it can drive a NavigationStack path:
enum Scope: Hashable { case list(String) // a list, by its (unique) name case today, scheduled, flagged, all var title: String { switch self { case .list(let name): name case .today: "Today" case .scheduled: "Scheduled" case .flagged: "Flagged" case .all: "All" } } } func isDueToday(_ date: Date?) -> Bool { guard let date else { return false } return Calendar.current.isDateInToday(date) }
A type used in a NavigationStack(path:) or .navigationDestination(for:) must be Hashable,
not just Identifiable. Scope carries the list by name (a String) rather than the
@Model object precisely so it stays a plain, hashable value in the path.
Step 2 — the home screen
@Query fetches and observes. The home needs two queries — every list, and every reminder (to
count the smart tiles):
struct RemindersHome: View { @Query(sort: \ReminderList.createdAt) private var lists: [ReminderList] @Query private var allReminders: [Reminder] private var todayCount: Int { allReminders.filter { !$0.isDone && isDueToday($0.dueDate) }.count } private var scheduledCount: Int { allReminders.filter { !$0.isDone && $0.dueDate != nil }.count } private var flaggedCount: Int { allReminders.filter { !$0.isDone && $0.flagged }.count } private var allCount: Int { allReminders.filter { !$0.isDone }.count } private let columns = [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)] var body: some View { List { Section { LazyVGrid(columns: columns, spacing: 12) { SmartTile(scope: .today, icon: "calendar", color: .blue, count: todayCount, title: "Today") SmartTile(scope: .scheduled, icon: "calendar.badge.clock", color: .red, count: scheduledCount, title: "Scheduled") SmartTile(scope: .flagged, icon: "flag.fill", color: .orange, count: flaggedCount, title: "Flagged") SmartTile(scope: .all, icon: "tray.fill", color: .gray, count: allCount, title: "All") } .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) } Section("My Lists") { ForEach(lists) { list in NavigationLink(value: Scope.list(list.name)) { HStack(spacing: 12) { Image(systemName: list.symbol) .font(.footnote).foregroundStyle(.white) .frame(width: 28, height: 28) .background(list.color, in: Circle()) Text(list.name) Spacer() Text("\(list.activeCount)").foregroundStyle(.secondary) } } } } } .navigationTitle("Reminders") } }
The per-list count is list.activeCount — a computed property on ReminderList that filters
its own reminders relationship. You never store a count; it's derived, so it can't drift.
Step 3 — the smart tile
Each tile is a NavigationLink(value:) that pushes a Scope. The link carries the value; the
stack decides what view to show (Step 5):
struct SmartTile: View { let scope: Scope let icon: String let color: Color let count: Int let title: String var body: some View { NavigationLink(value: scope) { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: icon) .font(.footnote.bold()).foregroundStyle(.white) .frame(width: 30, height: 30).background(color, in: Circle()) Spacer() Text("\(count)").font(.title.bold()) } Text(title).font(.subheadline.bold()).foregroundStyle(.secondary) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14)) } .buttonStyle(.plain) } }
Step 4 — generalize the list view to any scope
Now upgrade Milestone 1's ListView into a ReminderListView that takes a Scope. Instead of
reading one list's relationship, it @Querys all reminders and filters by the scope — the same
ReminderRows, a different source. This is the promised hand-off from Milestone 1:
struct ReminderListView: View { let scope: Scope @Query private var all: [Reminder] @Environment(\.modelContext) private var context private var reminders: [Reminder] { switch scope { case .list(let name): all.filter { $0.list?.name == name } case .today: all.filter { isDueToday($0.dueDate) } case .scheduled: all.filter { $0.dueDate != nil } case .flagged: all.filter { $0.flagged } case .all: all } } private var active: [Reminder] { reminders.filter { !$0.isDone } } private var completed: [Reminder] { reminders.filter { $0.isDone } } var body: some View { List { Section { ForEach(active) { ReminderRow(reminder: $0) {} } } if !completed.isEmpty { Section("Completed") { ForEach(completed) { ReminderRow(reminder: $0) {} } } } } .overlay { if reminders.isEmpty { ContentUnavailableView("No Reminders", systemImage: "checklist") } } .navigationTitle(scope.title) } }
A "smart view" is nothing more than a filter over the same store — Today is the reminders
whose dueDate is today, Flagged is the reminders where flagged is true. No separate storage,
no sync to keep; the counts on the home and the contents here read the same source of truth.
Step 5 — wire the navigation and the container
The root holds the NavigationStack and maps a Scope to the view. Register both model types
on the container:
@main struct ChecklistApp: App { var body: some Scene { WindowGroup { NavigationStack { RemindersHome() .navigationDestination(for: Scope.self) { scope in ReminderListView(scope: scope) } } } .modelContainer(for: [ReminderList.self, Reminder.self]) } }
Step 6 — create and delete
Adding a reminder to a list is insert plus setting the relationship; deleting is delete.
In ReminderListView, expose an add button when the scope is a real list:
private func add(to list: ReminderList) { let reminder = Reminder(title: "New Reminder") reminder.list = list context.insert(reminder) } private func delete(_ reminder: Reminder) { context.delete(reminder) }
Setting reminder.list = list and inserting is all it takes — the inverse relationship puts it
in list.reminders, the home's count updates, and it's saved. (Milestone 3 replaces the
placeholder title with the detail editor.)
Checkpoint
Seed a few lists with reminders (some due today, some flagged), then your app should:
- Show the home with correct Today / Scheduled / Flagged / All counts and your lists.
- Open a list, or a smart view, and show the right reminders in each.
- Update every count live when you complete, flag, or add a reminder.
Knowledge check
Q: Why can the Today tile and the Today view never disagree?
Both derive from the same @Query of reminders filtered by the same rule (isDueToday && !isDone).
There's one source of truth and no stored count, so there's nothing to fall out of sync.
Q: Why does Scope carry a list name rather than the ReminderList object?
The value goes into the navigation path, which needs a plain Hashable. Names are unique here,
so a String identifies the list without putting a @Model reference (with its identity and
lifecycle) into the path.