diff --git a/Andromida/App/Models/ArcHabit.swift b/Andromida/App/Models/ArcHabit.swift index 6ae4e0c..37b3c39 100644 --- a/Andromida/App/Models/ArcHabit.swift +++ b/Andromida/App/Models/ArcHabit.swift @@ -12,6 +12,9 @@ final class ArcHabit { var goal: String = "" var completedDayIDs: [String] = [] + /// Persisted sort order for deterministic ordering across CloudKit sync. + var sortIndex: Int = 0 + @Relationship(inverse: \RitualArc.habits) var arc: RitualArc? @@ -20,12 +23,14 @@ final class ArcHabit { title: String, symbolName: String, goal: String = "", + sortIndex: Int = 0, completedDayIDs: [String] = [] ) { self.id = id self.title = title self.symbolName = symbolName self.goal = goal + self.sortIndex = sortIndex self.completedDayIDs = completedDayIDs } @@ -35,6 +40,7 @@ final class ArcHabit { title: title, symbolName: symbolName, goal: goal, + sortIndex: sortIndex, completedDayIDs: [] ) } diff --git a/Andromida/App/Models/Ritual.swift b/Andromida/App/Models/Ritual.swift index 9e2309e..b9aef8e 100644 --- a/Andromida/App/Models/Ritual.swift +++ b/Andromida/App/Models/Ritual.swift @@ -88,6 +88,10 @@ final class Ritual { var iconName: String = "sparkles" var category: String = "" + /// Persisted sort order for deterministic ordering across CloudKit sync. + /// Used as secondary sort key after timeOfDay. + var sortIndex: Int = 0 + // Arcs - each arc represents a time-bound period with its own habits @Relationship(deleteRule: .cascade) var arcs: [RitualArc]? = [] @@ -101,6 +105,7 @@ final class Ritual { timeOfDay: TimeOfDay = .anytime, iconName: String = "sparkles", category: String = "", + sortIndex: Int = 0, arcs: [RitualArc] = [] ) { self.id = id @@ -111,6 +116,7 @@ final class Ritual { self.timeOfDay = timeOfDay self.iconName = iconName self.category = category + self.sortIndex = sortIndex self.arcs = arcs } @@ -150,9 +156,9 @@ final class Ritual { // MARK: - Convenience Accessors (for current arc) - /// Habits from the current arc (empty if no active arc). + /// Habits from the current arc sorted by sortIndex (empty if no active arc). var habits: [ArcHabit] { - currentArc?.habits ?? [] + (currentArc?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex } } /// Start date of the current arc. diff --git a/Andromida/App/Services/RitualSeedService.swift b/Andromida/App/Services/RitualSeedService.swift index 08106df..bef65f1 100644 --- a/Andromida/App/Services/RitualSeedService.swift +++ b/Andromida/App/Services/RitualSeedService.swift @@ -4,9 +4,9 @@ struct RitualSeedService: RitualSeedProviding { func makeSeedRituals(startDate: Date) -> [Ritual] { // Create morning ritual with arc let morningHabits = [ - ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), - ArcHabit(title: String(localized: "Stretch"), symbolName: "figure.walk"), - ArcHabit(title: String(localized: "Mindful minute"), symbolName: "sparkles") + ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill", sortIndex: 0), + ArcHabit(title: String(localized: "Stretch"), symbolName: "figure.walk", sortIndex: 1), + ArcHabit(title: String(localized: "Mindful minute"), symbolName: "sparkles", sortIndex: 2) ] let morningArc = RitualArc( startDate: startDate, @@ -23,14 +23,15 @@ struct RitualSeedService: RitualSeedProviding { timeOfDay: .morning, iconName: "sunrise.fill", category: String(localized: "Wellness"), + sortIndex: 0, arcs: [morningArc] ) // Create evening ritual with arc let eveningHabits = [ - ArcHabit(title: String(localized: "No screens"), symbolName: "moon.stars.fill"), - ArcHabit(title: String(localized: "Read 10 pages"), symbolName: "book.fill"), - ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") + ArcHabit(title: String(localized: "No screens"), symbolName: "moon.stars.fill", sortIndex: 0), + ArcHabit(title: String(localized: "Read 10 pages"), symbolName: "book.fill", sortIndex: 1), + ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2) ] let eveningStartDate = Calendar.current.date(byAdding: .day, value: -14, to: startDate) ?? startDate let eveningArc = RitualArc( @@ -48,6 +49,7 @@ struct RitualSeedService: RitualSeedProviding { timeOfDay: .evening, iconName: "moon.stars.fill", category: String(localized: "Wellness"), + sortIndex: 1, arcs: [eveningArc] ) diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 79ca354..6d83887 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -609,9 +609,9 @@ final class RitualStore: RitualStoreProviding { func createQuickRitual() { let defaultDuration = 28 let habits = [ - ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), - ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk"), - ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") + ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill", sortIndex: 0), + ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk", sortIndex: 1), + ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2) ] let arc = RitualArc( startDate: Date(), @@ -620,11 +620,13 @@ final class RitualStore: RitualStoreProviding { isActive: true, habits: habits ) + let nextSortIndex = rituals.count let ritual = Ritual( title: String(localized: "Custom Ritual"), theme: String(localized: "Your next chapter"), defaultDurationDays: defaultDuration, notes: String(localized: "A fresh ritual created from your focus today."), + sortIndex: nextSortIndex, arcs: [arc] ) modelContext.insert(ritual) @@ -642,13 +644,19 @@ final class RitualStore: RitualStoreProviding { category: String = "", habits: [ArcHabit] = [] ) { + // Assign sortIndex to habits if not already set + let indexedHabits = habits.enumerated().map { index, habit in + habit.sortIndex = index + return habit + } let arc = RitualArc( startDate: Date(), durationDays: durationDays, arcNumber: 1, isActive: true, - habits: habits + habits: indexedHabits ) + let nextSortIndex = rituals.count let ritual = Ritual( title: title, theme: theme, @@ -657,6 +665,7 @@ final class RitualStore: RitualStoreProviding { timeOfDay: timeOfDay, iconName: iconName, category: category, + sortIndex: nextSortIndex, arcs: [arc] ) modelContext.insert(ritual) @@ -665,8 +674,8 @@ final class RitualStore: RitualStoreProviding { /// Creates a ritual from a preset template func createRitualFromPreset(_ preset: RitualPreset) { - let habits = preset.habits.map { habitPreset in - ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName) + let habits = preset.habits.enumerated().map { index, habitPreset in + ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index) } createRitual( title: preset.title, @@ -684,8 +693,8 @@ final class RitualStore: RitualStoreProviding { /// Used during onboarding to immediately show the created ritual. @discardableResult func createRitual(from preset: RitualPreset) -> Ritual { - let habits = preset.habits.map { habitPreset in - ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName) + let habits = preset.habits.enumerated().map { index, habitPreset in + ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index) } let arc = RitualArc( startDate: Date(), @@ -694,6 +703,7 @@ final class RitualStore: RitualStoreProviding { isActive: true, habits: habits ) + let nextSortIndex = rituals.count let ritual = Ritual( title: preset.title, theme: preset.theme, @@ -702,6 +712,7 @@ final class RitualStore: RitualStoreProviding { timeOfDay: preset.timeOfDay, iconName: preset.iconName, category: preset.category, + sortIndex: nextSortIndex, arcs: [arc] ) modelContext.insert(ritual) @@ -739,10 +750,12 @@ final class RitualStore: RitualStoreProviding { /// Adds a habit to the current arc of a ritual func addHabit(to ritual: Ritual, title: String, symbolName: String) { guard let arc = ritual.currentArc else { return } - let habit = ArcHabit(title: title, symbolName: symbolName) - var habits = arc.habits ?? [] - habits.append(habit) - arc.habits = habits + let habits = arc.habits ?? [] + let nextSortIndex = habits.map(\.sortIndex).max().map { $0 + 1 } ?? 0 + let habit = ArcHabit(title: title, symbolName: symbolName, sortIndex: nextSortIndex) + var updatedHabits = habits + updatedHabits.append(habit) + arc.habits = updatedHabits saveContext() } @@ -785,7 +798,12 @@ final class RitualStore: RitualStoreProviding { private func updateDerivedData() { currentRituals = rituals .filter { $0.hasActiveArc } - .sorted { $0.timeOfDay < $1.timeOfDay } + .sorted { lhs, rhs in + if lhs.timeOfDay != rhs.timeOfDay { + return lhs.timeOfDay < rhs.timeOfDay + } + return lhs.sortIndex < rhs.sortIndex + } pastRituals = rituals .filter { !$0.hasActiveArc } .sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) } diff --git a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift index 324a810..610bc63 100644 --- a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift @@ -442,7 +442,9 @@ struct RitualEditSheet: View { } } else { // Create new ritual - let newHabits = habits.map { ArcHabit(title: $0.title, symbolName: $0.symbolName) } + let newHabits = habits.enumerated().map { index, habit in + ArcHabit(title: habit.title, symbolName: habit.symbolName, sortIndex: index) + } store.createRitual( title: trimmedTitle, theme: trimmedTheme, diff --git a/Andromida/App/Views/Today/Components/TodayHabitRowView.swift b/Andromida/App/Views/Today/Components/TodayHabitRowView.swift index cf4553c..27eab80 100644 --- a/Andromida/App/Views/Today/Components/TodayHabitRowView.swift +++ b/Andromida/App/Views/Today/Components/TodayHabitRowView.swift @@ -5,17 +5,20 @@ struct TodayHabitRowView: View { private let title: String private let symbolName: String private let isCompleted: Bool + private let horizontalPadding: CGFloat private let action: () -> Void init( title: String, symbolName: String, isCompleted: Bool, + horizontalPadding: CGFloat = Design.Spacing.large, action: @escaping () -> Void ) { self.title = title self.symbolName = symbolName self.isCompleted = isCompleted + self.horizontalPadding = horizontalPadding self.action = action } @@ -39,7 +42,7 @@ struct TodayHabitRowView: View { .foregroundStyle(isCompleted ? AppStatus.success : AppBorder.subtle) .accessibilityHidden(true) } - .padding(.horizontal, Design.Spacing.large) + .padding(.horizontal, horizontalPadding) .padding(.vertical, Design.Spacing.medium) .background(AppSurface.card) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) diff --git a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift index 618f442..1c9e930 100644 --- a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift +++ b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift @@ -75,6 +75,7 @@ struct TodayRitualSectionView: View { title: habit.title, symbolName: habit.symbolName, isCompleted: habit.isCompleted, + horizontalPadding: 0, action: habit.action ) }