Add SwiftUI-based iOS app implementation

pull/2224/head
Peyman Jahani 6 days ago
parent 2b24759d6f
commit 98b3108816

@ -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 show you how your habits improved over time. It is completely ad-free and open
source. 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.
<p align="center"> <p align="center">
<a href="https://play.google.com/store/apps/details?id=org.isoron.uhabits&utm_source=global_co&utm_medium=prtnr&utm_content=Mar2515&utm_campaign=PartBadge&pcampaignid=MKT-AC-global-none-all-co-pr-py-PartBadges-Oct1515-1"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" height="80px"/></a> <a href="https://play.google.com/store/apps/details?id=org.isoron.uhabits&utm_source=global_co&utm_medium=prtnr&utm_content=Mar2515&utm_campaign=PartBadge&pcampaignid=MKT-AC-global-none-all-co-pr-py-PartBadges-Oct1515-1"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" height="80px"/></a>
<a href="https://f-droid.org/app/org.isoron.uhabits"><img alt="Get it on F-Droid" src="https://f-droid.org/badge/get-it-on.png" height="80px"/></a> <a href="https://f-droid.org/app/org.isoron.uhabits"><img alt="Get it on F-Droid" src="https://f-droid.org/badge/get-it-on.png" height="80px"/></a>

@ -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.

@ -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()
}
}
}
}

@ -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 }
}

@ -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<Weekday>
@State private var customDescription: String
init(schedule: Binding<HabitSchedule>) {
_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()
}

@ -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 }
}

@ -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..<range).compactMap { offset in
calendar.date(byAdding: .day, value: -offset, to: today)
}.reversed()
}
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 4) {
ForEach(dates, id: \.self) { date in
let completed = habit.hasEvent(on: date, calendar: calendar)
let due = habit.schedule.isDue(on: date, since: habit.createdDate, calendar: calendar)
RoundedRectangle(cornerRadius: 6)
.fill(color(for: completed, due: due))
.frame(height: 24)
.overlay(
Text(dayLabel(for: date))
.font(.system(size: 9, weight: .semibold))
.foregroundColor(color(for: completed, due: due).accessibilityTextColor)
)
.accessibilityLabel(accessibilityLabel(for: date, completed: completed, due: due))
}
}
}
private func dayLabel(for date: Date) -> 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()
}

@ -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()
}

@ -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())
}

@ -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))
}

@ -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))
}

@ -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()
}

@ -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()
}

@ -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()
}

@ -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()
}

@ -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()
}

@ -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")
]
}

@ -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 }
}
}

@ -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))
}
}
}
}
}

@ -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<Weekday>)
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)
}
}
}

@ -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))
}
}

@ -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
}
}

@ -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<AnyCancellable> = []
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
}
}
}

@ -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
}
}

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Loop Habit Tracker</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string></string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UILaunchScreen</key>
<string></string>
</dict>
</plist>

@ -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

@ -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
}
}

@ -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]
}
}

@ -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)
}
}

@ -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
Loading…
Cancel
Save