Project 2 · Milestone 1 — the model, the relationship, and the listswift-6.4/ios-26
Lesson 2 / 5
Unit 22 · Checklist

Project 2 · Milestone 1 — the model, the relationship, and the list

Note

Unit 22 · Project 2 · Milestone 1. You build the data model — reminders grouped into lists via a SwiftData relationship — attach a container, and render one list's reminders with a completion toggle. By the end the app persists reminders in a list across launches.

A Reminders app is two things that reference each other: a list owns many reminders, and every reminder belongs to a list. That's a relationship, and SwiftData models it directly. Get the graph right first; the screens hang off it.

Step 1 — the two models and the relationship

Create ReminderList and Reminder. Each is an @Model — a persisted, observable reference type. The relationship is declared on the list side with @Relationship, and its inverse points back at the property on Reminder that holds the owning list:

import SwiftData
import SwiftUI

@Model
final class ReminderList {
    var name: String
    var symbol: String        // an SF Symbol name
    var colorName: String
    var createdAt: Date

    @Relationship(deleteRule: .cascade, inverse: \Reminder.list)
    var reminders: [Reminder] = []

    init(name: String, symbol: String = "list.bullet", colorName: String = "blue", createdAt: Date = .now) {
        self.name = name
        self.symbol = symbol
        self.colorName = colorName
        self.createdAt = createdAt
    }

    var color: Color { Palette.color(colorName) }
    var activeCount: Int { reminders.filter { !$0.isDone }.count }
}

@Model
final class Reminder {
    var title: String
    var notes: String
    var isDone: Bool
    var dueDate: Date?
    var priorityRaw: Int
    var flagged: Bool
    var createdAt: Date

    var list: ReminderList?

    init(title: String, notes: String = "", isDone: Bool = false, dueDate: Date? = nil,
         priority: Priority = .none, flagged: Bool = false, createdAt: Date = .now) {
        self.title = title
        self.notes = notes
        self.isDone = isDone
        self.dueDate = dueDate
        self.priorityRaw = priority.rawValue
        self.flagged = flagged
        self.createdAt = createdAt
    }

    var priority: Priority {
        get { Priority(rawValue: priorityRaw) ?? .none }
        set { priorityRaw = newValue.rawValue }
    }
}

Two things worth naming. deleteRule: .cascade means deleting a list deletes its reminders — no orphans left behind. And the model is named Reminder, not Task, because a SwiftData @Model final class Task would shadow Swift's own Task type — a real footgun the name sidesteps.

Note

SwiftData persists stored properties, not computed ones — so priority is stored as priorityRaw (an Int) with a computed priority façade over it. Raw-valued enums are the clean way to persist a small fixed set. Don't try to store the Priority enum directly.

Step 2 — the priority enum and the palette

Priority is a raw-valued enum so it can round-trip through priorityRaw. The palette maps a stored colour name to a real Color:

enum Priority: Int, CaseIterable, Identifiable {
    case none, low, medium, high
    var id: Int { rawValue }
    var label: String { ["None", "Low", "Medium", "High"][rawValue] }
    var indicator: String { ["", "!", "!!", "!!!"][rawValue] }
}

enum Palette {
    static func color(_ name: String) -> Color {
        switch name {
        case "red": .red
        case "green": .green
        case "orange": .orange
        case "purple": .purple
        case "yellow": .yellow
        default: .blue
        }
    }
}

Step 3 — attach the container

At the app root, .modelContainer(for:) builds the store and injects it into the environment. Because there are two model types related to each other, register both:

import SwiftUI
import SwiftData

@main
struct ChecklistApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
        .modelContainer(for: [ReminderList.self, Reminder.self])
    }
}

Step 4 — the reminder row

A row shows the state that matters at a glance: a completion circle you can tap, the title (struck through when done), any priority marker, a due date, and a flag. @Bindable gives the row a two-way binding into the persisted Reminder, so toggling isDone saves automatically:

struct ReminderRow: View {
    @Bindable var reminder: Reminder
    var onEdit: () -> Void

    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            Button {
                reminder.isDone.toggle()
            } label: {
                Image(systemName: reminder.isDone ? "largecircle.fill.circle" : "circle")
                    .font(.title3)
                    .foregroundStyle(reminder.isDone ? (reminder.list?.color ?? .accentColor) : .secondary)
            }
            .buttonStyle(.plain)

            VStack(alignment: .leading, spacing: 2) {
                HStack(spacing: 4) {
                    if reminder.priority != .none {
                        Text(reminder.priority.indicator).foregroundStyle(.orange)
                    }
                    Text(reminder.title)
                        .strikethrough(reminder.isDone)
                        .foregroundStyle(reminder.isDone ? .secondary : .primary)
                }
                if !reminder.notes.isEmpty {
                    Text(reminder.notes).font(.caption).foregroundStyle(.secondary).lineLimit(1)
                }
                if let due = reminder.dueDate {
                    Text(due, format: .dateTime.weekday(.abbreviated).hour().minute())
                        .font(.caption)
                        .foregroundStyle(!reminder.isDone && due < .now ? .red : .secondary)
                }
            }

            Spacer()

            if reminder.flagged {
                Image(systemName: "flag.fill").font(.caption).foregroundStyle(.orange)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture { onEdit() }
    }
}

The Button around the circle handles its own tap; the onTapGesture on the whole row (with contentShape(Rectangle()) so the empty space is tappable too) opens the editor — which you build in Milestone 3. For now, pass an empty closure.

Step 5 — one list's reminders

Render a single list's reminders straight off the relationship (list.reminders), split into active and completed sections. Because reminders is a SwiftData relationship, the view updates when a reminder's isDone flips:

struct ListView: View {
    let list: ReminderList

    private var active: [Reminder] { list.reminders.filter { !$0.isDone } }
    private var completed: [Reminder] { list.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) {} }
                }
            }
        }
        .navigationTitle(list.name)
    }
}
Tip

In Milestone 2 you'll generalize this into a view that can also render smart views (Today, Flagged) by filtering a @Query of all reminders — the same rows, a different source. Build it here against list.reminders first so you can see persistence working before you add the smart-view machinery.

Checkpoint

With a RootView that shows a seeded list (create a ReminderList, insert a few Reminders into modelContext, set each one's .list), your app should now:

  • Show a list's reminders with priority markers, due dates, and flags.
  • Toggle a reminder done on tap, moving it into the Completed section.
  • Persist every change — force-quit and relaunch, and it's all still there.

Knowledge check

Q: Why is the relationship declared with inverse: on the list side? It tells SwiftData that ReminderList.reminders and Reminder.list are the two ends of the same relationship, so setting reminder.list = someList also makes the reminder appear in someList.reminders. Without the inverse you'd have two unrelated properties to keep in sync by hand.

Q: Why store priorityRaw: Int with a computed priority, instead of a stored Priority? SwiftData persists stored properties; a raw-valued Int round-trips cleanly and stays stable if you reorder the enum's cases later. The computed priority gives the rest of the app a typed API over it.