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

Get it on Google Play Get it on F-Droid 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