mirror of https://github.com/iSoron/uhabits.git
parent
2b24759d6f
commit
98b3108816
@ -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…
Reference in new issue