Project 2 · Milestone 2 — the home screen and smart viewsswift-6.4/ios-26
Lesson 3 / 5
Unit 22 · Checklist

Project 2 · Milestone 2 — the home screen and smart views

Note

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)
}
Note

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.