diff --git a/README.md b/README.md
index 5ccef78b6..8ce770f58 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,10 @@ allowing you to achieve your long-term goals. Detailed graphs and statistics
show you how your habits improved over time. It is completely ad-free and open
source.
+This repository now contains both the classic Android client and a brand-new
+SwiftUI implementation for iOS. See [docs/IOS.md](docs/IOS.md) for build
+instructions and a feature overview of the iOS port.
+
diff --git a/docs/IOS.md b/docs/IOS.md
new file mode 100644
index 000000000..672851b3e
--- /dev/null
+++ b/docs/IOS.md
@@ -0,0 +1,33 @@
+# Loop Habit Tracker for iOS
+
+The `ios/LoopHabitTracker` directory contains the SwiftUI implementation of the Loop Habit Tracker iOS application. The project is described using an [XcodeGen](https://github.com/yonaskolb/XcodeGen) manifest (`project.yml`). To generate an Xcode project:
+
+1. Install XcodeGen (`brew install xcodegen`).
+2. From the repository root run:
+ ```bash
+ cd ios/LoopHabitTracker
+ xcodegen generate
+ ```
+3. Open the generated `LoopHabitTracker.xcodeproj` in Xcode and run on iOS 17+.
+
+## Feature coverage
+
+The iOS codebase mirrors the structure of the original Android project while adopting SwiftUI patterns:
+
+- Habit list with score rings, streak indicators and quick completion toggle.
+- Habit detail screen with charts, history grid, statistics and editable notes.
+- Persistence backed by JSON files (`habits.json`) with sample bootstrap data.
+- Modular feature folders for list, detail, creation and settings flows.
+- Placeholders for reminders, data export, widgets and backups to clearly mark the remaining work needed for parity with Android.
+- Asset catalog entries (app icon, accent colors) are stubbed so the build succeeds; replace the placeholder icon names with real artwork before publishing to the App Store.
+
+## Roadmap placeholders
+
+Some Android features require native iOS services and will be delivered in follow-up iterations. The UI already reserves their location:
+
+- Reminder configuration toggles, waiting for UserNotifications integration.
+- CSV/SQLite export entry points, pending share sheet wiring.
+- Widget preview cards earmarked for WidgetKit.
+- Backup and restore buttons in the settings screen to be connected to Files/CloudKit flows.
+
+Each placeholder view describes the missing functionality so future contributors know exactly where to continue.
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/App/LoopHabitTrackerApp.swift b/ios/LoopHabitTracker/LoopHabitTracker/App/LoopHabitTrackerApp.swift
new file mode 100644
index 000000000..4601aa04e
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/App/LoopHabitTrackerApp.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+
+@main
+struct LoopHabitTrackerApp: App {
+ @StateObject private var store = HabitStore()
+
+ var body: some Scene {
+ WindowGroup {
+ HabitListView()
+ .environmentObject(store)
+ .task {
+ await store.load()
+ }
+ }
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/AddHabit/AddHabitView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/AddHabit/AddHabitView.swift
new file mode 100644
index 000000000..fd8169a67
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/AddHabit/AddHabitView.swift
@@ -0,0 +1,145 @@
+import SwiftUI
+
+struct AddHabitView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var form: HabitFormModel
+ var onCreate: (Habit) -> Void
+
+ init(habit: Habit? = nil, onCreate: @escaping (Habit) -> Void) {
+ self._form = State(initialValue: HabitFormModel(habit: habit))
+ self.onCreate = onCreate
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section(header: Text("Details")) {
+ TextField("Name", text: $form.name)
+ .textInputAutocapitalization(.words)
+ TextField("Question", text: $form.question)
+ .textInputAutocapitalization(.sentences)
+ TextField("Notes", text: $form.notes, axis: .vertical)
+ .lineLimit(3...6)
+ }
+
+ Section(header: Text("Schedule")) {
+ SchedulePickerView(schedule: $form.schedule)
+ }
+
+ Section(header: Text("Color")) {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 12) {
+ ForEach(HabitColor.palette) { color in
+ Button {
+ form.color = color
+ } label: {
+ Circle()
+ .fill(color.color)
+ .frame(width: 36, height: 36)
+ .overlay(
+ Circle()
+ .stroke(Color.primary.opacity(form.color == color ? 0.6 : 0.1), lineWidth: form.color == color ? 4 : 1)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ Section(header: Text("Goal")) {
+ Stepper(value: $form.targetValue, in: 1...100, step: 1) {
+ Text("Target: \(Int(form.targetValue)) \(form.unit)")
+ }
+ TextField("Unit", text: $form.unit)
+ .textInputAutocapitalization(.never)
+ }
+ }
+ .navigationTitle(form.isEditing ? "Edit habit" : "New habit")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(form.isEditing ? "Save" : "Create") {
+ let habit = form.makeHabit()
+ onCreate(habit)
+ dismiss()
+ }
+ .disabled(!form.isValid)
+ }
+ }
+ }
+ }
+}
+
+private struct HabitFormModel {
+ var id: UUID?
+ var name: String
+ var question: String
+ var notes: String
+ var color: HabitColor
+ var schedule: HabitSchedule
+ var targetValue: Double
+ var unit: String
+ var createdDate: Date
+ var reminder: HabitReminder?
+ var archived: Bool
+ var events: [HabitEvent]
+
+ init(habit: Habit?) {
+ if let habit {
+ self.id = habit.id
+ self.name = habit.name
+ self.question = habit.question
+ self.notes = habit.notes
+ self.color = habit.color
+ self.schedule = habit.schedule
+ self.targetValue = habit.targetValue
+ self.unit = habit.unit
+ self.createdDate = habit.createdDate
+ self.reminder = habit.reminder
+ self.archived = habit.archived
+ self.events = habit.events
+ } else {
+ self.id = nil
+ self.name = ""
+ self.question = ""
+ self.notes = ""
+ self.color = HabitColor.palette.first ?? HabitColor.default
+ self.schedule = .daily
+ self.targetValue = 1
+ self.unit = "time"
+ self.createdDate = Date()
+ self.reminder = nil
+ self.archived = false
+ self.events = []
+ }
+ }
+
+ var isEditing: Bool { id != nil }
+
+ var isValid: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+
+ func makeHabit() -> Habit {
+ Habit(
+ id: id ?? UUID(),
+ name: name.trimmingCharacters(in: .whitespacesAndNewlines),
+ question: question,
+ notes: notes,
+ color: color,
+ schedule: schedule,
+ reminder: reminder,
+ createdDate: createdDate,
+ archived: archived,
+ events: events,
+ targetValue: targetValue,
+ unit: unit
+ )
+ }
+}
+
+#Preview {
+ AddHabitView { _ in }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/AddHabit/SchedulePickerView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/AddHabit/SchedulePickerView.swift
new file mode 100644
index 000000000..315b306d5
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/AddHabit/SchedulePickerView.swift
@@ -0,0 +1,167 @@
+import SwiftUI
+
+struct SchedulePickerView: View {
+ @Binding var schedule: HabitSchedule
+
+ @State private var selectedOption: Option
+ @State private var timesPerWeek: Int
+ @State private var intervalDays: Int
+ @State private var selectedDays: Set
+ @State private var customDescription: String
+
+ init(schedule: Binding) {
+ _schedule = schedule
+ let option = Option(schedule: schedule.wrappedValue)
+ _selectedOption = State(initialValue: option)
+ switch schedule.wrappedValue {
+ case .daily:
+ _timesPerWeek = State(initialValue: 3)
+ _intervalDays = State(initialValue: 1)
+ _selectedDays = State(initialValue: [])
+ _customDescription = State(initialValue: "")
+ case .timesPerWeek(let count):
+ _timesPerWeek = State(initialValue: count)
+ _intervalDays = State(initialValue: 1)
+ _selectedDays = State(initialValue: [])
+ _customDescription = State(initialValue: "")
+ case .weekly(let days):
+ _timesPerWeek = State(initialValue: max(days.count, 1))
+ _intervalDays = State(initialValue: 1)
+ _selectedDays = State(initialValue: days)
+ _customDescription = State(initialValue: "")
+ case .everyXDays(let interval):
+ _timesPerWeek = State(initialValue: 3)
+ _intervalDays = State(initialValue: max(interval, 1))
+ _selectedDays = State(initialValue: [])
+ _customDescription = State(initialValue: "")
+ case .custom(let description):
+ _timesPerWeek = State(initialValue: 3)
+ _intervalDays = State(initialValue: 1)
+ _selectedDays = State(initialValue: [])
+ _customDescription = State(initialValue: description)
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Picker("Schedule", selection: $selectedOption) {
+ ForEach(Option.allCases) { option in
+ Text(option.label).tag(option)
+ }
+ }
+ .pickerStyle(.segmented)
+
+ switch selectedOption {
+ case .daily:
+ Text("Repeat every day.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ case .timesPerWeek:
+ Stepper(value: $timesPerWeek, in: 1...7) {
+ Text("\(timesPerWeek) times per week")
+ }
+ case .weekly:
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Pick the days of the week")
+ .font(.subheadline)
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
+ ForEach(Weekday.allCases) { day in
+ Button {
+ if selectedDays.contains(day) {
+ selectedDays.remove(day)
+ } else {
+ selectedDays.insert(day)
+ }
+ } label: {
+ Text(day.localizedName)
+ .font(.caption)
+ .padding(.vertical, 6)
+ .frame(maxWidth: .infinity)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(selectedDays.contains(day) ? Color.accentColor.opacity(0.25) : Color(UIColor.secondarySystemBackground))
+ )
+ }
+ }
+ }
+ }
+ case .everyXDays:
+ Stepper(value: $intervalDays, in: 1...30) {
+ Text(intervalDays == 1 ? "Every day" : "Every \(intervalDays) days")
+ }
+ case .custom:
+ TextField("Describe your schedule", text: $customDescription)
+ .textInputAutocapitalization(.sentences)
+ }
+ }
+ .onChange(of: selectedOption, perform: updateSchedule)
+ .onChange(of: timesPerWeek) { _ in updateSchedule(selectedOption) }
+ .onChange(of: intervalDays) { _ in updateSchedule(selectedOption) }
+ .onChange(of: selectedDays) { _ in updateSchedule(selectedOption) }
+ .onChange(of: customDescription) { _ in updateSchedule(selectedOption) }
+ }
+
+ private func updateSchedule(_ option: Option) {
+ switch option {
+ case .daily:
+ schedule = .daily
+ case .timesPerWeek:
+ schedule = .timesPerWeek(timesPerWeek)
+ case .weekly:
+ if selectedDays.isEmpty {
+ schedule = .weekly(days: [.monday, .wednesday, .friday])
+ } else {
+ schedule = .weekly(days: selectedDays)
+ }
+ case .everyXDays:
+ schedule = .everyXDays(intervalDays)
+ case .custom:
+ schedule = .custom(description: customDescription.isEmpty ? "Custom schedule" : customDescription)
+ }
+ }
+
+ private enum Option: String, CaseIterable, Identifiable {
+ case daily
+ case timesPerWeek
+ case weekly
+ case everyXDays
+ case custom
+
+ var id: String { rawValue }
+
+ init(schedule: HabitSchedule) {
+ switch schedule {
+ case .daily:
+ self = .daily
+ case .timesPerWeek:
+ self = .timesPerWeek
+ case .weekly:
+ self = .weekly
+ case .everyXDays:
+ self = .everyXDays
+ case .custom:
+ self = .custom
+ }
+ }
+
+ var label: String {
+ switch self {
+ case .daily:
+ return "Daily"
+ case .timesPerWeek:
+ return "Weekly quota"
+ case .weekly:
+ return "Specific days"
+ case .everyXDays:
+ return "Interval"
+ case .custom:
+ return "Custom"
+ }
+ }
+ }
+}
+
+#Preview {
+ SchedulePickerView(schedule: .constant(.timesPerWeek(3)))
+ .padding()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/HabitDetailView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/HabitDetailView.swift
new file mode 100644
index 000000000..f37317e73
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/HabitDetailView.swift
@@ -0,0 +1,155 @@
+import SwiftUI
+
+struct HabitDetailView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var habit: Habit
+ let onSave: (Habit) -> Void
+
+ private var calendar: Calendar { Calendar.current }
+ private var today: Date { Date() }
+ private var stats: HabitStatistics { habit.statistics(asOf: today, calendar: calendar) }
+
+ init(habit: Habit, onSave: @escaping (Habit) -> Void) {
+ self._habit = State(initialValue: habit)
+ self.onSave = onSave
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 24) {
+ overview
+ statisticsSection
+ scoreChartSection
+ historySection
+ remindersSection
+ exportSection
+ widgetsSection
+ notesSection
+ }
+ .padding()
+ }
+ .background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
+ .navigationTitle(habit.displayName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Done") {
+ onSave(habit)
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+
+ private var overview: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(alignment: .center, spacing: 16) {
+ ScoreRingView(
+ color: habit.color.color,
+ progress: stats.currentScore,
+ isCompleted: habit.hasEvent(on: today)
+ ) {
+ habit.toggleCompletion(on: today)
+ onSave(habit)
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(habit.displayName)
+ .font(.title2.weight(.semibold))
+ Text(habit.schedule.description)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ Text("Current score \(Int(stats.currentScore * 100))%")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ if !habit.question.isEmpty {
+ Text(habit.question)
+ .font(.body)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+
+ private var statisticsSection: some View {
+ StatisticsOverviewView(stats: stats)
+ }
+
+ private var scoreChartSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Label("Habit strength", systemImage: "chart.xyaxis.line")
+ .font(.headline)
+ Spacer()
+ Text("Last 90 days")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ ScoreChartView(samples: stats.timeline)
+ .frame(height: 220)
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+
+ private var historySection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Recent history", systemImage: "calendar")
+ .font(.headline)
+ HistoryGridView(habit: habit, calendar: calendar)
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+
+ private var remindersSection: some View {
+ ReminderPlaceholderView(reminder: habit.reminder) { newReminder in
+ habit.reminder = newReminder
+ onSave(habit)
+ }
+ }
+
+ private var exportSection: some View {
+ ExportPlaceholderView(habit: habit)
+ }
+
+ private var widgetsSection: some View {
+ WidgetPlaceholderView()
+ }
+
+ private var notesSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Notes", systemImage: "square.and.pencil")
+ .font(.headline)
+ TextEditor(text: Binding(
+ get: { habit.notes },
+ set: {
+ habit.notes = $0
+ onSave(habit)
+ }
+ ))
+ .frame(minHeight: 120)
+ .padding(8)
+ .background(RoundedRectangle(cornerRadius: 12).fill(Color(UIColor.secondarySystemBackground)))
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+}
+
+#Preview {
+ HabitDetailView(habit: PreviewData.bootstrapHabits.first!) { _ in }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/HistoryGridView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/HistoryGridView.swift
new file mode 100644
index 000000000..4b0139933
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/HistoryGridView.swift
@@ -0,0 +1,81 @@
+import SwiftUI
+
+struct HistoryGridView: View {
+ let habit: Habit
+ var calendar: Calendar = .current
+ var range: Int = 56
+
+ private var dates: [Date] {
+ let today = calendar.startOfDay(for: Date())
+ return (0.. String {
+ let weekday = Weekday.weekday(for: date, calendar: calendar)
+ return weekday.localizedName.prefix(1).uppercased()
+ }
+
+ private func color(for completed: Bool, due: Bool) -> Color {
+ if completed {
+ return habit.color.color
+ } else if due {
+ return Color(UIColor.tertiarySystemFill)
+ } else {
+ return Color(UIColor.systemFill).opacity(0.2)
+ }
+ }
+
+ private func accessibilityLabel(for date: Date, completed: Bool, due: Bool) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ let dateString = formatter.string(from: date)
+ if completed {
+ return "\(dateString): completed"
+ } else if due {
+ return "\(dateString): missed"
+ } else {
+ return "\(dateString): rest day"
+ }
+ }
+}
+
+private extension Color {
+ var accessibilityTextColor: Color {
+ var red: CGFloat = 0
+ var green: CGFloat = 0
+ var blue: CGFloat = 0
+ var alpha: CGFloat = 0
+ #if canImport(UIKit)
+ UIColor(self).getRed(&red, green: &green, blue: &blue, alpha: &alpha)
+ let luminance = 0.299 * red + 0.587 * green + 0.114 * blue
+ return luminance > 0.6 ? .black : .white
+ #else
+ return .white
+ #endif
+ }
+}
+
+#Preview {
+ HistoryGridView(habit: PreviewData.bootstrapHabits.first!)
+ .padding()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/ScoreChartView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/ScoreChartView.swift
new file mode 100644
index 000000000..8c5cf0ee7
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/ScoreChartView.swift
@@ -0,0 +1,76 @@
+import SwiftUI
+#if canImport(Charts)
+import Charts
+#endif
+
+struct ScoreChartView: View {
+ var samples: [ScoreSample]
+
+ var body: some View {
+ #if canImport(Charts)
+ if #available(iOS 16.0, *) {
+ Chart(samples.suffix(120)) { sample in
+ LineMark(
+ x: .value("Date", sample.date),
+ y: .value("Score", sample.value)
+ )
+ .interpolationMethod(.catmullRom)
+ .foregroundStyle(LinearGradient(
+ colors: [.blue, .green],
+ startPoint: .leading,
+ endPoint: .trailing
+ ))
+ AreaMark(
+ x: .value("Date", sample.date),
+ y: .value("Score", sample.value)
+ )
+ .foregroundStyle(LinearGradient(
+ gradient: Gradient(colors: [Color.blue.opacity(0.25), Color.clear]),
+ startPoint: .top,
+ endPoint: .bottom
+ ))
+ }
+ .chartXAxis {
+ AxisMarks(values: .stride(by: .month)) { value in
+ AxisGridLine()
+ AxisValueLabel(format: .dateTime.month(.abbreviated))
+ }
+ }
+ .chartYAxis {
+ AxisMarks(values: stride(from: 0.0, through: 1.0, by: 0.25)) { value in
+ AxisGridLine()
+ AxisValueLabel {
+ if let doubleValue = value.as(Double.self) {
+ Text("\(Int(doubleValue * 100))%")
+ }
+ }
+ }
+ }
+ } else {
+ placeholder
+ }
+ #else
+ placeholder
+ #endif
+ }
+
+ private var placeholder: some View {
+ VStack(alignment: .center, spacing: 8) {
+ Image(systemName: "waveform")
+ .font(.title)
+ .foregroundColor(.secondary)
+ Text("Charts are not available in this preview.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(UIColor.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+}
+
+#Preview {
+ ScoreChartView(samples: PreviewData.bootstrapHabits.first!.scoreTimeline())
+ .frame(height: 220)
+ .padding()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/StatisticsOverviewView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/StatisticsOverviewView.swift
new file mode 100644
index 000000000..289e35194
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitDetail/StatisticsOverviewView.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+
+struct StatisticsOverviewView: View {
+ let stats: HabitStatistics
+
+ private var values: [StatisticValue] {
+ [
+ StatisticValue(
+ title: "Current streak",
+ value: "\(stats.currentStreak) days",
+ systemImage: "flame.fill",
+ tint: .orange
+ ),
+ StatisticValue(
+ title: "Best streak",
+ value: "\(stats.bestStreak) days",
+ systemImage: "crown.fill",
+ tint: .yellow
+ ),
+ StatisticValue(
+ title: "Completion rate",
+ value: String(format: "%.0f%%", stats.completionRate * 100),
+ systemImage: "target",
+ tint: .blue
+ ),
+ StatisticValue(
+ title: "30-day change",
+ value: String(format: "%+.0f%%", stats.scoreChange(days: 30) * 100),
+ systemImage: "arrow.up.and.down",
+ tint: stats.scoreChange(days: 30) >= 0 ? .green : .red
+ ),
+ StatisticValue(
+ title: "90-day avg",
+ value: String(format: "%.0f%%", stats.averageScore(last: 90) * 100),
+ systemImage: "waveform.path.ecg",
+ tint: .purple
+ ),
+ StatisticValue(
+ title: "Today",
+ value: String(format: "%.0f%%", stats.todaysCompletionValue * 100),
+ systemImage: "sun.max.fill",
+ tint: .pink
+ )
+ ]
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Statistics", systemImage: "chart.bar")
+ .font(.headline)
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 2), spacing: 12) {
+ ForEach(values) { stat in
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: stat.systemImage)
+ .foregroundColor(stat.tint)
+ .font(.title3)
+ Spacer()
+ Text(stat.value)
+ .font(.headline)
+ }
+ Text(stat.title)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.secondarySystemBackground)))
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+}
+
+private struct StatisticValue: Identifiable {
+ let id = UUID()
+ let title: String
+ let value: String
+ let systemImage: String
+ let tint: Color
+}
+
+#Preview {
+ StatisticsOverviewView(stats: PreviewData.bootstrapHabits.first!.statistics())
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/HabitListView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/HabitListView.swift
new file mode 100644
index 000000000..578729dd7
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/HabitListView.swift
@@ -0,0 +1,158 @@
+import SwiftUI
+
+struct HabitListView: View {
+ @EnvironmentObject private var store: HabitStore
+ @State private var showingAddHabit = false
+ @State private var showingSettings = false
+ @State private var selectedHabit: Habit?
+
+ private var today: Date { Date() }
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ backgroundView
+ content
+ }
+ .navigationTitle("Habits")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button {
+ showingSettings = true
+ } label: {
+ Image(systemName: "gearshape")
+ }
+ }
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ showingAddHabit = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Add habit")
+ }
+ ToolbarItem(placement: .navigationBarTrailing) {
+ EditButton()
+ }
+ }
+ .sheet(isPresented: $showingAddHabit) {
+ AddHabitView { newHabit in
+ store.addHabit(newHabit)
+ }
+ .presentationDetents([.medium, .large])
+ }
+ .sheet(item: $selectedHabit) { habit in
+ HabitDetailView(habit: habit) { updated in
+ store.updateHabit(updated)
+ }
+ .presentationDetents([.large])
+ }
+ .sheet(isPresented: $showingSettings) {
+ AppSettingsView()
+ }
+ .alert(item: Binding(
+ get: {
+ store.lastError.map { PersistenceAlertContext(error: $0) }
+ },
+ set: { _ in }
+ )) { context in
+ Alert(
+ title: Text("Persistence error"),
+ message: Text(context.message),
+ dismissButton: .default(Text("OK"))
+ )
+ }
+ }
+ }
+
+ private var backgroundView: some View {
+ LinearGradient(
+ gradient: Gradient(colors: [Color(UIColor.systemBackground), Color(UIColor.secondarySystemBackground)]),
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .ignoresSafeArea()
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if store.activeHabits.isEmpty && store.archivedHabits.isEmpty {
+ VStack(spacing: 16) {
+ Image(systemName: "checkmark.circle")
+ .font(.system(size: 60))
+ .foregroundColor(.secondary)
+ Text("No habits yet")
+ .font(.headline)
+ Text("Tap the + button to create your first habit.")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ } else {
+ List {
+ if !store.activeHabits.isEmpty {
+ Section(header: Text("Active")) {
+ ForEach(store.activeHabits) { habit in
+ HabitRowView(habit: habit, today: today) {
+ selectedHabit = habit
+ } toggleCompletion: {
+ store.toggleCompletion(for: habit.id, on: today)
+ }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ store.setArchived(true, habitID: habit.id)
+ } label: {
+ Label("Archive", systemImage: "archivebox")
+ }
+ }
+ }
+ .onDelete { offsets in
+ store.removeHabits(at: offsets)
+ }
+ .onMove { indices, newOffset in
+ store.reorder(fromOffsets: indices, toOffset: newOffset, archived: false)
+ }
+ }
+ }
+
+ if !store.archivedHabits.isEmpty {
+ Section(header: Text("Archived")) {
+ ForEach(store.archivedHabits) { habit in
+ HabitRowView(habit: habit, today: today) {
+ selectedHabit = habit
+ } toggleCompletion: {
+ store.toggleCompletion(for: habit.id, on: today)
+ }
+ .swipeActions(edge: .trailing) {
+ Button {
+ store.setArchived(false, habitID: habit.id)
+ } label: {
+ Label("Activate", systemImage: "arrow.uturn.backward")
+ }
+ }
+ }
+ .onDelete { offsets in
+ store.removeHabits(at: offsets, inArchivedSection: true)
+ }
+ }
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .listStyle(.insetGrouped)
+ }
+ }
+}
+
+private struct PersistenceAlertContext: Identifiable {
+ var id = UUID()
+ let message: String
+
+ init(error: HabitPersistence.PersistenceError) {
+ message = error.localizedDescription
+ }
+}
+
+#Preview {
+ HabitListView()
+ .environmentObject(HabitStore(habits: PreviewData.bootstrapHabits))
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/HabitRowView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/HabitRowView.swift
new file mode 100644
index 000000000..05493b8f3
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/HabitRowView.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+
+struct HabitRowView: View {
+ let habit: Habit
+ let today: Date
+ let onTap: () -> Void
+ let toggleCompletion: () -> Void
+
+ private var stats: HabitStatistics {
+ habit.statistics(asOf: today)
+ }
+
+ var body: some View {
+ Button(action: onTap) {
+ HStack(spacing: 16) {
+ ScoreRingView(
+ color: habit.color.color,
+ progress: stats.currentScore,
+ isCompleted: habit.hasEvent(on: today)
+ ) {
+ toggleCompletion()
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text(habit.displayName)
+ .font(.headline)
+ .foregroundColor(.primary)
+ if !habit.question.isEmpty {
+ Text(habit.question)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .lineLimit(2)
+ }
+
+ HStack(spacing: 12) {
+ Label {
+ Text("\(Int(stats.currentScore * 100))%")
+ } icon: {
+ Image(systemName: "chart.bar.fill")
+ .foregroundStyle(habit.color.color)
+ }
+ .font(.caption)
+
+ Label {
+ Text("Streak: \(stats.currentStreak)")
+ } icon: {
+ Image(systemName: "flame.fill")
+ .foregroundStyle(.orange)
+ }
+ .font(.caption)
+ }
+ }
+ Spacer()
+ }
+ .padding(.vertical, 8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+#Preview {
+ HabitRowView(
+ habit: PreviewData.bootstrapHabits.first!,
+ today: Date(),
+ onTap: {},
+ toggleCompletion: {}
+ )
+ .environmentObject(HabitStore(habits: PreviewData.bootstrapHabits))
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/ScoreRingView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/ScoreRingView.swift
new file mode 100644
index 000000000..4c3942681
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/HabitList/ScoreRingView.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+
+struct ScoreRingView: View {
+ var color: Color
+ var progress: Double
+ var isCompleted: Bool
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ ZStack {
+ Circle()
+ .stroke(color.opacity(0.15), lineWidth: 8)
+ Circle()
+ .trim(from: 0, to: CGFloat(min(max(progress, 0.0), 1.0)))
+ .stroke(style: StrokeStyle(lineWidth: 8, lineCap: .round))
+ .foregroundColor(color)
+ .rotationEffect(.degrees(-90))
+ .animation(.easeInOut(duration: 0.3), value: progress)
+
+ Image(systemName: isCompleted ? "checkmark" : "plus")
+ .font(.system(size: 14, weight: .bold))
+ .foregroundColor(isCompleted ? .white : color)
+ .padding(8)
+ .background(
+ Circle()
+ .fill(isCompleted ? color : color.opacity(0.15))
+ )
+ }
+ .frame(width: 54, height: 54)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(isCompleted ? "Mark as missed" : "Mark as completed")
+ }
+}
+
+#Preview {
+ HStack(spacing: 24) {
+ ScoreRingView(color: .blue, progress: 0.8, isCompleted: true, action: {})
+ ScoreRingView(color: .orange, progress: 0.3, isCompleted: false, action: {})
+ }
+ .padding()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/ExportPlaceholderView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/ExportPlaceholderView.swift
new file mode 100644
index 000000000..d24965006
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/ExportPlaceholderView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct ExportPlaceholderView: View {
+ var habit: Habit
+
+ @State private var showingInfo = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Data export", systemImage: "square.and.arrow.up")
+ .font(.headline)
+ Text("CSV and SQLite exports are not yet wired on iOS. Tap below to see where the export entry points will appear once implemented.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+
+ Button {
+ showingInfo = true
+ } label: {
+ Label("Export habit data", systemImage: "doc.badge.plus")
+ .font(.subheadline)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(habit.color.color)
+ .alert("Export roadmap", isPresented: $showingInfo) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("A background worker will serialize your habit entries, notes and scores to CSV and SQLite formats. The files will be shared using the system share sheet.")
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+}
+
+#Preview {
+ ExportPlaceholderView(habit: PreviewData.bootstrapHabits.first!)
+ .padding()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/ReminderPlaceholderView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/ReminderPlaceholderView.swift
new file mode 100644
index 000000000..fd4ef4f3e
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/ReminderPlaceholderView.swift
@@ -0,0 +1,55 @@
+import SwiftUI
+
+struct ReminderPlaceholderView: View {
+ var reminder: HabitReminder?
+ var onUpdate: (HabitReminder?) -> Void
+
+ @State private var showingPlaceholderAlert = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Label("Reminder", systemImage: "bell")
+ .font(.headline)
+ Spacer()
+ Toggle(isOn: Binding(
+ get: { reminder?.isEnabled ?? false },
+ set: { isOn in
+ var updated = reminder ?? HabitReminder()
+ updated.isEnabled = isOn
+ onUpdate(isOn ? updated : nil)
+ }
+ )) {
+ Text("Enabled")
+ }
+ .labelsHidden()
+ }
+
+ Text("Notification scheduling is not yet implemented on iOS. This section keeps your configuration so it can be wired to the native notification APIs in a future update.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+
+ Button {
+ showingPlaceholderAlert = true
+ } label: {
+ Label("Configure reminder", systemImage: "slider.horizontal.3")
+ .font(.subheadline)
+ }
+ .buttonStyle(.bordered)
+ .alert("Coming soon", isPresented: $showingPlaceholderAlert) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("Reminder time picker and notification scheduling will be implemented once the iOS notification stack is in place.")
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+}
+
+#Preview {
+ ReminderPlaceholderView(reminder: HabitReminder(isEnabled: true)) { _ in }
+ .padding()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/WidgetPlaceholderView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/WidgetPlaceholderView.swift
new file mode 100644
index 000000000..a9996a6af
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/Placeholders/WidgetPlaceholderView.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+struct WidgetPlaceholderView: View {
+ @State private var showingInfo = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Widgets", systemImage: "square.grid.2x2")
+ .font(.headline)
+ Text("Home screen and lock screen widgets will arrive soon. This placeholder reserves the layout slot and documents the intended design hooks.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+
+ Button {
+ showingInfo = true
+ } label: {
+ Label("Preview widget designs", systemImage: "eye")
+ .font(.subheadline)
+ }
+ .buttonStyle(.bordered)
+ .alert("Widget backlog", isPresented: $showingInfo) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("Interactive widget timelines will mirror your daily check-ins and scores. Implementation is pending the WidgetKit data bridge.")
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 16).fill(.background))
+ .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
+ }
+}
+
+#Preview {
+ WidgetPlaceholderView()
+ .padding()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Features/Settings/AppSettingsView.swift b/ios/LoopHabitTracker/LoopHabitTracker/Features/Settings/AppSettingsView.swift
new file mode 100644
index 000000000..aec18b608
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Features/Settings/AppSettingsView.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct AppSettingsView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var use24HourFormat = true
+ @State private var weekStartsOn: Weekday = .monday
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section(header: Text("General")) {
+ Toggle("24-hour time", isOn: $use24HourFormat)
+ Picker("First weekday", selection: $weekStartsOn) {
+ ForEach(Weekday.allCases) { day in
+ Text(day.localizedName.capitalized).tag(day)
+ }
+ }
+ }
+
+ Section(header: Text("Data"), footer: Text("Backups, imports and cloud sync will be added in a future milestone. This screen keeps the UX entry points documented.")) {
+ Button {
+ // Placeholder: implement backup workflow
+ } label: {
+ Label("Create backup", systemImage: "tray.and.arrow.up")
+ }
+ .disabled(true)
+
+ Button {
+ // Placeholder: implement restore workflow
+ } label: {
+ Label("Restore from backup", systemImage: "arrow.triangle.2.circlepath")
+ }
+ .disabled(true)
+ }
+
+ Section(header: Text("About")) {
+ Link(destination: URL(string: "https://github.com/iSoron/uhabits")!) {
+ Label("Project repository", systemImage: "link")
+ }
+ Link(destination: URL(string: "https://loophabits.org")!) {
+ Label("Official website", systemImage: "safari")
+ }
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Loop Habit Tracker is open source software released under the GPLv3 license.")
+ Text("This iOS version is in active development; some Android features are still on the roadmap.")
+ .foregroundColor(.secondary)
+ }
+ .font(.footnote)
+ }
+ }
+ .navigationTitle("Settings")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Close") { dismiss() }
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ AppSettingsView()
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Models/Habit.swift b/ios/LoopHabitTracker/LoopHabitTracker/Models/Habit.swift
new file mode 100644
index 000000000..683a25dd6
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Models/Habit.swift
@@ -0,0 +1,195 @@
+import Foundation
+import SwiftUI
+
+struct Habit: Identifiable, Codable, Equatable, Hashable {
+ var id: UUID
+ var name: String
+ var question: String
+ var notes: String
+ var color: HabitColor
+ var schedule: HabitSchedule
+ var reminder: HabitReminder?
+ var createdDate: Date
+ var archived: Bool
+ var events: [HabitEvent]
+ var targetValue: Double
+ var unit: String
+
+ init(
+ id: UUID = UUID(),
+ name: String,
+ question: String = "",
+ notes: String = "",
+ color: HabitColor = HabitColor.palette.first ?? HabitColor.default,
+ schedule: HabitSchedule = .daily,
+ reminder: HabitReminder? = nil,
+ createdDate: Date = Date(),
+ archived: Bool = false,
+ events: [HabitEvent] = [],
+ targetValue: Double = 1.0,
+ unit: String = "times"
+ ) {
+ self.id = id
+ self.name = name
+ self.question = question
+ self.notes = notes
+ self.color = color
+ self.schedule = schedule
+ self.reminder = reminder
+ self.createdDate = createdDate
+ self.archived = archived
+ self.events = events
+ self.targetValue = targetValue
+ self.unit = unit
+ }
+
+ var displayName: String { name }
+
+ var sortedEvents: [HabitEvent] {
+ events.sortedAscending()
+ }
+
+ func hasEvent(on date: Date, calendar: Calendar = .current) -> Bool {
+ events.containsEvent(on: date, calendar: calendar)
+ }
+
+ func completionValue(on date: Date, calendar: Calendar = .current) -> Double {
+ events.event(on: date, calendar: calendar)?.value ?? 0.0
+ }
+
+ func completionNote(on date: Date, calendar: Calendar = .current) -> String? {
+ events.event(on: date, calendar: calendar)?.note
+ }
+
+ func statistics(asOf date: Date = Date(), calendar: Calendar = .current) -> HabitStatistics {
+ let timeline = scoreTimeline(until: date, calendar: calendar)
+ let completions = completionMap(calendar: calendar)
+ return HabitStatistics(
+ habit: self,
+ timeline: timeline,
+ completions: completions,
+ asOf: date,
+ calendar: calendar
+ )
+ }
+
+ func scoreTimeline(until endDate: Date = Date(), calendar: Calendar = .current) -> [ScoreSample] {
+ let normalizedStart = calendar.startOfDay(for: createdDate)
+ let normalizedEnd = calendar.startOfDay(for: endDate)
+ guard normalizedStart <= normalizedEnd else { return [] }
+
+ var cursor = normalizedStart
+ var previousScore = 0.0
+ var samples: [ScoreSample] = []
+
+ let completionMap = completionMap(calendar: calendar)
+ while cursor <= normalizedEnd {
+ let key = calendar.startOfDay(for: cursor)
+ let due = schedule.isDue(on: key, since: createdDate, calendar: calendar)
+ let checkmark: Double
+ if due {
+ checkmark = completionMap[key] ?? 0.0
+ } else {
+ checkmark = previousScore
+ }
+ let computed = HabitScoreCalculator.compute(
+ frequency: schedule.frequency,
+ previousScore: previousScore,
+ checkmarkValue: checkmark
+ )
+ samples.append(ScoreSample(date: key, value: computed))
+ previousScore = computed
+ guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
+ cursor = next
+ }
+ return samples
+ }
+
+ func completionMap(calendar: Calendar = .current) -> [Date: Double] {
+ var map: [Date: Double] = [:]
+ let normalized = events.map { event -> HabitEvent in
+ var copy = event
+ copy.date = calendar.startOfDay(for: event.date)
+ return copy
+ }
+ for event in normalized {
+ map[event.date] = event.value
+ }
+ return map
+ }
+
+ func toggledCompletion(on date: Date, calendar: Calendar = .current) -> Habit {
+ var copy = self
+ copy.toggleCompletion(on: date, calendar: calendar)
+ return copy
+ }
+
+ mutating func toggleCompletion(on date: Date, calendar: Calendar = .current) {
+ let normalizedDate = calendar.startOfDay(for: date)
+ if let existing = events.event(on: normalizedDate, calendar: calendar) {
+ events = events.filter { $0.id != existing.id }
+ } else {
+ events.append(HabitEvent(date: normalizedDate, value: 1.0))
+ }
+ }
+
+ mutating func setCompletion(_ isCompleted: Bool, on date: Date, calendar: Calendar = .current) {
+ let normalizedDate = calendar.startOfDay(for: date)
+ events = events.filter { !calendar.isDate($0.date, inSameDayAs: normalizedDate) }
+ if isCompleted {
+ events.append(HabitEvent(date: normalizedDate, value: 1.0))
+ }
+ }
+
+ mutating func updateEvent(on date: Date, note: String?, value: Double = 1.0, calendar: Calendar = .current) {
+ let normalizedDate = calendar.startOfDay(for: date)
+ if var event = events.event(on: normalizedDate, calendar: calendar) {
+ event.note = note
+ event.value = value
+ events = events.replacing(event)
+ } else {
+ events.append(HabitEvent(date: normalizedDate, value: value, note: note))
+ }
+ }
+}
+
+struct HabitReminder: Codable, Hashable {
+ var isEnabled: Bool
+ var time: DateComponents
+ var message: String
+
+ init(isEnabled: Bool = false, time: DateComponents = DateComponents(hour: 9, minute: 0), message: String = "") {
+ self.isEnabled = isEnabled
+ self.time = time
+ self.message = message
+ }
+}
+
+struct HabitColor: Codable, Hashable, Identifiable {
+ var id: String { name }
+ var name: String
+ var hex: String
+
+ init(name: String, hex: String) {
+ self.name = name
+ self.hex = hex
+ }
+
+ var color: Color {
+ Color(hex: hex)
+ }
+
+ static let `default` = HabitColor(name: "Sky", hex: "4DA1FF")
+
+ static let palette: [HabitColor] = [
+ HabitColor(name: "Sky", hex: "4DA1FF"),
+ HabitColor(name: "Sunrise", hex: "FFB545"),
+ HabitColor(name: "Lime", hex: "74C365"),
+ HabitColor(name: "Orchid", hex: "B574FF"),
+ HabitColor(name: "Crimson", hex: "FF4F70"),
+ HabitColor(name: "Ocean", hex: "1772FF"),
+ HabitColor(name: "Slate", hex: "657786"),
+ HabitColor(name: "Midnight", hex: "192734"),
+ HabitColor(name: "Sunset", hex: "FF8360")
+ ]
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitEvent.swift b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitEvent.swift
new file mode 100644
index 000000000..1d5ff6049
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitEvent.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+struct HabitEvent: Identifiable, Codable, Hashable {
+ let id: UUID
+ var date: Date
+ var value: Double
+ var note: String?
+ var createdAt: Date
+
+ init(id: UUID = UUID(), date: Date, value: Double = 1.0, note: String? = nil, createdAt: Date = Date()) {
+ self.id = id
+ self.date = date
+ self.value = value
+ self.note = note
+ self.createdAt = createdAt
+ }
+}
+
+extension Array where Element == HabitEvent {
+ func event(on date: Date, calendar: Calendar = .current) -> HabitEvent? {
+ let normalized = calendar.startOfDay(for: date)
+ return first { calendar.isDate($0.date, inSameDayAs: normalized) }
+ }
+
+ func containsEvent(on date: Date, calendar: Calendar = .current) -> Bool {
+ event(on: date, calendar: calendar) != nil
+ }
+
+ func removingEvent(on date: Date, calendar: Calendar = .current) -> [HabitEvent] {
+ let normalized = calendar.startOfDay(for: date)
+ return filter { !calendar.isDate($0.date, inSameDayAs: normalized) }
+ }
+
+ func replacing(_ event: HabitEvent) -> [HabitEvent] {
+ map { $0.id == event.id ? event : $0 }
+ }
+
+ func sortedAscending() -> [HabitEvent] {
+ sorted { lhs, rhs in lhs.date < rhs.date }
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitPersistence.swift b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitPersistence.swift
new file mode 100644
index 000000000..9c43d4964
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitPersistence.swift
@@ -0,0 +1,78 @@
+import Foundation
+
+struct HabitPersistence {
+ enum PersistenceError: LocalizedError, Equatable {
+ case failedToAccessDirectory
+ case failedToLoad(String)
+ case failedToSave(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .failedToAccessDirectory:
+ return NSLocalizedString("Unable to access documents directory.", comment: "Persistence error")
+ case .failedToLoad(let message):
+ return message
+ case .failedToSave(let message):
+ return message
+ }
+ }
+ }
+
+ private let fileURL: URL
+ private let encoder: JSONEncoder
+ private let decoder: JSONDecoder
+ private let fileManager: FileManager
+
+ init(fileManager: FileManager = .default, directory: URL? = nil) {
+ self.fileManager = fileManager
+ self.encoder = JSONEncoder()
+ self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ self.encoder.dateEncodingStrategy = .iso8601
+
+ self.decoder = JSONDecoder()
+ self.decoder.dateDecodingStrategy = .iso8601
+
+ if let directory {
+ self.fileURL = directory.appendingPathComponent("habits.json")
+ } else {
+ let base = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first ?? fileManager.temporaryDirectory
+ self.fileURL = base.appendingPathComponent("habits.json")
+ }
+ }
+
+ func load() async throws -> [Habit] {
+ if !fileManager.fileExists(atPath: fileURL.path) {
+ return PreviewData.bootstrapHabits
+ }
+
+ return try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.global(qos: .userInitiated).async {
+ do {
+ let data = try Data(contentsOf: fileURL)
+ let habits = try decoder.decode([Habit].self, from: data)
+ continuation.resume(returning: habits)
+ } catch {
+ continuation.resume(throwing: PersistenceError.failedToLoad(error.localizedDescription))
+ }
+ }
+ }
+ }
+
+ func save(_ habits: [Habit]) async throws {
+ return try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let directory = fileURL.deletingLastPathComponent()
+ if !fileManager.fileExists(atPath: directory.path) {
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+ let data = try encoder.encode(habits)
+ try data.write(to: fileURL, options: [.atomic])
+ continuation.resume()
+ } catch {
+ continuation.resume(throwing: PersistenceError.failedToSave(error.localizedDescription))
+ }
+ }
+ }
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitSchedule.swift b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitSchedule.swift
new file mode 100644
index 000000000..f962afcef
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitSchedule.swift
@@ -0,0 +1,167 @@
+import Foundation
+
+enum Weekday: Int, CaseIterable, Codable, Identifiable {
+ case sunday = 1
+ case monday
+ case tuesday
+ case wednesday
+ case thursday
+ case friday
+ case saturday
+
+ var id: Int { rawValue }
+
+ var calendarIndex: Int {
+ rawValue
+ }
+
+ var localizedName: String {
+ let calendar = Calendar.current
+ let symbols = calendar.shortWeekdaySymbols
+ let index = (rawValue - calendar.firstWeekday + 7) % 7
+ if symbols.indices.contains(index) {
+ return symbols[index]
+ } else {
+ return String(describing: self).capitalized
+ }
+ }
+
+ static func weekday(for date: Date, calendar: Calendar = .current) -> Weekday {
+ let weekdayValue = calendar.component(.weekday, from: date)
+ return Weekday(rawValue: weekdayValue) ?? .monday
+ }
+}
+
+enum HabitSchedule: Hashable {
+ case daily
+ case weekly(days: Set)
+ case timesPerWeek(Int)
+ case everyXDays(Int)
+ case custom(description: String)
+
+ var frequency: Double {
+ switch self {
+ case .daily:
+ return 1.0
+ case .weekly(let days):
+ return Double(max(days.count, 1)) / 7.0
+ case .timesPerWeek(let count):
+ return Double(max(count, 1)) / 7.0
+ case .everyXDays(let interval):
+ guard interval > 0 else { return 1.0 }
+ return 1.0 / Double(interval)
+ case .custom:
+ return 1.0
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .daily:
+ return NSLocalizedString("Every day", comment: "Daily schedule")
+ case .weekly(let days):
+ let sorted = days.sorted { $0.rawValue < $1.rawValue }
+ let label = sorted.map { $0.localizedName }.joined(separator: ", ")
+ return label.isEmpty ? NSLocalizedString("Weekly", comment: "Weekly schedule fallback") : label
+ case .timesPerWeek(let times):
+ return String(format: NSLocalizedString("%d times per week", comment: "Times per week"), times)
+ case .everyXDays(let interval):
+ if interval == 1 {
+ return NSLocalizedString("Every day", comment: "Interval schedule fallback")
+ }
+ return String(format: NSLocalizedString("Every %d days", comment: "Interval schedule"), interval)
+ case .custom(let description):
+ return description
+ }
+ }
+
+ func isDue(on date: Date, since startDate: Date, calendar: Calendar = .current) -> Bool {
+ switch self {
+ case .daily, .timesPerWeek:
+ return true
+ case .weekly(let days):
+ let weekday = Weekday.weekday(for: date, calendar: calendar)
+ return days.contains(weekday)
+ case .everyXDays(let interval):
+ guard interval > 0 else { return true }
+ let start = calendar.startOfDay(for: startDate)
+ let target = calendar.startOfDay(for: date)
+ guard let days = calendar.dateComponents([.day], from: start, to: target).day else { return false }
+ return days % interval == 0
+ case .custom:
+ return true
+ }
+ }
+
+ func expectedOccurrences(from startDate: Date, to endDate: Date, calendar: Calendar = .current) -> Int {
+ guard startDate <= endDate else { return 0 }
+ let start = calendar.startOfDay(for: startDate)
+ let end = calendar.startOfDay(for: endDate)
+ var cursor = start
+ var occurrences = 0
+ while cursor <= end {
+ if isDue(on: cursor, since: startDate, calendar: calendar) {
+ occurrences += 1
+ }
+ guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
+ cursor = next
+ }
+ return occurrences
+ }
+}
+
+extension HabitSchedule: Codable {
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case payload
+ }
+
+ private enum ScheduleType: String, Codable {
+ case daily
+ case weekly
+ case timesPerWeek
+ case everyXDays
+ case custom
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .daily:
+ try container.encode(ScheduleType.daily, forKey: .type)
+ case .weekly(let days):
+ try container.encode(ScheduleType.weekly, forKey: .type)
+ try container.encode(Array(days), forKey: .payload)
+ case .timesPerWeek(let value):
+ try container.encode(ScheduleType.timesPerWeek, forKey: .type)
+ try container.encode(value, forKey: .payload)
+ case .everyXDays(let value):
+ try container.encode(ScheduleType.everyXDays, forKey: .type)
+ try container.encode(value, forKey: .payload)
+ case .custom(let description):
+ try container.encode(ScheduleType.custom, forKey: .type)
+ try container.encode(description, forKey: .payload)
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(ScheduleType.self, forKey: .type)
+ switch type {
+ case .daily:
+ self = .daily
+ case .weekly:
+ let days = try container.decode([Weekday].self, forKey: .payload)
+ self = .weekly(days: Set(days))
+ case .timesPerWeek:
+ let value = try container.decode(Int.self, forKey: .payload)
+ self = .timesPerWeek(value)
+ case .everyXDays:
+ let value = try container.decode(Int.self, forKey: .payload)
+ self = .everyXDays(value)
+ case .custom:
+ let description = try container.decode(String.self, forKey: .payload)
+ self = .custom(description: description)
+ }
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitScoreCalculator.swift b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitScoreCalculator.swift
new file mode 100644
index 000000000..e61775ab9
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitScoreCalculator.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+enum HabitScoreCalculator {
+ static func compute(frequency: Double, previousScore: Double, checkmarkValue: Double) -> Double {
+ guard frequency.isFinite, frequency > 0 else {
+ return previousScore
+ }
+ let clamped = max(0.0, min(1.0, checkmarkValue))
+ let multiplier = pow(0.5, sqrt(frequency) / 13.0)
+ let score = previousScore * multiplier + clamped * (1.0 - multiplier)
+ return max(0.0, min(1.0, score))
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitStatistics.swift b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitStatistics.swift
new file mode 100644
index 000000000..6566a239e
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitStatistics.swift
@@ -0,0 +1,120 @@
+import Foundation
+
+struct ScoreSample: Identifiable, Hashable, Codable {
+ var id: Date { date }
+ var date: Date
+ var value: Double
+
+ init(date: Date, value: Double) {
+ self.date = date
+ self.value = value
+ }
+}
+
+struct HabitStatistics: Hashable {
+ let habit: Habit
+ let timeline: [ScoreSample]
+ let completions: [Date: Double]
+ let asOf: Date
+ var calendar: Calendar = Calendar.current
+
+ var currentScore: Double {
+ timeline.last?.value ?? 0.0
+ }
+
+ var todaysCompletionValue: Double {
+ let today = calendar.startOfDay(for: asOf)
+ return completions[today] ?? 0.0
+ }
+
+ var completionCount: Int {
+ completions.values.filter { $0 > 0 }.count
+ }
+
+ var expectedCompletionCount: Int {
+ habit.schedule.expectedOccurrences(from: habit.createdDate, to: asOf, calendar: calendar)
+ }
+
+ var completionRate: Double {
+ guard expectedCompletionCount > 0 else { return 0 }
+ return Double(completionCount) / Double(expectedCompletionCount)
+ }
+
+ var currentStreak: Int {
+ computeCurrentStreak()
+ }
+
+ var bestStreak: Int {
+ computeBestStreak()
+ }
+
+ func scoreChange(days: Int) -> Double {
+ guard days > 0 else { return 0 }
+ let target = calendar.date(byAdding: .day, value: -days, to: calendar.startOfDay(for: asOf)) ?? asOf
+ guard let sample = sample(on: target) else { return 0 }
+ return currentScore - sample.value
+ }
+
+ func sample(on date: Date) -> ScoreSample? {
+ let normalized = calendar.startOfDay(for: date)
+ return timeline.first { calendar.isDate($0.date, inSameDayAs: normalized) }
+ }
+
+ func averageScore(last days: Int) -> Double {
+ guard days > 0 else { return currentScore }
+ let lowerBound = calendar.date(byAdding: .day, value: -days + 1, to: calendar.startOfDay(for: asOf)) ?? asOf
+ let filtered = timeline.filter { sample in
+ sample.date >= lowerBound && sample.date <= asOf
+ }
+ guard !filtered.isEmpty else { return currentScore }
+ let total = filtered.reduce(0.0) { $0 + $1.value }
+ return total / Double(filtered.count)
+ }
+
+ private func computeCurrentStreak() -> Int {
+ var streak = 0
+ var cursor = calendar.startOfDay(for: asOf)
+ let lowerBound = calendar.startOfDay(for: habit.createdDate)
+
+ while cursor >= lowerBound {
+ if !habit.schedule.isDue(on: cursor, since: habit.createdDate, calendar: calendar) {
+ guard let previous = calendar.date(byAdding: .day, value: -1, to: cursor) else { break }
+ cursor = previous
+ continue
+ }
+
+ let value = completions[cursor] ?? 0
+ if value > 0 {
+ streak += 1
+ } else {
+ break
+ }
+
+ guard let previous = calendar.date(byAdding: .day, value: -1, to: cursor) else { break }
+ cursor = previous
+ }
+ return streak
+ }
+
+ private func computeBestStreak() -> Int {
+ var best = 0
+ var current = 0
+ var cursor = calendar.startOfDay(for: habit.createdDate)
+ let end = calendar.startOfDay(for: asOf)
+
+ while cursor <= end {
+ if habit.schedule.isDue(on: cursor, since: habit.createdDate, calendar: calendar) {
+ if (completions[cursor] ?? 0) > 0 {
+ current += 1
+ best = max(best, current)
+ } else {
+ current = 0
+ }
+ }
+ guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
+ cursor = next
+ }
+
+ return best
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitStore.swift b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitStore.swift
new file mode 100644
index 000000000..0d28d64af
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Models/HabitStore.swift
@@ -0,0 +1,95 @@
+import Foundation
+import Combine
+
+@MainActor
+final class HabitStore: ObservableObject {
+ @Published private(set) var habits: [Habit]
+ @Published var lastError: HabitPersistence.PersistenceError?
+
+ private let persistence: HabitPersistence
+ private var cancellables: Set = []
+
+ init(habits: [Habit] = [], persistence: HabitPersistence = HabitPersistence()) {
+ self.habits = habits
+ self.persistence = persistence
+
+ $habits
+ .dropFirst()
+ .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
+ .sink { [weak self] habits in
+ Task { await self?.persist(habits: habits) }
+ }
+ .store(in: &cancellables)
+ }
+
+ var activeHabits: [Habit] {
+ habits.filter { !$0.archived }
+ }
+
+ var archivedHabits: [Habit] {
+ habits.filter { $0.archived }
+ }
+
+ func load() async {
+ do {
+ let loaded = try await persistence.load()
+ habits = loaded
+ } catch let error as HabitPersistence.PersistenceError {
+ lastError = error
+ } catch {
+ lastError = .failedToLoad(error.localizedDescription)
+ }
+ }
+
+ func persist(habits: [Habit]) async {
+ do {
+ try await persistence.save(habits)
+ } catch let error as HabitPersistence.PersistenceError {
+ lastError = error
+ } catch {
+ lastError = .failedToSave(error.localizedDescription)
+ }
+ }
+
+ func addHabit(_ habit: Habit) {
+ habits.append(habit)
+ }
+
+ func updateHabit(_ habit: Habit) {
+ guard let index = habits.firstIndex(where: { $0.id == habit.id }) else { return }
+ habits[index] = habit
+ }
+
+ func removeHabits(at offsets: IndexSet, inArchivedSection: Bool = false) {
+ var filtered = inArchivedSection ? archivedHabits : activeHabits
+ offsets.sorted(by: >).forEach { index in
+ guard filtered.indices.contains(index) else { return }
+ let habit = filtered[index]
+ if let originalIndex = habits.firstIndex(where: { $0.id == habit.id }) {
+ habits.remove(at: originalIndex)
+ }
+ }
+ }
+
+ func toggleCompletion(for habitID: UUID, on date: Date, calendar: Calendar = .current) {
+ guard let index = habits.firstIndex(where: { $0.id == habitID }) else { return }
+ habits[index].toggleCompletion(on: date, calendar: calendar)
+ }
+
+ func setArchived(_ isArchived: Bool, habitID: UUID) {
+ guard let index = habits.firstIndex(where: { $0.id == habitID }) else { return }
+ habits[index].archived = isArchived
+ }
+
+ func reorder(fromOffsets: IndexSet, toOffset: Int, archived: Bool) {
+ var filteredIDs: [UUID] = (archived ? archivedHabits : activeHabits).map { $0.id }
+ filteredIDs.move(fromOffsets: fromOffsets, toOffset: toOffset)
+
+ let newOrder = filteredIDs + (archived ? activeHabits : archivedHabits).map { $0.id }
+ habits.sort { lhs, rhs in
+ guard let leftIndex = newOrder.firstIndex(of: lhs.id) else { return false }
+ guard let rightIndex = newOrder.firstIndex(of: rhs.id) else { return true }
+ return leftIndex < rightIndex
+ }
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LoopHabitTracker/LoopHabitTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..55684bf56
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60",
+ "filename" : "AppIcon-60@2x.png"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60",
+ "filename" : "AppIcon-60@3x.png"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Resources/Assets.xcassets/Contents.json b/ios/LoopHabitTracker/LoopHabitTracker/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Resources/Preview/.gitkeep b/ios/LoopHabitTracker/LoopHabitTracker/Resources/Preview/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Supporting/Info.plist b/ios/LoopHabitTracker/LoopHabitTracker/Supporting/Info.plist
new file mode 100644
index 000000000..241bf870b
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Supporting/Info.plist
@@ -0,0 +1,39 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Loop Habit Tracker
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UILaunchScreen
+
+
+
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Utilities/ColorExtensions.swift b/ios/LoopHabitTracker/LoopHabitTracker/Utilities/ColorExtensions.swift
new file mode 100644
index 000000000..44056cc9b
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Utilities/ColorExtensions.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+#if canImport(UIKit)
+import UIKit
+#endif
+
+extension Color {
+ init(hex: String) {
+ let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ var int = UInt64()
+ Scanner(string: cleaned).scanHexInt64(&int)
+ let a, r, g, b: UInt64
+ switch cleaned.count {
+ case 3: // RGB (12-bit)
+ (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
+ case 6: // RGB (24-bit)
+ (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
+ case 8: // ARGB (32-bit)
+ (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
+ default:
+ (a, r, g, b) = (255, 0, 0, 0)
+ }
+ self.init(
+ .sRGB,
+ red: Double(r) / 255,
+ green: Double(g) / 255,
+ blue: Double(b) / 255,
+ opacity: Double(a) / 255
+ )
+ }
+
+ var hexString: String {
+#if canImport(UIKit)
+ let components = UIColor(self).cgColor.components ?? [0, 0, 0, 1]
+ let r = Int((components[safe: 0] ?? 0) * 255)
+ let g = Int((components[safe: 1] ?? 0) * 255)
+ let b = Int((components[safe: 2] ?? 0) * 255)
+ return String(format: "%02X%02X%02X", r, g, b)
+#else
+ return "000000"
+#endif
+ }
+}
+
+#if canImport(UIKit)
+private extension Array where Element == CGFloat {
+ subscript(safe index: Int) -> CGFloat? {
+ guard indices.contains(index) else { return nil }
+ return self[index]
+ }
+}
+#endif
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Utilities/DateExtensions.swift b/ios/LoopHabitTracker/LoopHabitTracker/Utilities/DateExtensions.swift
new file mode 100644
index 000000000..fec323f76
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Utilities/DateExtensions.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+extension Date {
+ func startOfDay(calendar: Calendar = .current) -> Date {
+ calendar.startOfDay(for: self)
+ }
+
+ func days(to other: Date, calendar: Calendar = .current) -> Int {
+ let start = calendar.startOfDay(for: self)
+ let end = calendar.startOfDay(for: other)
+ return calendar.dateComponents([.day], from: start, to: end).day ?? 0
+ }
+
+ func addingDays(_ days: Int, calendar: Calendar = .current) -> Date {
+ calendar.date(byAdding: .day, value: days, to: self) ?? self
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTracker/Utilities/PreviewData.swift b/ios/LoopHabitTracker/LoopHabitTracker/Utilities/PreviewData.swift
new file mode 100644
index 000000000..ef9dc3343
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTracker/Utilities/PreviewData.swift
@@ -0,0 +1,56 @@
+import Foundation
+
+enum PreviewData {
+ static var bootstrapHabits: [Habit] {
+ let calendar = Calendar.current
+ var morningRoutine = Habit(
+ name: "Morning run",
+ question: "Did you complete your morning run?",
+ notes: "Run at least 3km around the park.",
+ color: HabitColor(name: "Sky", hex: "4DA1FF"),
+ schedule: .timesPerWeek(4),
+ createdDate: calendar.date(byAdding: .day, value: -120, to: Date()) ?? Date()
+ )
+
+ var meditation = Habit(
+ name: "Meditation",
+ question: "Did you meditate today?",
+ notes: "10 minutes mindful breathing.",
+ color: HabitColor(name: "Orchid", hex: "B574FF"),
+ schedule: .daily,
+ createdDate: calendar.date(byAdding: .day, value: -60, to: Date()) ?? Date()
+ )
+
+ var water = Habit(
+ name: "Drink Water",
+ question: "Did you drink 2L of water?",
+ notes: "Track daily hydration.",
+ color: HabitColor(name: "Lime", hex: "74C365"),
+ schedule: .everyXDays(1),
+ createdDate: calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date()
+ )
+
+ // Populate sample events
+ for offset in stride(from: 0, through: 60, by: 1) {
+ if offset % 2 == 0 {
+ if let date = calendar.date(byAdding: .day, value: -offset, to: Date()) {
+ morningRoutine.events.append(HabitEvent(date: date, value: 1.0))
+ }
+ }
+ }
+
+ for offset in stride(from: 0, through: 45, by: 1) {
+ if offset % 3 != 0, let date = calendar.date(byAdding: .day, value: -offset, to: Date()) {
+ meditation.events.append(HabitEvent(date: date, value: 1.0))
+ }
+ }
+
+ for offset in stride(from: 0, through: 25, by: 1) {
+ if offset % 2 == 0, let date = calendar.date(byAdding: .day, value: -offset, to: Date()) {
+ water.events.append(HabitEvent(date: date, value: 1.0))
+ }
+ }
+
+ return [morningRoutine, meditation, water]
+ }
+}
diff --git a/ios/LoopHabitTracker/LoopHabitTrackerTests/HabitScoreCalculatorTests.swift b/ios/LoopHabitTracker/LoopHabitTrackerTests/HabitScoreCalculatorTests.swift
new file mode 100644
index 000000000..ffbb8a18a
--- /dev/null
+++ b/ios/LoopHabitTracker/LoopHabitTrackerTests/HabitScoreCalculatorTests.swift
@@ -0,0 +1,20 @@
+import XCTest
+@testable import LoopHabitTracker
+
+final class HabitScoreCalculatorTests: XCTestCase {
+ func testScoreDecay() {
+ let frequency = 1.0
+ var score = 1.0
+ score = HabitScoreCalculator.compute(frequency: frequency, previousScore: score, checkmarkValue: 0)
+ let dayWithoutCompletion = HabitScoreCalculator.compute(frequency: frequency, previousScore: score, checkmarkValue: 0)
+ XCTAssertLessThan(dayWithoutCompletion, score)
+ }
+
+ func testScoreIncrease() {
+ var score = 0.0
+ for _ in 0..<7 {
+ score = HabitScoreCalculator.compute(frequency: 1.0, previousScore: score, checkmarkValue: 1)
+ }
+ XCTAssertGreaterThan(score, 0.9)
+ }
+}
diff --git a/ios/LoopHabitTracker/project.yml b/ios/LoopHabitTracker/project.yml
new file mode 100644
index 000000000..902d797e1
--- /dev/null
+++ b/ios/LoopHabitTracker/project.yml
@@ -0,0 +1,35 @@
+name: LoopHabitTracker
+options:
+ bundleIdPrefix: org.isoron
+ deploymentTarget:
+ iOS: 17.0
+configs:
+ Debug: debug
+ Release: release
+settings:
+ SWIFT_VERSION: 6.0
+ IPHONEOS_DEPLOYMENT_TARGET: 17.0
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
+ OTHER_SWIFT_FLAGS: "-enable-bare-slash-regex"
+targets:
+ LoopHabitTracker:
+ type: application
+ platform: iOS
+ sources:
+ - path: LoopHabitTracker
+ resources:
+ - path: LoopHabitTracker/Resources
+ settings:
+ PRODUCT_BUNDLE_IDENTIFIER: org.isoron.loophabitstracker
+ INFOPLIST_FILE: LoopHabitTracker/Supporting/Info.plist
+ CURRENT_PROJECT_VERSION: 1
+ MARKETING_VERSION: 1.0
+ DEVELOPMENT_ASSET_PATHS: LoopHabitTracker/Resources/Preview
+dependencies: []
+ LoopHabitTrackerTests:
+ type: bundle.unit-test
+ platform: iOS
+ sources:
+ - path: LoopHabitTrackerTests
+ dependencies:
+ - target: LoopHabitTracker