Capstone: A Data-Driven, Tested SwiftUI App

iOS Development — Architecture & Data

Chapter 10 · Capstone: A Data-Driven, Tested SwiftUI App

Fundamentals' own TaskFlow worked, but everything lived in memory and nothing was tested. This capstone evolves it into a real, production-shaped app — persisted, networked, dependency-injected, and genuinely tested — wiring together every one of this course's own nine chapters.

ChapterWhat It Contributes
1 — MVVMTaskListViewModel as a real, dedicated ViewModel — Model/View/ViewModel separation throughout
2 — Concurrency@MainActor on the ViewModel, real Task {} for background sync
3 — NetworkingAPIClient, URLRequest, APIError, real status-code checking
4 — SwiftDataTask as a real @Model, @Query for live local display
5 — Dependency InjectionAPIClientProtocol, real initializer injection, FakeAPIClient
6 — Unit TestingReal @Test functions verifying TaskListViewModel with zero real network access
7 — UI Testing & InstrumentsReal .accessibilityIdentifier() on every interactive element
8 — Device CapabilitiesPhotosPicker for a task photo, a real local reminder notification
9 — Reactive PatternsA real AsyncStream-based task-added event feed

File Structure

Task.swift — Ch4 (real @Model) TaskDTO.swift — Ch3, Ch4 (network shape, separate from the persisted Model) APIClient.swift — Ch3, Ch5 (protocol + real implementation) FakeAPIClient.swift — Ch5, Ch6 TaskEvents.swift — Ch9 (AsyncStream) TaskListViewModel.swift — Ch1, Ch2, Ch3, Ch5 TaskListView.swift — Ch4, Ch7 NewTaskView.swift — Ch7, Ch8 TaskFlowApp.swift — Ch4 (ModelContainer) TaskListViewModelTests.swift — Ch6

A Real, Deliberate Split: Task vs. TaskDTO

An Honest Architectural Tension, Resolved
Chapter 3's own Task was Decodable; Chapter 4's own Task is a real @Model class. Mixing both roles onto one type genuinely works in simple cases, but real apps commonly keep them separate — a lightweight DTO (Data Transfer Object) matches the server's own real JSON shape, while the persisted @Model stays focused purely on local storage. This capstone makes that real, deliberate choice explicit.
Task.swift
@Model final class Task { var title: String var isDone: Bool var priority: Int var photoData: Data? init(title: String, isDone: Bool = false, priority: Int = 1) { self.title = title self.isDone = isDone self.priority = priority } }
TaskDTO.swift
struct TaskDTO: Decodable { let title: String let priority: Int func toTask() -> Task { Task(title: title, priority: priority) } }

The ViewModel: MVVM, Concurrency, and Injected Networking

TaskListViewModel.swift
@Observable @MainActor final class TaskListViewModel { private let apiClient: APIClientProtocol private let modelContext: ModelContext private(set) var isSyncing = false init(modelContext: ModelContext, apiClient: APIClientProtocol = APIClient()) { self.modelContext = modelContext self.apiClient = apiClient } func syncWithServer() async { isSyncing = true defer { isSyncing = false } guard let dtos = try? await apiClient.fetch([TaskDTO].self, from: tasksURL) else { return } for dto in dtos { modelContext.insert(dto.toTask()) } } }
Every Chapter, In One Real Type
Chapter 1's own MVVM separation, Chapter 2's @MainActor, Chapter 3's APIClientProtocol, and Chapter 5's real initializer injection all land in this single, real ViewModel — the same real class shape this course has been building toward since Chapter 1's own TaskStore refactor.

The View: Real, Live Local Data

TaskListView.swift
struct TaskListView: View { @Query private var tasks: [Task] @Environment(\.modelContext) private var modelContext @State private var isShowingNewTask = false var body: some View { NavigationStack { List(tasks) { task in Text(task.title) } .navigationTitle("TaskFlow") .toolbar { Button("Add") { isShowingNewTask = true } .accessibilityIdentifier("addTaskButton") } .sheet(isPresented: $isShowingNewTask) { NewTaskView() } .task { let viewModel = TaskListViewModel(modelContext: modelContext) await viewModel.syncWithServer() } } } }
A Real, Deliberate Division of Labor
@Query keeps the View's own display genuinely live — any insert from anywhere, including the ViewModel's own syncWithServer(), updates tasks automatically. The ViewModel's real job is narrower and more focused than Course 1's own capstone: syncing with the network, not owning the displayed array directly — SwiftData's @Query already does that better.

Device Capabilities: Photo & Reminder

NewTaskView.swift (excerpt)
func save() { let newTask = Task(title: title, priority: priority) newTask.photoData = selectedPhotoData modelContext.insert(newTask) TaskEvents.shared.taskAdded(newTask.title) // Ch9 let content = UNMutableNotificationContent() content.title = "Reminder" content.body = newTask.title let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) dismiss() }

A Real, Live Event Feed: TaskEvents

TaskEvents.swift
final class TaskEvents { static let shared = TaskEvents() private var continuation: AsyncStream<String>.Continuation? lazy var stream: AsyncStream<String> = AsyncStream { continuation in self.continuation = continuation } func taskAdded(_ title: String) { continuation?.yield(title) } }

Any part of the app can now observe every task-added event, in real time, using the exact same for await vocabulary Chapter 9 established — a real, live activity feed with zero Combine involved.

Testing It All

TaskListViewModelTests.swift
import Testing import SwiftData @Test func syncingInsertsTasksFromTheFakeClient() async throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: Task.self, configurations: config) let context = ModelContext(container) let viewModel = TaskListViewModel(modelContext: context, apiClient: FakeAPIClient()) await viewModel.syncWithServer() let fetchedTasks = try context.fetch(FetchDescriptor<Task>()) #expect(!fetchedTasks.isEmpty) }
A Real, Genuinely Complete Test — No Real Network, No Real Disk
ModelConfiguration(isStoredInMemoryOnly: true) gives this test a real, working SwiftData stack that never touches disk, and FakeAPIClient means it never touches a real network either — the entire real sync flow, verified end to end, in milliseconds.

Hands-On Exercises

Exercise 1

Add a real .accessibilityIdentifier("saveButton") to NewTaskView's own Save button, and write a real XCUITest confirming a newly-added task's own title appears in the list after the full add-task flow.

📄 View solution
Exercise 2

Write a real @Test function using a fresh, in-memory ModelContainer that inserts one Task directly via modelContext.insert(...), then confirms context.fetch(FetchDescriptor<Task>()) returns exactly one task with the expected title — with no networking involved at all.

📄 View solution
Exercise 3

Explain, in your own words, why splitting Task (a real @Model) and TaskDTO (a real Decodable struct) into two separate types is a genuinely better real design than making one type do both jobs, connecting your answer to Chapter 1's own MVVM separation-of-concerns principle.

📄 View solution

Where to Go From Here

This capstone's own app still isn't ready to ship — no app icon, no signing, no real App Store submission, no CI pipeline running these very tests automatically. That's genuine iOS Development — Production & Publishing territory: provisioning, TestFlight, App Store Connect, and shipping this exact codebase to real users, the direct next course in this three-course arc.

What This Capstone Demonstrates

  • MVVM (Ch1), @MainActor concurrency (Ch2), and injected networking via APIClientProtocol (Ch3, Ch5) in one real ViewModel
  • Real SwiftData persistence (@Model, @Query) deliberately kept separate from a network-facing TaskDTO (Ch4)
  • Real, fast, isolated tests using FakeAPIClient and an in-memory ModelConfiguration — no real network, no real disk (Ch5, Ch6)
  • Real accessibility identifiers ready for XCUITest (Ch7)
  • PhotosPicker and a real local reminder notification (Ch8)
  • A real, live AsyncStream-based event feed, using this course's own consistent async vocabulary throughout (Ch9)