
Day 17 – Mini SwiftUI Project: To-Do App
Build a simple To-Do app where users can add, mark as done, and delete tasks, combining lists, forms, and state management.
1) App structure
We’ll keep it simple and clean:
TaskItem(model)TasksViewModel(state & logic)ContentView(list + add/edit)EditTaskView(form used for add & edit)
Create a new iOS App (SwiftUI) project named TodoLite. Replace ContentView.swift with the code below.
import SwiftUI
// MARK: - Model
struct TaskItem: Identifiable, Hashable {
let id: UUID
var title: String
var notes: String
var isDone: Bool
var due: Date?
init(id: UUID = UUID(), title: String, notes: String = "", isDone: Bool = false, due: Date? = nil) {
self.id = id
self.title = title
self.notes = notes
self.isDone = isDone
self.due = due
}
}
// MARK: - ViewModel
final class TasksViewModel: ObservableObject {
@Published var tasks: [TaskItem] = [
TaskItem(title: "Try SwiftUI", notes: "Follow Day 12–16", due: .now.addingTimeInterval(60*60*24)),
TaskItem(title: "Build To-Do app", notes: "Today!", isDone: false),
TaskItem(title: "Walk 5k steps", isDone: true)
]
@Published var showCompleted = true
@Published var sortByDueDate = false
var visibleTasks: [TaskItem] {
let base = showCompleted ? tasks : tasks.filter { !$0.isDone }
if sortByDueDate {
return base.sorted { (a, b) in
switch (a.due, b.due) {
case let (l?, r?): return l < r
case (_?, nil): return true
case (nil, _?): return false
default: return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending
}
}
}
return base
}
func toggleDone(_ task: TaskItem) {
guard let idx = tasks.firstIndex(of: task) else { return }
tasks[idx].isDone.toggle()
}
func delete(at offsets: IndexSet) {
let idsToDelete = offsets.map { visibleTasks[$0].id }
tasks.removeAll { idsToDelete.contains($0.id) }
}
func add(_ task: TaskItem) {
tasks.append(task)
}
func update(_ task: TaskItem) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx] = task
}
}
// MARK: - Views
struct ContentView: View {
@StateObject private var vm = TasksViewModel()
@State private var showingAdd = false
var body: some View {
NavigationStack {
List {
if vm.visibleTasks.isEmpty {
ContentUnavailableView("No tasks", systemImage: "checkmark.circle", description: Text("Tap + to add your first task"))
} else {
ForEach(vm.visibleTasks) { task in
NavigationLink {
EditTaskView(task: task) { updated in
vm.update(updated)
}
} label: {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Button {
vm.toggleDone(task)
} label: {
Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
.imageScale(.large)
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.strikethrough(task.isDone)
.foregroundStyle(task.isDone ? .secondary : .primary)
.font(.headline)
if !task.notes.isEmpty {
Text(task.notes).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
}
if let due = task.due {
Label(due, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
if let idx = vm.visibleTasks.firstIndex(of: task) {
vm.delete(at: IndexSet(integer: idx))
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
.onDelete(perform: vm.delete)
}
}
.animation(.default, value: vm.tasks)
.navigationTitle("To-Do")
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
Toggle(isOn: $vm.showCompleted) {
Image(systemName: vm.showCompleted ? "eye" : "eye.slash")
}
.toggleStyle(.button)
Toggle(isOn: $vm.sortByDueDate) {
Image(systemName: "calendar")
}
.toggleStyle(.button)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAdd = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAdd) {
NavigationStack {
EditTaskView(task: TaskItem(title: "", notes: "", isDone: false, due: nil)) { newTask in
vm.add(newTask)
}
.navigationTitle("New Task")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Close") { showingAdd = false } }
}
}
}
}
}
}
struct EditTaskView: View {
@Environment(\.dismiss) private var dismiss
@State var task: TaskItem
var onSave: (TaskItem) -> Void
@FocusState private var titleFocused: Bool
var body: some View {
Form {
Section("Task") {
TextField("Title", text: $task.title)
.focused($titleFocused)
.submitLabel(.done)
TextField("Notes (optional)", text: $task.notes, axis: .vertical)
.lineLimit(1...4)
Toggle("Completed", isOn: $task.isDone)
DatePicker("Due", selection: Binding($task.due, Date.now), displayedComponents: [.date, .hourAndMinute])
.labelsHidden()
.datePickerStyle(.compact)
.padding(.leading, -8)
.overlay(alignment: .leading) { Text("Due").foregroundStyle(.secondary).padding(.leading, 16) }
}
}
.onAppear {
// Focus title on open if empty
if task.title.isEmpty { DispatchQueue.main.async { titleFocused = true } }
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let trimmed = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
var t = task
t.title = trimmed
onSave(t)
dismiss()
}
.disabled(task.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
#Preview { ContentView() }
Tip: The DatePicker is bound via a small helper using Binding($task.due, Date.now). If you prefer simpler code, replace it with a toggle “Has due date” and show the picker only when enabled.
2) What you learned (and used)
Modeling tasks with a
structand optional due dateState management via
ObservableObject+@StateObjectList rendering with swipe-to-delete & toolbar toggles
Navigation + sheets for add/edit flows
Bindings into forms to edit values
Simple sorting & filtering (show/hide completed, sort by due date)
🛠 Practice (nice upgrades)
Sections: Split the list into “Today”, “Upcoming”, and “No Due Date”.
Search: Add
.searchable(text:)to filter by title.Priority: Add a
priority: Int(1–3) and sort/highlight high priority.Reminders: Add a local notification for due dates (Day 25 will go deeper).
Accessibility: Add
.accessibilityLabeland.accessibilityValueon the row.
