```html How to Build a Water Intake Tracker App in SwiftUI (2026)

How to Build a Water Intake Tracker App in SwiftUI

A water intake tracker lets users log glasses and bottles throughout the day, visualise progress toward a personalised hydration goal, and receive gentle reminders when they fall behind. It's a perfect first health app — compact enough to finish in a weekend, yet real enough to ship and charge for.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

Architecture overview

This app uses SwiftData as its local persistence layer, storing each water log as an individual WaterEntry record. A single @Observable store drives UI state, while two lightweight service types — HealthKitService and NotificationService — isolate the HealthKit and UserNotifications frameworks from view code. The daily goal lives in UserDefaults as a simple Double, keeping the schema lean. Charts renders the 7-day bar chart directly from SwiftData query results with no intermediate transformation layer.

WaterTrackerApp/
├── App/
│   └── WaterTrackerApp.swift       # @main, .modelContainer setup
├── Models/
│   ├── WaterEntry.swift            # @Model — amount + timestamp
│   └── HydrationGoal.swift        # UserDefaults wrapper
├── Views/
│   ├── DashboardView.swift         # Today's ring + quick-add
│   ├── HistoryView.swift           # 7-day Charts bar chart
│   └── SettingsView.swift          # Goal, reminder interval
├── Services/
│   ├── HealthKitService.swift      # HKHealthStore wrapper
│   └── NotificationService.swift   # UNUserNotificationCenter
└── Resources/
    └── PrivacyInfo.xcprivacy       # Required for App Store

Step-by-step

1. Create the Xcode project

Open Xcode 16, choose File › New › Project, pick the iOS App template, set Interface to SwiftUI, and check Use SwiftData for storage. Name it WaterTracker. Then add the HealthKit capability under your target's Signing & Capabilities tab. Replace the generated entry point with the version below so the model container is available app-wide from launch.

// App/WaterTrackerApp.swift
import SwiftUI
import SwiftData

@main
struct WaterTrackerApp: App {
    var body: some Scene {
        WindowGroup {
            DashboardView()
        }
        .modelContainer(for: [WaterEntry.self])
    }
}

2. Define the data model with SwiftData

Each tap of a "Add 250 ml" button creates one WaterEntry row. Keeping entries granular makes it easy to delete individual logs and query by date range with a #Predicate. The daily hydration goal is not a database entity — it changes rarely, so UserDefaults with a simple wrapper is the right fit and avoids unnecessary migrations.

// Models/WaterEntry.swift
import SwiftData
import Foundation

@Model
final class WaterEntry {
    var id: UUID
    var amount: Double    // millilitres
    var timestamp: Date

    init(amount: Double, timestamp: Date = .now) {
        self.id = UUID()
        self.amount = amount
        self.timestamp = timestamp
    }
}

// Models/HydrationGoal.swift
import Foundation

enum HydrationGoal {
    static let key = "hydrationGoalML"
    static let defaultML: Double = 2000

    static var current: Double {
        get {
            let stored = UserDefaults.standard.double(forKey: key)
            return stored > 0 ? stored : defaultML
        }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

3. Build the dashboard UI

The dashboard is the app's home screen. A circular progress ring gives instant visual feedback, quick-add buttons (100 ml, 250 ml, 500 ml) cover the most common container sizes, and a scrollable list shows each log for the current day with swipe-to-delete. All today's entries are fetched with a #Predicate filtered to the start of the current calendar day.

// Views/DashboardView.swift
import SwiftUI
import SwiftData

struct DashboardView: View {
    @Environment(\.modelContext) private var modelContext

    @Query(
        filter: #Predicate<WaterEntry> { entry in
            entry.timestamp >= Calendar.current.startOfDay(for: Date.now)
        },
        sort: \WaterEntry.timestamp,
        order: .reverse
    ) private var todayEntries: [WaterEntry]

    private var totalML: Double { todayEntries.reduce(0) { $0 + $1.amount } }
    private var goal: Double { HydrationGoal.current }
    private var progress: Double { min(totalML / goal, 1.0) }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    ProgressRingView(progress: progress, total: totalML, goal: goal)
                        .frame(height: 240)
                        .padding(.top)

                    QuickAddBar { ml in addEntry(ml) }

                    if !todayEntries.isEmpty {
                        VStack(alignment: .leading, spacing: 8) {
                            Text("Today's log")
                                .font(.headline)
                                .padding(.horizontal)
                            ForEach(todayEntries) { entry in
                                HStack {
                                    Image(systemName: "drop.fill")
                                        .foregroundStyle(.blue)
                                    Text("\(Int(entry.amount)) ml")
                                    Spacer()
                                    Text(entry.timestamp, style: .time)
                                        .foregroundStyle(.secondary)
                                        .font(.caption)
                                }
                                .padding(.horizontal)
                                .padding(.vertical, 6)
                                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
                                .padding(.horizontal)
                                .swipeActions {
                                    Button(role: .destructive) {
                                        modelContext.delete(entry)
                                    } label: { Label("Delete", systemImage: "trash") }
                                }
                            }
                        }
                    }
                }
                .padding(.bottom, 32)
            }
            .navigationTitle("Hydration")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    NavigationLink { HistoryView() } label: {
                        Label("History", systemImage: "chart.bar.fill")
                    }
                }
                ToolbarItem(placement: .topBarTrailing) {
                    NavigationLink { SettingsView() } label: {
                        Label("Settings", systemImage: "gear")
                    }
                }
            }
        }
    }

    private func addEntry(_ ml: Double) {
        let entry = WaterEntry(amount: ml)
        modelContext.insert(entry)
    }
}

struct ProgressRingView: View {
    let progress: Double
    let total: Double
    let goal: Double

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color.blue.opacity(0.12), lineWidth: 22)
            Circle()
                .trim(from: 0, to: progress)
                .stroke(
                    progress >= 1.0 ? Color.green : Color.blue,
                    style: StrokeStyle(lineWidth: 22, lineCap: .round)
                )
                .rotationEffect(.degrees(-90))
                .animation(.spring(duration: 0.55), value: progress)
            VStack(spacing: 6) {
                Text("\(Int(total)) ml")
                    .font(.system(size: 38, weight: .bold, design: .rounded))
                Text("of \(Int(goal)) ml")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                if progress >= 1.0 {
                    Text("Goal reached!")
                        .font(.caption.bold())
                        .foregroundStyle(.green)
                }
            }
        }
    }
}

struct QuickAddBar: View {
    let onAdd: (Double) -> Void
    private let amounts: [Double] = [100, 250, 330, 500]

    var body: some View {
        HStack(spacing: 12) {
            ForEach(amounts, id: \.self) { ml in
                Button {
                    onAdd(ml)
                } label: {
                    VStack(spacing: 4) {
                        Image(systemName: "plus.circle.fill")
                            .font(.title2)
                        Text("\(Int(ml)) ml")
                            .font(.caption.bold())
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 12)
                    .background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
                    .foregroundStyle(.blue)
                }
                .buttonStyle(.plain)
            }
        }
        .padding(.horizontal)
    }
}

#Preview {
    DashboardView()
        .modelContainer(for: [WaterEntry.self], inMemory: true)
}

4. Add the history chart and HealthKit write-back

The history screen aggregates daily totals and renders them as a bar chart using the Charts framework. Bars turn green when the goal is met. Below the chart, HealthKitService writes each logged entry to Apple Health as a dietaryWater sample — this is optional but gives the app a significant perceived value boost and a clear answer to "why do you need HealthKit?" in the App Store review notes.

// Views/HistoryView.swift
import SwiftUI
import SwiftData
import Charts

struct DailyTotal: Identifiable {
    let id = UUID()
    let date: Date
    let totalML: Double
}

struct HistoryView: View {
    @Query(sort: \WaterEntry.timestamp) private var allEntries: [WaterEntry]

    private var last7Days: [DailyTotal] {
        let calendar = Calendar.current
        return (0..<7).compactMap { offset -> DailyTotal? in
            guard let date = calendar.date(byAdding: .day, value: -offset, to: .now) else { return nil }
            let start = calendar.startOfDay(for: date)
            guard let end = calendar.date(byAdding: .day, value: 1, to: start) else { return nil }
            let total = allEntries
                .filter { $0.timestamp >= start && $0.timestamp < end }
                .reduce(0) { $0 + $1.amount }
            return DailyTotal(date: start, totalML: total)
        }.reversed()
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 20) {
                Text("Last 7 Days")
                    .font(.title2.bold())
                    .padding(.horizontal)

                Chart(last7Days) { day in
                    BarMark(
                        x: .value("Day", day.date, unit: .day),
                        y: .value("ml", day.totalML)
                    )
                    .foregroundStyle(
                        day.totalML >= HydrationGoal.current ? Color.green : Color.blue
                    )
                    .cornerRadius(6)

                    RuleMark(y: .value("Goal", HydrationGoal.current))
                        .foregroundStyle(.red.opacity(0.55))
                        .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [5, 3]))
                        .annotation(position: .top, alignment: .trailing) {
                            Text("Goal")
                                .font(.caption2)
                                .foregroundStyle(.red)
                        }
                }
                .frame(height: 220)
                .padding(.horizontal)
                .chartXAxis {
                    AxisMarks(values: .stride(by: .day)) { _ in
                        AxisValueLabel(format: .dateTime.weekday(.abbreviated))
                    }
                }
            }
            .padding(.vertical)
        }
        .navigationTitle("History")
        .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    NavigationStack { HistoryView() }
        .modelContainer(for: [WaterEntry.self], inMemory: true)
}

// Services/HealthKitService.swift
import HealthKit
import Foundation

final class HealthKitService {
    static let shared = HealthKitService()
    private let store = HKHealthStore()
    private let waterType = HKQuantityType(.dietaryWater)

    var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }

    func requestPermission() async throws {
        guard isAvailable else { return }
        try await store.requestAuthorization(toShare: [waterType], read: [])
    }

    func logWater(amountML: Double, date: Date = .now) async throws {
        guard isAvailable else { return }
        let qty = HKQuantity(unit: .literUnit(with: .milli), doubleValue: amountML)
        let sample = HKQuantitySample(
            type: waterType,
            quantity: qty,
            start: date,
            end: date
        )
        try await store.save(sample)
    }
}

5. Schedule hydration reminders and add the Privacy Manifest

Recurring reminders dramatically improve user retention for habit-forming apps. Request notification permission on first launch, then schedule one UNCalendarNotificationTrigger per reminder slot so they fire daily without a server. The PrivacyInfo.xcprivacy file is mandatory for all App Store submissions as of 2024 — missing it causes automatic rejection. Create it via File › New › File › App Privacy in Xcode.

// Services/NotificationService.swift
import UserNotifications
import Foundation

@MainActor
final class NotificationService {
    static let shared = NotificationService()

    func requestPermission() async -> Bool {
        let center = UNUserNotificationCenter.current()
        let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
        return granted ?? false
    }

    func scheduleReminders(everyHours interval: Int = 2,
                           from startHour: Int = 8,
                           until endHour: Int = 21) {
        let center = UNUserNotificationCenter.current()
        // Clear old reminders before rescheduling
        center.removePendingNotificationRequests(
            withIdentifiers: (startHour...endHour).map { "hydration.\($0)" }
        )

        let content = UNMutableNotificationContent()
        content.title = "Time to hydrate 💧"
        content.body = "A glass of water keeps you sharp and energised."
        content.sound = .default

        var hour = startHour
        while hour <= endHour {
            var components = DateComponents()
            components.hour = hour
            components.minute = 0
            let trigger = UNCalendarNotificationTrigger(
                dateMatching: components, repeats: true
            )
            let request = UNNotificationRequest(
                identifier: "hydration.\(hour)",
                content: content,
                trigger: trigger
            )
            center.add(request)
            hour += interval
        }
    }
}

// Call on app launch (in WaterTrackerApp.init or an .onAppear):
// Task {
//     await NotificationService.shared.requestPermission()
//     NotificationService.shared.scheduleReminders()
// }

// Resources/PrivacyInfo.xcprivacy  (key declarations — edit in Xcode's GUI)
// NSPrivacyTracking: NO
// NSPrivacyTrackingDomains: []
// NSPrivacyCollectedDataTypes:
//   - NSPrivacyCollectedDataType: NSPrivacyCollectedDataTypeHealth
//     NSPrivacyCollectedDataTypeLinked: NO
//     NSPrivacyCollectedDataTypeTracking: NO
//     NSPrivacyCollectedDataTypePurposes:
//       - NSPrivacyCollectedDataTypePurposeAppFunctionality
// NSPrivacyAccessedAPITypes:
//   - NSPrivacyAccessedAPIType: NSPrivacyAccessedAPICategoryUserDefaults
//     NSPrivacyAccessedAPITypeReasons: [CA92.1]

Common pitfalls

Adding monetization: One-time purchase

A one-time purchase (also called a non-consumable in-app purchase) is a natural fit for a utility like this — users pay once and own the app forever. Implement it with StoreKit 2: define a non-consumable product in App Store Connect, then use Product.products(for:) to fetch it at launch and product.purchase() when the user taps Buy. Listen to Transaction.updates in a background task to handle edge cases like family sharing and refunds. Gate premium features — custom goal amounts, additional reminder slots, and the history chart — behind a hasPurchased flag persisted in UserDefaults and verified against the latest StoreKit transaction on each launch. Keep the core logging flow free so users experience the value before committing.

Shipping this faster with Soarias

Soarias handles the parts of this build that have nothing to do with your app idea: it scaffolds the SwiftData model container and service layer from a short description, auto-generates the PrivacyInfo.xcprivacy with the correct NSPrivacyAccessedAPICategoryUserDefaults reason codes, sets up a Fastlane Matchfile for code signing, and submits the finished binary to App Store Connect with metadata pre-filled. You describe the feature, Soarias writes the boilerplate, and Claude Code reviews the result in your local environment before anything touches the App Store.

For a beginner-complexity project like this water tracker, most developers spend roughly half their weekend fighting Xcode signing, provisioning profiles, and the Privacy Manifest rather than building features. Soarias compresses that overhead to under 10 minutes, meaning your weekend goes from "maybe I'll have a TestFlight build" to "I submitted to the App Store and have time left over to add a custom reminder sound."

Related guides

FAQ

Does this work on iOS 16?

The guide targets iOS 17+ because it uses the @Observable macro, the #Predicate macro for SwiftData queries, and the #Preview macro — all introduced in iOS 17 / Xcode 15. If you need iOS 16 support, replace @Observable with ObservableObject, use @FetchRequest with Core Data instead of SwiftData, and fall back to PreviewProvider. That said, iOS 17 adoption exceeds 90% as of early 2026, so targeting iOS 17 as your minimum is a safe call for a new App Store submission.

Do I need a paid Apple Developer account to test?

You can run the app on the Simulator and on your personal device via free provisioning without a paid account. However, HealthKit requires a real device, TestFlight distribution requires a paid account ($99/year), and App Store submission obviously does too. If you just want to validate the UI and SwiftData logic, the free tier is fine for the first weekend.

How do I add this to the App Store?

Create an app record in App Store Connect, upload a build via Xcode's Organiser or Fastlane, fill in the required metadata (name, description, screenshots for iPhone 6.9" and 6.5", privacy URL), declare your data practices using the nutrition labels wizard, and submit for review. First-time reviews typically take 24–48 hours. Make sure your HealthKit usage description is clear and your PrivacyInfo.xcprivacy is present, as those are the two most common first-time rejection reasons for health apps.

My app is beginner complexity — do I really need the Charts framework?

Not for your MVP. The history screen with Charts is genuinely optional — you could ship with just the dashboard ring and daily log, then add the chart in a 1.1 update once you have real user feedback. Shipping a focused v1.0 beats a delayed "complete" app every time. The Charts step is included here because the framework is straightforward to integrate and meaningfully increases perceived value for a one-time purchase price point.

Last reviewed: 2026-05-12 by the Soarias team.

```