Day 17 – Mini SwiftUI Project: To-Do App
T
Tuan Beo

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 struct and optional due date

  • State management via ObservableObject + @StateObject

  • List 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)

  1. Sections: Split the list into “Today”, “Upcoming”, and “No Due Date”.

  2. Search: Add .searchable(text:) to filter by title.

  3. Priority: Add a priority: Int (1–3) and sort/highlight high priority.

  4. Reminders: Add a local notification for due dates (Day 25 will go deeper).

  5. Accessibility: Add .accessibilityLabel and .accessibilityValue on the row.

Comments