Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 073 / 100

Building a real app: habit tracker — a step-by-step guide

A full feature from UI to storage.

Expona Admin11 min read

Introduction

We've covered SwiftUI fundamentals, state management, and networking in previous articles. Now it's time to put those pieces together and build something real: a habit tracker that users will actually use. Building a real app: habit tracker — a step-by-step guide means tackling persistence, widgets, notifications, and a polished UI all at once—exactly what you'll encounter in production.

A habit tracker is an ideal capstone project because it forces you to think about data modeling, local storage, background notifications, and home screen presence. By the end of this guide, you'll have a working app that syncs habits to the lock screen and reminds users daily to check in.

Model: SwiftData Habit and CheckIn Models

SwiftData replaced Core Data as the modern persistence layer in iOS 17. It's lighter and integrates seamlessly with SwiftUI's observation system. Let's start with our domain models.

A habit needs a name, a description, a creation date, and a way to track daily completions. We'll use two models: Habit for the definition and CheckIn for each day's completion record.

swift
import SwiftData
import Foundation

@Model
final class Habit {
    var id: UUID = UUID()
    var name: String
    var description: String
    var createdAt: Date = Date()
    var color: String = "blue"
    
    @Relationship(deleteRule: .cascade, inverse: \CheckIn.habit)
    var checkIns: [CheckIn] = []
    
    init(name: String, description: String, color: String = "blue") {
        self.name = name
        self.description = description
        self.color = color
    }
    
    func isCompletedToday() -> Bool {
        let today = Calendar.current.startOfDay(for: Date())
        return checkIns.contains { Calendar.current.startOfDay(for: $0.date) == today }
    }
    
    func completionStreak() -> Int {
        var streak = 0
        let today = Calendar.current.startOfDay(for: Date())
        
        for i in 0... {
            let checkDate = Calendar.current.date(byAdding: .day, value: -i, to: today)!
            if checkIns.contains(where: { Calendar.current.startOfDay(for: $0.date) == checkDate }) {
                streak += 1
            } else {
                break
            }
        }
        return streak
    }
}

@Model
final class CheckIn {
    var id: UUID = UUID()
    var date: Date = Date()
    var habit: Habit?
    
    init(date: Date = Date(), habit: Habit? = nil) {
        self.date = date
        self.habit = habit
    }
}

Notice the cascade delete rule: when a habit is deleted, all its check-ins go with it. The isCompletedToday() method checks if there's a check-in for today's date. The completionStreak() method counts consecutive days backward from today—useful for gamification.

These models are simple but powerful. In a production app, you might add recurrence patterns (daily, weekdays only), tags, or color preferences. The pattern here scales well.

UI: List of Habits with Daily Check-Off

The main screen shows a list of habits and allows users to tap a button to mark them complete for today. We'll use a SwiftUI list with a chevron-style indicator and a tap gesture.

swift
import SwiftUI
import SwiftData

struct HabitListView: View {
    @Query(sort: \Habit.createdAt, order: .reverse) var habits: [Habit]
    @Environment(\.modelContext) var modelContext
    
    @State private var showAddSheet = false
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(habits) { habit in
                    NavigationLink(destination: HabitDetailView(habit: habit)) {
                        HabitRowView(habit: habit)
                    }
                }
                .onDelete(perform: deleteHabit)
            }
            .navigationTitle("Habits")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button(action: { showAddSheet = true }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showAddSheet) {
                AddHabitView()
                    .environment(\.modelContext, modelContext)
            }
        }
    }
    
    private func deleteHabit(_ indexSet: IndexSet) {
        indexSet.forEach { index in
            let habit = habits[index]
            modelContext.delete(habit)
        }
    }
}

struct HabitRowView: View {
    @Bindable var habit: Habit
    @Environment(\.modelContext) var modelContext
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(habit.name)
                    .font(.headline)
                
                Text(habit.description)
                    .font(.caption)
                    .foregroundColor(.secondary)
                
                HStack(spacing: 12) {
                    Label("\(habit.completionStreak()) day streak", systemImage: "flame.fill")
                        .font(.caption2)
                        .foregroundColor(.orange)
                }
            }
            
            Spacer()
            
            Button(action: {
                if habit.isCompletedToday() {
                    if let checkIn = habit.checkIns.first(where: { Calendar.current.startOfDay(for: $0.date) == Calendar.current.startOfDay(for: Date()) }) {
                        habit.checkIns.removeAll { $0.id == checkIn.id }
                    }
                } else {
                    let newCheckIn = CheckIn(date: Date(), habit: habit)
                    habit.checkIns.append(newCheckIn)
                }
                try? modelContext.save()
            }) {
                Image(systemName: habit.isCompletedToday() ? "checkmark.circle.fill" : "circle")
                    .font(.title2)
                    .foregroundColor(habit.isCompletedToday() ? .green : .gray)
            }
            .buttonStyle(.plain)
        }
        .padding(.vertical, 8)
    }
}

struct AddHabitView: View {
    @Environment(\.dismiss) var dismiss
    @Environment(\.modelContext) var modelContext
    
    @State private var name = ""
    @State private var description = ""
    @State private var selectedColor = "blue"
    
    let colors = ["red", "orange", "yellow", "green", "blue", "purple"]
    
    var body: some View {
        NavigationStack {
            Form {
                Section("Habit Details") {
                    TextField("Habit name", text: $name)
                    TextField("Description", text: $description)
                }
                
                Section("Color") {
                    HStack {
                        ForEach(colors, id: \.self) { color in
                            Circle()
                                .fill(Color(color))
                                .frame(width: 44, height: 44)
                                .overlay(
                                    Circle()
                                        .stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0)
                                )
                                .onTapGesture {
                                    selectedColor = color
                                }
                        }
                    }
                }
            }
            .navigationTitle("New Habit")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Add") {
                        let habit = Habit(name: name, description: description, color: selectedColor)
                        modelContext.insert(habit)
                        dismiss()
                    }
                    .disabled(name.isEmpty)
                }
            }
        }
    }
}

The key here is the @Query macro, which automatically re-runs whenever SwiftData detects changes. When a user taps the check-in button, we create or remove a CheckIn record and call try? modelContext.save(). The UI responds immediately because of SwiftUI's observation.

Widgets: Today's Progress on the Home Screen

WidgetKit lets users see habit progress without opening the app. A simple widget shows how many habits are complete today out of the total.

swift
import WidgetKit
import SwiftUI
import SwiftData

struct HabitWidgetProvider: TimelineProvider {
    func placeholder(in context: Context) -> HabitWidgetEntry {
        HabitWidgetEntry(
            date: Date(),
            completedCount: 0,
            totalCount: 0
        )
    }
    
    func getSnapshot(in context: Context, completion: @escaping (HabitWidgetEntry) -> Void) {
        let entry = HabitWidgetEntry(
            date: Date(),
            completedCount: 0,
            totalCount: 0
        )
        completion(entry)
    }
    
    func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
        // Load from shared user defaults or SwiftData
        let defaults = UserDefaults(suiteName: "group.com.yourapp.habits")
        let completed = defaults?.integer(forKey: "completedToday") ?? 0
        let total = defaults?.integer(forKey: "totalHabits") ?? 0
        
        let entry = HabitWidgetEntry(
            date: Date(),
            completedCount: completed,
            totalCount: total
        )
        
        // Refresh tomorrow at midnight
        let nextRefresh = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
        let timeline = Timeline(entries: [entry], policy: .after(nextRefresh))
        completion(timeline)
    }
}

struct HabitWidgetEntry: TimelineEntry {
    let date: Date
    let completedCount: Int
    let totalCount: Int
}

struct HabitWidgetEntryView: View {
    var entry: HabitWidgetProvider.Entry
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Today's Habits")
                .font(.headline)
            
            HStack {
                VStack(alignment: .leading) {
                    Text("\(entry.completedCount)/\(entry.totalCount)")
                        .font(.system(.title, design: .rounded))
                        .fontWeight(.bold)
                    Text("Completed")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                Spacer()
                
                ZStack {
                    Circle()
                        .stroke(Color.gray.opacity(0.2), lineWidth: 4)
                    
                    Circle()
                        .trim(from: 0, to: entry.totalCount > 0 ? CGFloat(entry.completedCount) / CGFloat(entry.totalCount) : 0)
                        .stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round))
                        .rotationEffect(.degrees(-90))
                }
                .frame(width: 60, height: 60)
            }
        }
        .padding()
        .background(Color(.systemBackground))
    }
}

@main
struct HabitWidget: Widget {
    let kind: String = "HabitWidget"
    
    var body: some WidgetConfiguration {
        StaticConfiguration(
            kind: kind,
            provider: HabitWidgetProvider()
        ) { entry in
            HabitWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Today's Habits")
        .description("See your daily habit completion at a glance")
        .supportedFamilies([.systemSmall])
    }
}

Widgets can't directly access SwiftData (they run in an extension process), so we sync data via shared UserDefaults with an app group. Every time the main app updates a habit, it writes the counts to this shared defaults. The widget reads from there and refreshes at midnight.

Notifications: Daily Reminders with Local Notifications

Local notifications remind users to check in. We schedule them once at app launch and reschedule them daily. iOS handles the delivery even if the app is closed.

swift
import UserNotifications

class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
    static let shared = NotificationManager()
    
    func requestAuthorization() {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            if granted {
                DispatchQueue.main.async {
                    UIApplication.shared.registerForRemoteNotifications()
                }
            }
        }
    }
    
    func scheduleDailyReminder(at hour: Int, minute: Int) {
        let content = UNMutableNotificationContent()
        content.title = "Time for your habits"
        content.body = "Check in on your daily habits"
        content.sound = .default
        content.badge = NSNumber(value: UIApplication.shared.applicationIconBadgeNumber + 1)
        
        var dateComponents = DateComponents()
        dateComponents.hour = hour
        dateComponents.minute = minute
        
        let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
        let request = UNNotificationRequest(identifier: "dailyHabitReminder", content: content, trigger: trigger)
        
        UNUserNotificationCenter.current().add(request) { error in
            if let error = error {
                print("Error scheduling notification: \(error)")
            }
        }
    }
    
    func cancelReminder() {
        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["dailyHabitReminder"])
    }
}

In your app's main view or scene delegate, request authorization and schedule the reminder:

swift
import SwiftUI

@main
struct HabitTrackerApp: App {
    init() {
        NotificationManager.shared.requestAuthorization()
        NotificationManager.shared.scheduleDailyReminder(at: 9, minute: 0) // 9 AM daily
    }
    
    var body: some Scene {
        WindowGroup {
            HabitListView()
                .modelContainer(for: Habit.self)
        }
    }
}

The notification fires at 9 AM every day. Users can customize the time in settings by re-scheduling with a different hour and minute.

Common Mistakes

  • Not saving SwiftData changes: Remember that inserting or modifying models doesn't auto-save. Call try? modelContext.save() after mutations.
  • Widgets accessing SwiftData directly: Widgets run in their own process and can't read SwiftData. Use shared UserDefaults or a shared database instead.
  • Forgetting to set app group identifier: If your main app and widget need to share data, both targets must have the same app group in capabilities.
  • Scheduling notifications without user permission: Always request authorization first. iOS silently fails if permission is denied.
  • Not resetting the badge count: Remember to set UIApplication.shared.applicationIconBadgeNumber = 0 when users open the app, or badges pile up.

Key Takeaways

  • SwiftData models with relationships handle complex data structures cleanly; use cascade delete rules to maintain referential integrity.
  • The @Query macro keeps UI in sync with data changes automatically—no manual fetching required.
  • Widgets can't access SwiftData; sync state via shared UserDefaults and app groups.
  • Local notifications require explicit user permission and must be scheduled during app initialization.
  • Test notifications and widgets on real devices; the simulator's notification timing and widget refresh can be unreliable.
  • A streak counter is a simple but effective way to drive user engagement—show it prominently.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles