Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<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://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>
Expand Down
33 changes: 33 additions & 0 deletions docs/IOS.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading