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.
Prerequisites
- Mac with Xcode 16+ installed
- Apple Developer Program ($99/year) — required for TestFlight, App Store, and HealthKit entitlements
- Basic Swift and SwiftUI knowledge — you should be comfortable with views, state, and navigation
- A physical iPhone for HealthKit testing — the Simulator does not support HealthKit read/write
- HealthKit capability added in your target's Signing & Capabilities pane
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
- HealthKit on the Simulator:
HKHealthStore.isHealthDataAvailable()returnsfalsein the Simulator. Always guard on this flag and test HealthKit writes on a real device before submitting. - Midnight boundary bug: If you filter today's entries with a hardcoded
Date()snapshot rather than re-evaluatingstartOfDaydynamically, entries logged just after midnight appear in yesterday's bucket. Recompute the day boundary inside the query predicate. - App Store rejection — missing HealthKit usage string: Adding the HealthKit capability without a matching
NSHealthShareUsageDescriptionandNSHealthUpdateUsageDescriptioninInfo.plistcauses an automatic binary rejection. Add both strings even if you only write data. - Too many scheduled notifications: iOS caps pending notifications at 64 per app. If users can freely adjust their reminder frequency, remove all existing hydration notifications before scheduling the new set, otherwise you'll silently hit the cap.
- Privacy Manifest omission: Any app using
UserDefaultsmust declareNSPrivacyAccessedAPICategoryUserDefaultswith an approved reason code inPrivacyInfo.xcprivacy. Omitting this is an automatic App Store rejection as of Spring 2024 policy.
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.