Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
4139f4c530
commit
845abfd24d
@ -10,7 +10,8 @@ struct AndromidaApp: App {
|
|||||||
@State private var settingsStore: SettingsStore
|
@State private var settingsStore: SettingsStore
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let schema = Schema([Ritual.self, Habit.self])
|
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
||||||
|
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
do {
|
do {
|
||||||
|
|||||||
@ -8,6 +8,18 @@
|
|||||||
"comment" : "A description of how a user's usage has changed compared to the previous week. The argument is the percentage by which the usage has increased or decreased.",
|
"comment" : "A description of how a user's usage has changed compared to the previous week. The argument is the percentage by which the usage has increased or decreased.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%@ – %@" : {
|
||||||
|
"comment" : "A sublabel that shows the start and end dates of an arc. The date format used is \"MMM d, yyyy\".",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$@ – %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%@, Day %lld" : {
|
"%@, Day %lld" : {
|
||||||
"comment" : "A view representing a milestone achievement in a ritual journey. The first argument is the title of the milestone. The second argument is the day on which the milestone was achieved.",
|
"comment" : "A view representing a milestone achievement in a ritual journey. The first argument is the title of the milestone. The second argument is the day on which the milestone was achieved.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -24,6 +36,18 @@
|
|||||||
"comment" : "A text view displaying the day number in a history calendar cell. The text is centered and has a small font size.",
|
"comment" : "A text view displaying the day number in a history calendar cell. The text is centered and has a small font size.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%lld arc%@" : {
|
||||||
|
"comment" : "A badge displaying the number of completed arcs for a ritual. The argument is the count of completed arcs.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld arc%2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%lld days" : {
|
"%lld days" : {
|
||||||
"comment" : "A label displaying the duration of a preset in days. The argument is the number of days the preset is active.",
|
"comment" : "A label displaying the duration of a preset in days. The argument is the number of days the preset is active.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -40,6 +64,10 @@
|
|||||||
"comment" : "A tip suggesting that more habits need to be completed to reach the goal. The argument is the number of habits remaining.",
|
"comment" : "A tip suggesting that more habits need to be completed to reach the goal. The argument is the number of habits remaining.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%lld habits tracked" : {
|
||||||
|
"comment" : "A label indicating how many habits have been tracked in a ritual's arc. The argument is the number of habits in the arc.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld more days to beat your record!" : {
|
"%lld more days to beat your record!" : {
|
||||||
"comment" : "A tip suggesting that a user should work towards beating their current streak. The argument is the number of days remaining to beat the current streak.",
|
"comment" : "A tip suggesting that a user should work towards beating their current streak. The argument is the number of days remaining to beat the current streak.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -86,6 +114,10 @@
|
|||||||
"comment" : "A value describing the completion rate of a habit. The value is a percentage.",
|
"comment" : "A value describing the completion rate of a habit. The value is a percentage.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%lld total" : {
|
||||||
|
"comment" : "The subtitle of the \"Arc History\" section in the Ritual Detail view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld-day streak" : {
|
"%lld-day streak" : {
|
||||||
"comment" : "A badge indicating a user's current streak of completed habits. The number in the label is replaced with the actual streak length.",
|
"comment" : "A badge indicating a user's current streak of completed habits. The number in the label is replaced with the actual streak length.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -94,6 +126,18 @@
|
|||||||
"comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.",
|
"comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%lld%% completion over %lld days" : {
|
||||||
|
"comment" : "A string summarizing the completion rate of a ritual arc. The first argument is the completion rate, expressed as a percentage. The second argument is the duration of the arc in days.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld%% completion over %2$lld days"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"+%lld" : {
|
"+%lld" : {
|
||||||
"comment" : "A label that appears in the preset card to indicate that there are more habits than can be shown in the preview. The number inside the plus sign is the count of additional habits.",
|
"comment" : "A label that appears in the preset card to indicate that there are more habits than can be shown in the preview. The number inside the plus sign is the count of additional habits.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -102,14 +146,34 @@
|
|||||||
"comment" : "A textual representation of a percentage change, indicating whether it is positive or negative. The argument is the percentage change, as an integer.",
|
"comment" : "A textual representation of a percentage change, indicating whether it is positive or negative. The argument is the percentage change, as an integer.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"1 week" : {
|
||||||
|
"comment" : "A caption displayed alongside a slider in the \"Next Arc Duration\" section of the `ArcRenewalSheet`.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"1 year" : {
|
||||||
|
"comment" : "A caption displayed alongside the slider in the \"Next Arc Duration\" section of the arc renewal sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"2pm – 5pm" : {
|
||||||
|
"comment" : "Time range description for \"Afternoon\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"5-minute meditation" : {
|
"5-minute meditation" : {
|
||||||
"comment" : "Title of a habit preset within a mindfulness ritual preset.",
|
"comment" : "Title of a habit preset within a mindfulness ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"5pm – 9pm" : {
|
||||||
|
"comment" : "Time range description for the \"Evening\" time of day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"7-Day Trend" : {
|
"7-Day Trend" : {
|
||||||
"comment" : "A heading for the 7-day trend section of an insight detail sheet.",
|
"comment" : "A heading for the 7-day trend section of an insight detail sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"11am – 2pm" : {
|
||||||
|
"comment" : "Time range description for the \"Midday\" time of day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"21 days is when habits start to stick." : {
|
"21 days is when habits start to stick." : {
|
||||||
"comment" : "Tip text indicating that 21 days is when habits start to stick.",
|
"comment" : "Tip text indicating that 21 days is when habits start to stick.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -299,13 +363,28 @@
|
|||||||
"Advanced Insights" : {
|
"Advanced Insights" : {
|
||||||
"comment" : "Subtitle for a feature in the \"Rituals Pro\" upgrade screen, related to \"Advanced Insights\".",
|
"comment" : "Subtitle for a feature in the \"Rituals Pro\" upgrade screen, related to \"Advanced Insights\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"After 9pm" : {
|
||||||
|
"comment" : "Time range description for the \"Night\" time of day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Afternoon" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"All" : {
|
"All" : {
|
||||||
"comment" : "Title for the \"All\" option in the history ritual filter picker.",
|
"comment" : "Title for the \"All\" option in the history ritual filter picker.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"All caught up" : {
|
||||||
|
"comment" : "Title of a section header view in the \"Today no rituals for time\" view, indicating that the user has completed all their rituals for the current time of day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"All habits complete! Great work today." : {
|
"All habits complete! Great work today." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Always visible" : {
|
||||||
|
"comment" : "Combined display name with time range for rituals that can be performed at any time.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Anytime" : {
|
"Anytime" : {
|
||||||
"comment" : "Name of the \"Anytime\" time of day option in the Ritual editor.",
|
"comment" : "Name of the \"Anytime\" time of day option in the Ritual editor.",
|
||||||
@ -319,27 +398,31 @@
|
|||||||
"comment" : "Name of a habit within a self-care ritual preset.",
|
"comment" : "Name of a habit within a self-care ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Arc %lld" : {
|
||||||
|
"comment" : "A badge displaying the current arc number of a ritual. The argument is the arc number.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Arc %lld Complete" : {
|
||||||
|
"comment" : "A label below the celebration header, indicating which arc of a ritual has just completed. The argument is the arc number of the completed arc.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Arc complete!" : {
|
"Arc complete!" : {
|
||||||
"comment" : "A message displayed when a ritual's streak reaches its maximum value.",
|
"comment" : "A message displayed when a ritual's streak reaches its maximum value.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Archive" : {
|
"Arc History" : {
|
||||||
"comment" : "A button that archives a ritual, hiding it from the user's active list but preserving its history.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Archive Ritual?" : {
|
|
||||||
"comment" : "A confirmation prompt title for archiving a ritual.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Archived" : {
|
|
||||||
"comment" : "A label displayed above the user's archived rituals.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Balanced daily check-ins" : {
|
"Balanced daily check-ins" : {
|
||||||
"comment" : "Description of what the \"Steady\" focus style means for the user.",
|
"comment" : "Description of what the \"Steady\" focus style means for the user.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Before 11am" : {
|
||||||
|
"comment" : "Time range description for the \"Morning\" time of day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Begin a four-week arc" : {
|
"Begin a four-week arc" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -459,6 +542,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Choose a theme and keep your focus clear for 28 days." : {
|
"Choose a theme and keep your focus clear for 28 days." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -480,6 +564,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Choose Habit Icon" : {
|
||||||
|
"comment" : "The title of the icon picker sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Choose Icon" : {
|
"Choose Icon" : {
|
||||||
"comment" : "The title of the icon picker sheet.",
|
"comment" : "The title of the icon picker sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -545,6 +633,10 @@
|
|||||||
"comment" : "A label indicating that a feature is coming soon.",
|
"comment" : "A label indicating that a feature is coming soon.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Coming up later:" : {
|
||||||
|
"comment" : "A label for a section of a view that lists rituals scheduled for times after the current time of day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Complete" : {
|
"Complete" : {
|
||||||
"comment" : "Title of the final milestone in a 28-day ritual arc.",
|
"comment" : "Title of the final milestone in a 28-day ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -552,6 +644,9 @@
|
|||||||
"Complete all habits today to start a new streak." : {
|
"Complete all habits today to start a new streak." : {
|
||||||
"comment" : "Tip for the \"Habits today\" insight card, encouraging users to complete all their habits to start a new streak.",
|
"comment" : "Tip for the \"Habits today\" insight card, encouraging users to complete all their habits to start a new streak.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Complete First Active Arc (Test Renewal)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Completed" : {
|
"Completed" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -608,6 +703,14 @@
|
|||||||
"comment" : "Tip to consider focusing on fewer habits for better consistency.",
|
"comment" : "Tip to consider focusing on fewer habits for better consistency.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Continue with Changes" : {
|
||||||
|
"comment" : "A button label that lets users continue a ritual with changes they've made.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Continue with Same Habits" : {
|
||||||
|
"comment" : "A button label that prompts the user to continue the ritual with the same habits they had before it ended.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Create" : {
|
"Create" : {
|
||||||
"comment" : "A button label that says \"Create\".",
|
"comment" : "A button label that says \"Create\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -624,11 +727,16 @@
|
|||||||
"comment" : "Subtitle for a feature row in the \"Rituals Pro\" upgrade view, describing the ability to create as many rituals as needed.",
|
"comment" : "Subtitle for a feature row in the \"Rituals Pro\" upgrade view, describing the ability to create as many rituals as needed.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Create Custom Ritual" : {
|
||||||
|
"comment" : "A button label that triggers the creation of a custom ritual.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Create New" : {
|
"Create New" : {
|
||||||
"comment" : "A button label that suggests creating a new ritual.",
|
"comment" : "A button label that suggests creating a new ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Create ritual" : {
|
"Create ritual" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -676,6 +784,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Current" : {
|
||||||
|
"comment" : "Name of the tab in the \"Rituals\" view that shows only the currently active rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Current streak" : {
|
"Current streak" : {
|
||||||
"comment" : "Label for a breakdown item in the \"Streak\" insight card, indicating the current streak of consecutive days with 100% habit completion.",
|
"comment" : "Label for a breakdown item in the \"Streak\" insight card, indicating the current streak of consecutive days with 100% habit completion.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -702,6 +814,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Custom..." : {
|
||||||
|
"comment" : "A text option in the ritual category picker that allows users to input their own custom category.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Daily reminders" : {
|
"Daily reminders" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -838,14 +954,6 @@
|
|||||||
"comment" : "Habit title for dimming lights one hour before bedtime.",
|
"comment" : "Habit title for dimming lights one hour before bedtime.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Disable" : {
|
|
||||||
"comment" : "A button label that disables a ritual.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Disabled" : {
|
|
||||||
"comment" : "A label indicating that a feature or option is currently disabled.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Disconnect to reconnect" : {
|
"Disconnect to reconnect" : {
|
||||||
"comment" : "Theme for the \"Digital Detox\" ritual preset.",
|
"comment" : "Theme for the \"Digital Detox\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -903,6 +1011,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Drag the handle to reorder habits." : {
|
||||||
|
"comment" : "A footer text displayed below the list of habits in the ritual edit sheet, explaining how to reorder them by dragging.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Drink a glass of water" : {
|
"Drink a glass of water" : {
|
||||||
"comment" : "Title of a habit within a preset ritual.",
|
"comment" : "Title of a habit within a preset ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -953,13 +1065,37 @@
|
|||||||
"comment" : "The title of the navigation bar for editing a ritual.",
|
"comment" : "The title of the navigation bar for editing a ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Enable" : {
|
"End" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"End Arc" : {
|
||||||
|
"comment" : "A button that ends the current ritual arc.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"End Arc?" : {
|
||||||
|
"comment" : "A confirmation alert title that asks the user if they want to end the current arc.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"End This Ritual" : {
|
||||||
|
"comment" : "A button label that allows the user to permanently end a ritual.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"End-of-Day Review" : {
|
"End-of-Day Review" : {
|
||||||
"comment" : "Title of a ritual preset that encourages reviewing completed tasks and planning for the next day.",
|
"comment" : "Title of a ritual preset that encourages reviewing completed tasks and planning for the next day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Ended %@" : {
|
||||||
|
"comment" : "A string representation of a date, formatted to show the date only.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Enjoy this moment. Your next ritual will appear when it's time." : {
|
||||||
|
"comment" : "A motivational message displayed below the \"All caught up\" section in the \"Today\" view when there are no active rituals scheduled for the current time of day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Enter custom category" : {
|
||||||
|
"comment" : "A text field label for entering a custom category name.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Evening" : {
|
"Evening" : {
|
||||||
"comment" : "Description of a ritual scheduling option when the ritual should be visible in the Today view after 5pm.",
|
"comment" : "Description of a ritual scheduling option when the ritual should be visible in the Today view after 5pm.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1032,6 +1168,10 @@
|
|||||||
"comment" : "Title of a habit in a ritual preset focused on practicing gratitude.",
|
"comment" : "Title of a habit in a ritual preset focused on practicing gratitude.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"First check-in" : {
|
||||||
|
"comment" : "Label for the first check-in date in the \"Days Active\" breakdown.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"First Day" : {
|
"First Day" : {
|
||||||
"comment" : "Title of the first milestone in a 28-day ritual arc.",
|
"comment" : "Title of the first milestone in a 28-day ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1457,6 +1597,18 @@
|
|||||||
"comment" : "Habit title for keeping the bedroom cool at night.",
|
"comment" : "Habit title for keeping the bedroom cool at night.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Last arc completed with %lld%% habit completion over %lld days." : {
|
||||||
|
"comment" : "A caption that provides details about the last arc of a ritual, including the number of days it lasted and the percentage of habit completions.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Last arc completed with %1$lld%% habit completion over %2$lld days."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Last synced %@" : {
|
"Last synced %@" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1480,6 +1632,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Later" : {
|
||||||
|
"comment" : "A button that dismisses the renewal prompt and returns to the main screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Let go of the day" : {
|
"Let go of the day" : {
|
||||||
"comment" : "Habit title for a mindfulness ritual where the user lets go of their worries or stresses of the day.",
|
"comment" : "Habit title for a mindfulness ritual where the user lets go of their worries or stresses of the day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1492,6 +1648,10 @@
|
|||||||
"comment" : "Label for the longest streak breakdown item.",
|
"comment" : "Label for the longest streak breakdown item.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Midday" : {
|
||||||
|
"comment" : "Description of a ritual is typically performed during the day.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Midday Movement" : {
|
"Midday Movement" : {
|
||||||
"comment" : "Title of a ritual preset that encourages regular physical activity during the day.",
|
"comment" : "Title of a ritual preset that encourages regular physical activity during the day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1582,6 +1742,10 @@
|
|||||||
"comment" : "Title of a ritual preset focused on skincare.",
|
"comment" : "Title of a ritual preset focused on skincare.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Most recent" : {
|
||||||
|
"comment" : "A description of the most recent check-in date.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Move" : {
|
"Move" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1608,15 +1772,44 @@
|
|||||||
"comment" : "The title of the view when creating a new ritual.",
|
"comment" : "The title of the view when creating a new ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Next Arc Duration" : {
|
||||||
|
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Night" : {
|
||||||
|
"comment" : "Name for the time of day after 9pm.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"No active arc" : {
|
||||||
|
"comment" : "A description shown when a ritual does not have an active arc.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"No Active Rituals" : {
|
||||||
|
"comment" : "A message displayed when a user has no active rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No caffeine after 2pm" : {
|
"No caffeine after 2pm" : {
|
||||||
"comment" : "Habit title for not consuming caffeine after 2 PM.",
|
"comment" : "Habit title for not consuming caffeine after 2 PM.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"No completed arcs yet." : {
|
||||||
|
"comment" : "A message displayed when a ritual has no completed arcs.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No habits tracked" : {
|
"No habits tracked" : {
|
||||||
"comment" : "A message displayed when a user has not tracked any habits on a given day.",
|
"comment" : "A message displayed when a user has not tracked any habits on a given day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"No icons found" : {
|
||||||
|
"comment" : "A message displayed when no icons match the current search term.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"No Past Rituals" : {
|
||||||
|
"comment" : "A description displayed when the user has no past rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No ritual yet" : {
|
"No ritual yet" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1638,8 +1831,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"No Rituals Yet" : {
|
"No rituals scheduled for %@." : {
|
||||||
"comment" : "A message displayed when a user has not created any rituals yet.",
|
"comment" : "A message indicating that there are no rituals scheduled for the current time of day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"No screens" : {
|
"No screens" : {
|
||||||
@ -1732,6 +1925,14 @@
|
|||||||
"comment" : "Title for a milestone that occurs one week into a ritual arc.",
|
"comment" : "Title for a milestone that occurs one week into a ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Or restart a past ritual from the Past tab." : {
|
||||||
|
"comment" : "A footnote displayed below the \"Create\" button in the \"No Active Rituals\" view, encouraging users to explore their past rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Past" : {
|
||||||
|
"comment" : "Display name for the \"Past\" tab in the Rituals view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Perfect day! You crushed it." : {
|
"Perfect day! You crushed it." : {
|
||||||
"comment" : "A motivational message displayed when a user achieves 100% completion on a ritual.",
|
"comment" : "A motivational message displayed when a user achieves 100% completion on a ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1915,8 +2116,8 @@
|
|||||||
"comment" : "Notes for a ritual preset focused on sleep preparation.",
|
"comment" : "Notes for a ritual preset focused on sleep preparation.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Restore" : {
|
"Restart" : {
|
||||||
"comment" : "A button label that restores a deleted or archived ritual.",
|
"comment" : "A button label that says \"Restart\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Review completed tasks" : {
|
"Review completed tasks" : {
|
||||||
@ -1957,6 +2158,10 @@
|
|||||||
"comment" : "Explanation of the insight card titled \"Active\".",
|
"comment" : "Explanation of the insight card titled \"Active\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Ritual Complete" : {
|
||||||
|
"comment" : "The title of the view that appears when a ritual's arc completes.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ritual days" : {
|
"Ritual days" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2050,6 +2255,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Rituals help you build consistent habits through focused, time-bound journeys." : {
|
||||||
|
"comment" : "A description of the purpose of rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Rituals is a four-week habit companion that keeps your focus grounded in small, repeatable arcs." : {
|
"Rituals is a four-week habit companion that keeps your focus grounded in small, repeatable arcs." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -2102,6 +2311,10 @@
|
|||||||
"comment" : "A description of what \"Rituals Pro\" offers.",
|
"comment" : "A description of what \"Rituals Pro\" offers.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Rituals that have ended will appear here. You can restart them anytime." : {
|
||||||
|
"comment" : "A description below the label \"No Past Rituals\" in the \"Rituals\" view, explaining that users can restart their past rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Save" : {
|
"Save" : {
|
||||||
"comment" : "The text for a button that saves data.",
|
"comment" : "The text for a button that saves data.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2110,6 +2323,14 @@
|
|||||||
"comment" : "A label displayed above the ritual's scheduling information.",
|
"comment" : "A label displayed above the ritual's scheduling information.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Search icons" : {
|
||||||
|
"comment" : "A placeholder text for a search bar in an icon picker sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Search icons (e.g., heart, star, book)" : {
|
||||||
|
"comment" : "A prompt for searching through habit icons.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Set an intention for the day" : {
|
"Set an intention for the day" : {
|
||||||
"comment" : "Habit title for a ritual preset focused on setting an intention for the day.",
|
"comment" : "Habit title for a ritual preset focused on setting an intention for the day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2243,6 +2464,22 @@
|
|||||||
"comment" : "Description of a trend direction when there is no significant change.",
|
"comment" : "Description of a trend direction when there is no significant change.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Start" : {
|
||||||
|
"comment" : "A button that starts a new arc for a ritual.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Start building better habits" : {
|
||||||
|
"comment" : "Subtitle for the \"No Active Rituals\" section in the \"Today\" view when there are no active rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Start New Arc" : {
|
||||||
|
"comment" : "A button that starts a new arc for a ritual.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Start New Arc?" : {
|
||||||
|
"comment" : "A confirmation prompt for starting a new arc for a ritual.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Start with stillness" : {
|
"Start with stillness" : {
|
||||||
"comment" : "Theme of the \"Morning Meditation\" ritual preset.",
|
"comment" : "Theme of the \"Morning Meditation\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2252,6 +2489,7 @@
|
|||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Start your first ritual" : {
|
"Start your first ritual" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2535,6 +2773,10 @@
|
|||||||
"comment" : "A hint that appears when a user taps on an element to learn more about it.",
|
"comment" : "A hint that appears when a user taps on an element to learn more about it.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Tap the duration to enter a custom number of days (up to 365)." : {
|
||||||
|
"comment" : "A footer label explaining that the user can enter a custom number of days for the ritual's duration.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Tap to check in" : {
|
"Tap to check in" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -2565,10 +2807,6 @@
|
|||||||
"comment" : "Habit title for a ritual preset focused on gratitude practice.",
|
"comment" : "Habit title for a ritual preset focused on gratitude practice.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"The number of days you've completed at least one habit. Each day you check in counts toward your journey." : {
|
|
||||||
"comment" : "Explanation for the InsightCard titled \"Days Active\".",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals." : {
|
"The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals." : {
|
||||||
"comment" : "Explanation of the value for the Insight Card titled \"Habits today\".",
|
"comment" : "Explanation of the value for the Insight Card titled \"Habits today\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2577,12 +2815,20 @@
|
|||||||
"comment" : "A label for an optional tagline or theme associated with a ritual.",
|
"comment" : "A label for an optional tagline or theme associated with a ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"This counts every unique calendar day where you completed at least one habit. It's calculated by scanning all your habit check-ins across all rituals and counting the distinct days. For example, if you checked in on Monday, skipped Tuesday, then checked in Wednesday and Thursday, your Days Active would be 3." : {
|
||||||
|
"comment" : "Explanation of how to calculate the number of \"Days Active\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"This day has no habit data recorded." : {
|
"This day has no habit data recorded." : {
|
||||||
"comment" : "A description displayed when a day has no habit completion data.",
|
"comment" : "A description displayed when a day has no habit completion data.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"This ritual will be hidden from your active list but its history will be preserved." : {
|
"This ritual is not currently active" : {
|
||||||
"comment" : "An alert message that appears when archiving a ritual. It informs the user that the ritual will remain in their history but will not be visible in the \"Active Rituals\" section.",
|
"comment" : "A message displayed when a user views a past ritual and it is not currently active.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"This will end the current arc. You can start a new arc anytime from the Past tab." : {
|
||||||
|
"comment" : "An alert message that appears when the user confirms ending an arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"This will permanently remove \"%@\" and all its completion history. This cannot be undone." : {
|
"This will permanently remove \"%@\" and all its completion history. This cannot be undone." : {
|
||||||
@ -2593,6 +2839,10 @@
|
|||||||
"comment" : "An alert message that appears when the user attempts to delete a ritual. It explains that deleting the ritual cannot be undone.",
|
"comment" : "An alert message that appears when the user attempts to delete a ritual. It explains that deleting the ritual cannot be undone.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"This will start a new arc for this ritual with the same habits. You can modify habits after starting." : {
|
||||||
|
"comment" : "An alert message displayed when starting a new ritual arc.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Three Weeks" : {
|
"Three Weeks" : {
|
||||||
"comment" : "Title of a milestone that is achieved after three weeks of a ritual journey.",
|
"comment" : "Title of a milestone that is achieved after three weeks of a ritual journey.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2670,6 +2920,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Total check-ins" : {
|
||||||
|
"comment" : "Label for a breakdown item showing the total number of check-ins made by the user.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Total days logged" : {
|
"Total days logged" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2705,10 +2959,18 @@
|
|||||||
"comment" : "Accessibility label for a trend direction indicating an increase.",
|
"comment" : "Accessibility label for a trend direction indicating an increase.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Try a different search term" : {
|
||||||
|
"comment" : "A description text displayed below the \"No icons found\" message in the icon picker sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Try starting with just one habit to build momentum." : {
|
"Try starting with just one habit to build momentum." : {
|
||||||
"comment" : "Tip to start with a single habit to build momentum.",
|
"comment" : "Tip to start with a single habit to build momentum.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Unique days with activity" : {
|
||||||
|
"comment" : "A label for a breakdown item showing the number of unique days with activity.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Unlimited Rituals" : {
|
"Unlimited Rituals" : {
|
||||||
"comment" : "Description of a feature in the \"Pro\" upgrade section, describing that users can create as many rituals as they need.",
|
"comment" : "Description of a feature in the \"Pro\" upgrade section, describing that users can create as many rituals as they need.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2731,6 +2993,10 @@
|
|||||||
},
|
},
|
||||||
"Vibrate when completing habits" : {
|
"Vibrate when completing habits" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"View" : {
|
||||||
|
"comment" : "A label describing the segmented control in the rituals view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Weekly completion chart" : {
|
"Weekly completion chart" : {
|
||||||
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
||||||
@ -2816,11 +3082,16 @@
|
|||||||
"comment" : "Habit title for a ritual preset that encourages the user to write down their thoughts.",
|
"comment" : "Habit title for a ritual preset that encourages the user to write down their thoughts.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"You can also restart a past ritual from the Rituals tab." : {
|
||||||
|
"comment" : "A hint displayed below the \"Browse Presets\" button, encouraging users to explore preset rituals.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"You're at your best streak! Keep it going." : {
|
"You're at your best streak! Keep it going." : {
|
||||||
"comment" : "Tip provided when the user is at their longest streak and it is greater than zero.",
|
"comment" : "Tip provided when the user is at their longest streak and it is greater than zero.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Your active and recent arcs" : {
|
"Your active and recent arcs" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2872,6 +3143,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Your Journey" : {
|
||||||
|
"comment" : "A heading for the summary of a user's ritual progress.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Your journey over time" : {
|
"Your journey over time" : {
|
||||||
"comment" : "Subtitle for the History view, describing what the view is about.",
|
"comment" : "Subtitle for the History view, describing what the view is about.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
41
Andromida/App/Models/ArcHabit.swift
Normal file
41
Andromida/App/Models/ArcHabit.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// A habit within a specific ritual arc. Each arc has its own copy of habits
|
||||||
|
/// with their own completion tracking, allowing habits to change between arcs
|
||||||
|
/// while preserving historical data.
|
||||||
|
@Model
|
||||||
|
final class ArcHabit {
|
||||||
|
var id: UUID
|
||||||
|
var title: String
|
||||||
|
var symbolName: String
|
||||||
|
var goal: String
|
||||||
|
var completedDayIDs: [String]
|
||||||
|
|
||||||
|
@Relationship(inverse: \RitualArc.habits)
|
||||||
|
var arc: RitualArc?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
title: String,
|
||||||
|
symbolName: String,
|
||||||
|
goal: String = "",
|
||||||
|
completedDayIDs: [String] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.symbolName = symbolName
|
||||||
|
self.goal = goal
|
||||||
|
self.completedDayIDs = completedDayIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy of this habit for a new arc (without completions).
|
||||||
|
func copyForNewArc() -> ArcHabit {
|
||||||
|
ArcHabit(
|
||||||
|
title: title,
|
||||||
|
symbolName: symbolName,
|
||||||
|
goal: goal,
|
||||||
|
completedDayIDs: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class Habit {
|
|
||||||
var id: UUID
|
|
||||||
var title: String
|
|
||||||
var symbolName: String
|
|
||||||
var goal: String
|
|
||||||
var createdAt: Date
|
|
||||||
var completedDayIDs: [String]
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: UUID = UUID(),
|
|
||||||
title: String,
|
|
||||||
symbolName: String,
|
|
||||||
goal: String = "",
|
|
||||||
createdAt: Date = Date(),
|
|
||||||
completedDayIDs: [String] = []
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.title = title
|
|
||||||
self.symbolName = symbolName
|
|
||||||
self.goal = goal
|
|
||||||
self.createdAt = createdAt
|
|
||||||
self.completedDayIDs = completedDayIDs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,7 +10,7 @@ import Foundation
|
|||||||
/// Represents a habit's completion status with its ritual context.
|
/// Represents a habit's completion status with its ritual context.
|
||||||
struct HabitCompletion: Identifiable {
|
struct HabitCompletion: Identifiable {
|
||||||
var id: UUID { habit.id }
|
var id: UUID { habit.id }
|
||||||
let habit: Habit
|
let habit: ArcHabit
|
||||||
let ritualTitle: String
|
let ritualTitle: String
|
||||||
let isCompleted: Bool
|
let isCompleted: Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,43 +1,85 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// Represents when a ritual should appear in the Today view.
|
/// Represents when a ritual is typically performed during the day.
|
||||||
enum TimeOfDay: String, Codable, CaseIterable {
|
/// Used for sorting and display purposes.
|
||||||
case morning // Before noon
|
enum TimeOfDay: String, Codable, CaseIterable, Comparable {
|
||||||
case evening // After 5pm
|
case morning // Before 11am
|
||||||
case anytime // Always visible
|
case midday // 11am - 2pm
|
||||||
|
case afternoon // 2pm - 5pm
|
||||||
|
case evening // 5pm - 9pm
|
||||||
|
case night // After 9pm
|
||||||
|
case anytime // Flexible timing
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .morning: return String(localized: "Morning")
|
case .morning: return String(localized: "Morning")
|
||||||
|
case .midday: return String(localized: "Midday")
|
||||||
|
case .afternoon: return String(localized: "Afternoon")
|
||||||
case .evening: return String(localized: "Evening")
|
case .evening: return String(localized: "Evening")
|
||||||
|
case .night: return String(localized: "Night")
|
||||||
case .anytime: return String(localized: "Anytime")
|
case .anytime: return String(localized: "Anytime")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Time range description for this time of day
|
||||||
|
var timeRange: String {
|
||||||
|
switch self {
|
||||||
|
case .morning: return String(localized: "Before 11am")
|
||||||
|
case .midday: return String(localized: "11am – 2pm")
|
||||||
|
case .afternoon: return String(localized: "2pm – 5pm")
|
||||||
|
case .evening: return String(localized: "5pm – 9pm")
|
||||||
|
case .night: return String(localized: "After 9pm")
|
||||||
|
case .anytime: return String(localized: "Always visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined display name with time range
|
||||||
|
var displayNameWithRange: String {
|
||||||
|
"\(displayName) (\(timeRange))"
|
||||||
|
}
|
||||||
|
|
||||||
var symbolName: String {
|
var symbolName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .morning: return "sunrise.fill"
|
case .morning: return "sunrise.fill"
|
||||||
case .evening: return "moon.stars.fill"
|
case .midday: return "sun.max.fill"
|
||||||
|
case .afternoon: return "sun.haze.fill"
|
||||||
|
case .evening: return "sunset.fill"
|
||||||
|
case .night: return "moon.stars.fill"
|
||||||
case .anytime: return "clock.fill"
|
case .anytime: return "clock.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sort order for displaying rituals by time of day
|
||||||
|
var sortOrder: Int {
|
||||||
|
switch self {
|
||||||
|
case .morning: return 0
|
||||||
|
case .midday: return 1
|
||||||
|
case .afternoon: return 2
|
||||||
|
case .evening: return 3
|
||||||
|
case .night: return 4
|
||||||
|
case .anytime: return 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comparable conformance for sorting
|
||||||
|
static func < (lhs: TimeOfDay, rhs: TimeOfDay) -> Bool {
|
||||||
|
lhs.sortOrder < rhs.sortOrder
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A ritual represents a persistent habit-building journey. It contains multiple
|
||||||
|
/// arcs, each representing a time-bound period with its own habits and completions.
|
||||||
|
/// This allows rituals to be renewed while preserving historical accuracy.
|
||||||
@Model
|
@Model
|
||||||
final class Ritual {
|
final class Ritual {
|
||||||
var id: UUID
|
var id: UUID
|
||||||
var title: String
|
var title: String
|
||||||
var theme: String
|
var theme: String
|
||||||
var startDate: Date
|
|
||||||
var durationDays: Int
|
|
||||||
@Relationship(deleteRule: .cascade)
|
|
||||||
var habits: [Habit]
|
|
||||||
var notes: String
|
var notes: String
|
||||||
|
|
||||||
// Management
|
// Default duration for new arcs
|
||||||
var isEnabled: Bool
|
var defaultDurationDays: Int
|
||||||
var isArchived: Bool
|
|
||||||
|
|
||||||
// Scheduling
|
// Scheduling
|
||||||
var timeOfDay: TimeOfDay
|
var timeOfDay: TimeOfDay
|
||||||
@ -45,32 +87,101 @@ final class Ritual {
|
|||||||
// Organization
|
// Organization
|
||||||
var iconName: String
|
var iconName: String
|
||||||
var category: String
|
var category: String
|
||||||
|
|
||||||
|
// Arcs - each arc represents a time-bound period with its own habits
|
||||||
|
@Relationship(deleteRule: .cascade)
|
||||||
|
var arcs: [RitualArc]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
title: String,
|
title: String,
|
||||||
theme: String,
|
theme: String,
|
||||||
startDate: Date = Date(),
|
defaultDurationDays: Int = 28,
|
||||||
durationDays: Int = 28,
|
|
||||||
habits: [Habit] = [],
|
|
||||||
notes: String = "",
|
notes: String = "",
|
||||||
isEnabled: Bool = true,
|
|
||||||
isArchived: Bool = false,
|
|
||||||
timeOfDay: TimeOfDay = .anytime,
|
timeOfDay: TimeOfDay = .anytime,
|
||||||
iconName: String = "sparkles",
|
iconName: String = "sparkles",
|
||||||
category: String = ""
|
category: String = "",
|
||||||
|
arcs: [RitualArc] = []
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.theme = theme
|
self.theme = theme
|
||||||
self.startDate = startDate
|
self.defaultDurationDays = defaultDurationDays
|
||||||
self.durationDays = durationDays
|
|
||||||
self.habits = habits
|
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.isEnabled = isEnabled
|
|
||||||
self.isArchived = isArchived
|
|
||||||
self.timeOfDay = timeOfDay
|
self.timeOfDay = timeOfDay
|
||||||
self.iconName = iconName
|
self.iconName = iconName
|
||||||
self.category = category
|
self.category = category
|
||||||
|
self.arcs = arcs
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
/// The currently active arc, if any.
|
||||||
|
var currentArc: RitualArc? {
|
||||||
|
arcs.first { $0.isActive }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this ritual has an active arc in progress.
|
||||||
|
var hasActiveArc: Bool {
|
||||||
|
currentArc != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All arcs sorted by start date (newest first).
|
||||||
|
var sortedArcs: [RitualArc] {
|
||||||
|
arcs.sorted { $0.startDate > $1.startDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent arc (active or completed).
|
||||||
|
var latestArc: RitualArc? {
|
||||||
|
sortedArcs.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total number of completed arcs.
|
||||||
|
var completedArcCount: Int {
|
||||||
|
arcs.filter { !$0.isActive }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end date of the most recently completed arc, if any.
|
||||||
|
var lastCompletedDate: Date? {
|
||||||
|
arcs.filter { !$0.isActive }
|
||||||
|
.sorted { $0.endDate > $1.endDate }
|
||||||
|
.first?.endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Accessors (for current arc)
|
||||||
|
|
||||||
|
/// Habits from the current arc (empty if no active arc).
|
||||||
|
var habits: [ArcHabit] {
|
||||||
|
currentArc?.habits ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start date of the current arc.
|
||||||
|
var startDate: Date {
|
||||||
|
currentArc?.startDate ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration of the current arc in days.
|
||||||
|
var durationDays: Int {
|
||||||
|
currentArc?.durationDays ?? defaultDurationDays
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End date of the current arc.
|
||||||
|
var endDate: Date {
|
||||||
|
currentArc?.endDate ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Arc Queries
|
||||||
|
|
||||||
|
/// Returns the arc that was active on a specific date, if any.
|
||||||
|
func arc(for date: Date) -> RitualArc? {
|
||||||
|
arcs.first { $0.contains(date: date) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all arcs that overlap with a date range.
|
||||||
|
func arcs(in range: ClosedRange<Date>) -> [RitualArc] {
|
||||||
|
arcs.filter { arc in
|
||||||
|
// Arc overlaps if its range intersects with the query range
|
||||||
|
arc.endDate >= range.lowerBound && arc.startDate <= range.upperBound
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
Andromida/App/Models/RitualArc.swift
Normal file
101
Andromida/App/Models/RitualArc.swift
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Represents a time-bound period of a ritual. Each arc has its own habits and
|
||||||
|
/// completion tracking. When a ritual is renewed, a new arc is created while
|
||||||
|
/// the old arc's data remains frozen for historical accuracy.
|
||||||
|
@Model
|
||||||
|
final class RitualArc {
|
||||||
|
var id: UUID
|
||||||
|
var startDate: Date
|
||||||
|
var endDate: Date
|
||||||
|
var arcNumber: Int
|
||||||
|
var isActive: Bool
|
||||||
|
|
||||||
|
@Relationship(deleteRule: .cascade)
|
||||||
|
var habits: [ArcHabit]
|
||||||
|
|
||||||
|
@Relationship(inverse: \Ritual.arcs)
|
||||||
|
var ritual: Ritual?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
startDate: Date = Date(),
|
||||||
|
endDate: Date,
|
||||||
|
arcNumber: Int = 1,
|
||||||
|
isActive: Bool = true,
|
||||||
|
habits: [ArcHabit] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.startDate = startDate
|
||||||
|
self.endDate = endDate
|
||||||
|
self.arcNumber = arcNumber
|
||||||
|
self.isActive = isActive
|
||||||
|
self.habits = habits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience initializer using duration in days.
|
||||||
|
convenience init(
|
||||||
|
startDate: Date = Date(),
|
||||||
|
durationDays: Int,
|
||||||
|
arcNumber: Int = 1,
|
||||||
|
isActive: Bool = true,
|
||||||
|
habits: [ArcHabit] = []
|
||||||
|
) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let start = calendar.startOfDay(for: startDate)
|
||||||
|
let end = calendar.date(byAdding: .day, value: durationDays - 1, to: start) ?? start
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
arcNumber: arcNumber,
|
||||||
|
isActive: isActive,
|
||||||
|
habits: habits
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of days in this arc.
|
||||||
|
var durationDays: Int {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let days = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
||||||
|
return days + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a specific date falls within this arc's date range.
|
||||||
|
func contains(date: Date) -> Bool {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let checkDate = calendar.startOfDay(for: date)
|
||||||
|
let start = calendar.startOfDay(for: startDate)
|
||||||
|
let end = calendar.startOfDay(for: endDate)
|
||||||
|
return checkDate >= start && checkDate <= end
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the day index (1-based) for a given date within this arc.
|
||||||
|
func dayIndex(for date: Date) -> Int {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let checkDate = calendar.startOfDay(for: date)
|
||||||
|
let start = calendar.startOfDay(for: startDate)
|
||||||
|
let days = calendar.dateComponents([.day], from: start, to: checkDate).day ?? 0
|
||||||
|
return max(1, min(days + 1, durationDays))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new arc that continues from this one with the same habits.
|
||||||
|
/// - Parameter durationDays: The duration for the new arc (defaults to same as this arc)
|
||||||
|
/// - Returns: A new arc with copied habits and incremented arc number
|
||||||
|
func createRenewalArc(durationDays: Int? = nil) -> RitualArc {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let newStartDate = calendar.date(byAdding: .day, value: 1, to: endDate) ?? Date()
|
||||||
|
let newDuration = durationDays ?? self.durationDays
|
||||||
|
|
||||||
|
let copiedHabits = habits.map { $0.copyForNewArc() }
|
||||||
|
|
||||||
|
return RitualArc(
|
||||||
|
startDate: newStartDate,
|
||||||
|
durationDays: newDuration,
|
||||||
|
arcNumber: arcNumber + 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: copiedHabits
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -79,7 +79,7 @@ enum RitualPresetLibrary {
|
|||||||
theme: String(localized: "Break up your day"),
|
theme: String(localized: "Break up your day"),
|
||||||
notes: String(localized: "Combat sedentary habits with midday activity."),
|
notes: String(localized: "Combat sedentary habits with midday activity."),
|
||||||
durationDays: 28,
|
durationDays: 28,
|
||||||
timeOfDay: .anytime,
|
timeOfDay: .midday,
|
||||||
iconName: "figure.walk",
|
iconName: "figure.walk",
|
||||||
category: PresetCategory.health.rawValue,
|
category: PresetCategory.health.rawValue,
|
||||||
habits: [
|
habits: [
|
||||||
|
|||||||
@ -2,25 +2,36 @@ import Foundation
|
|||||||
|
|
||||||
protocol RitualStoreProviding {
|
protocol RitualStoreProviding {
|
||||||
var rituals: [Ritual] { get }
|
var rituals: [Ritual] { get }
|
||||||
|
var currentRituals: [Ritual] { get }
|
||||||
|
var pastRituals: [Ritual] { get }
|
||||||
var activeRitual: Ritual? { get }
|
var activeRitual: Ritual? { get }
|
||||||
var todayDisplayString: String { get }
|
var todayDisplayString: String { get }
|
||||||
var activeRitualProgress: Double { get }
|
var activeRitualProgress: Double { get }
|
||||||
func ritualProgress(for ritual: Ritual) -> Double
|
func ritualProgress(for ritual: Ritual) -> Double
|
||||||
func habits(for ritual: Ritual) -> [Habit]
|
func habits(for ritual: Ritual) -> [ArcHabit]
|
||||||
func isHabitCompletedToday(_ habit: Habit) -> Bool
|
func isHabitCompletedToday(_ habit: ArcHabit) -> Bool
|
||||||
func toggleHabitCompletion(_ habit: Habit)
|
func toggleHabitCompletion(_ habit: ArcHabit)
|
||||||
func ritualDayIndex(for ritual: Ritual) -> Int
|
func ritualDayIndex(for ritual: Ritual) -> Int
|
||||||
func ritualDayLabel(for ritual: Ritual) -> String
|
func ritualDayLabel(for ritual: Ritual) -> String
|
||||||
func completionSummary(for ritual: Ritual) -> String
|
func completionSummary(for ritual: Ritual) -> String
|
||||||
func insightCards() -> [InsightCard]
|
func insightCards() -> [InsightCard]
|
||||||
func createQuickRitual()
|
func createQuickRitual()
|
||||||
|
func ritualsForToday() -> [Ritual]
|
||||||
|
func currentRitualsGroupedByTime() -> [(timeOfDay: TimeOfDay, rituals: [Ritual])]
|
||||||
|
|
||||||
|
// Arc Management
|
||||||
|
func arcsActive(on date: Date) -> [RitualArc]
|
||||||
|
func habitsActive(on date: Date) -> [ArcHabit]
|
||||||
|
func renewArc(for ritual: Ritual, durationDays: Int?, copyHabits: Bool)
|
||||||
|
func startNewArc(for ritual: Ritual, durationDays: Int?)
|
||||||
|
func endArc(for ritual: Ritual)
|
||||||
|
|
||||||
// Enhanced Analytics
|
// Enhanced Analytics
|
||||||
func weeklyAverageForDate(_ date: Date) -> Double
|
func weeklyAverageForDate(_ date: Date) -> Double
|
||||||
func streakIncluding(_ date: Date) -> Int?
|
func streakIncluding(_ date: Date) -> Int?
|
||||||
func daysRemaining(for ritual: Ritual) -> Int
|
func daysRemaining(for ritual: Ritual) -> Int
|
||||||
func streakForRitual(_ ritual: Ritual) -> Int
|
func streakForRitual(_ ritual: Ritual) -> Int
|
||||||
func habitCompletionRates(for ritual: Ritual) -> [(habit: Habit, rate: Double)]
|
func habitCompletionRates(for ritual: Ritual) -> [(habit: ArcHabit, rate: Double)]
|
||||||
func milestonesAchieved(for ritual: Ritual) -> [Milestone]
|
func milestonesAchieved(for ritual: Ritual) -> [Milestone]
|
||||||
func weekOverWeekChange() -> Double
|
func weekOverWeekChange() -> Double
|
||||||
func motivationalMessage(for rate: Double) -> String
|
func motivationalMessage(for rate: Double) -> String
|
||||||
|
|||||||
@ -2,39 +2,53 @@ import Foundation
|
|||||||
|
|
||||||
struct RitualSeedService: RitualSeedProviding {
|
struct RitualSeedService: RitualSeedProviding {
|
||||||
func makeSeedRituals(startDate: Date) -> [Ritual] {
|
func makeSeedRituals(startDate: Date) -> [Ritual] {
|
||||||
|
// Create morning ritual with arc
|
||||||
let morningHabits = [
|
let morningHabits = [
|
||||||
Habit(title: String(localized: "Hydrate"), symbolName: "drop.fill"),
|
ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"),
|
||||||
Habit(title: String(localized: "Stretch"), symbolName: "figure.walk"),
|
ArcHabit(title: String(localized: "Stretch"), symbolName: "figure.walk"),
|
||||||
Habit(title: String(localized: "Mindful minute"), symbolName: "sparkles")
|
ArcHabit(title: String(localized: "Mindful minute"), symbolName: "sparkles")
|
||||||
]
|
]
|
||||||
let eveningHabits = [
|
let morningArc = RitualArc(
|
||||||
Habit(title: String(localized: "No screens"), symbolName: "moon.stars.fill"),
|
startDate: startDate,
|
||||||
Habit(title: String(localized: "Read 10 pages"), symbolName: "book.fill"),
|
durationDays: 28,
|
||||||
Habit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard")
|
arcNumber: 1,
|
||||||
]
|
isActive: true,
|
||||||
|
habits: morningHabits
|
||||||
|
)
|
||||||
let morningRitual = Ritual(
|
let morningRitual = Ritual(
|
||||||
title: String(localized: "Morning Clarity"),
|
title: String(localized: "Morning Clarity"),
|
||||||
theme: String(localized: "Fresh starts"),
|
theme: String(localized: "Fresh starts"),
|
||||||
startDate: startDate,
|
defaultDurationDays: 28,
|
||||||
durationDays: 28,
|
|
||||||
habits: morningHabits,
|
|
||||||
notes: String(localized: "A gentle 4-week arc for energy and focus."),
|
notes: String(localized: "A gentle 4-week arc for energy and focus."),
|
||||||
timeOfDay: .morning,
|
timeOfDay: .morning,
|
||||||
iconName: "sunrise.fill",
|
iconName: "sunrise.fill",
|
||||||
category: String(localized: "Wellness")
|
category: String(localized: "Wellness"),
|
||||||
|
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")
|
||||||
|
]
|
||||||
|
let eveningStartDate = Calendar.current.date(byAdding: .day, value: -14, to: startDate) ?? startDate
|
||||||
|
let eveningArc = RitualArc(
|
||||||
|
startDate: eveningStartDate,
|
||||||
|
durationDays: 28,
|
||||||
|
arcNumber: 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: eveningHabits
|
||||||
|
)
|
||||||
let eveningRitual = Ritual(
|
let eveningRitual = Ritual(
|
||||||
title: String(localized: "Evening Reset"),
|
title: String(localized: "Evening Reset"),
|
||||||
theme: String(localized: "Soft landings"),
|
theme: String(localized: "Soft landings"),
|
||||||
startDate: Calendar.current.date(byAdding: .day, value: -14, to: startDate) ?? startDate,
|
defaultDurationDays: 28,
|
||||||
durationDays: 28,
|
|
||||||
habits: eveningHabits,
|
|
||||||
notes: String(localized: "Wind down with quiet, consistent cues."),
|
notes: String(localized: "Wind down with quiet, consistent cues."),
|
||||||
timeOfDay: .evening,
|
timeOfDay: .evening,
|
||||||
iconName: "moon.stars.fill",
|
iconName: "moon.stars.fill",
|
||||||
category: String(localized: "Wellness")
|
category: String(localized: "Wellness"),
|
||||||
|
arcs: [eveningArc]
|
||||||
)
|
)
|
||||||
|
|
||||||
return [morningRitual, eveningRitual]
|
return [morningRitual, eveningRitual]
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import SwiftData
|
|||||||
|
|
||||||
extension RitualStore {
|
extension RitualStore {
|
||||||
static var preview: RitualStore {
|
static var preview: RitualStore {
|
||||||
let schema = Schema([Ritual.self, Habit.self])
|
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
do {
|
do {
|
||||||
|
|||||||
@ -15,6 +15,9 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
private(set) var rituals: [Ritual] = []
|
private(set) var rituals: [Ritual] = []
|
||||||
private(set) var lastErrorMessage: String?
|
private(set) var lastErrorMessage: String?
|
||||||
|
|
||||||
|
/// Ritual that needs renewal prompt (arc just completed)
|
||||||
|
var ritualNeedingRenewal: Ritual?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
modelContext: ModelContext,
|
modelContext: ModelContext,
|
||||||
@ -37,13 +40,11 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var activeRitual: Ritual? {
|
var activeRitual: Ritual? {
|
||||||
let today = calendar.startOfDay(for: Date())
|
// Return the first ritual with an active arc that covers today
|
||||||
let candidates = rituals.filter { ritual in
|
currentRituals.first { ritual in
|
||||||
let start = calendar.startOfDay(for: ritual.startDate)
|
guard let arc = ritual.currentArc else { return false }
|
||||||
let end = calendar.date(byAdding: .day, value: ritual.durationDays - 1, to: start) ?? start
|
return arc.contains(date: Date())
|
||||||
return today >= start && today <= end
|
|
||||||
}
|
}
|
||||||
return candidates.sorted { $0.startDate > $1.startDate }.first
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var todayDisplayString: String {
|
var todayDisplayString: String {
|
||||||
@ -65,16 +66,16 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
return Double(completed) / Double(habits.count)
|
return Double(completed) / Double(habits.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func habits(for ritual: Ritual) -> [Habit] {
|
func habits(for ritual: Ritual) -> [ArcHabit] {
|
||||||
ritual.habits
|
ritual.habits
|
||||||
}
|
}
|
||||||
|
|
||||||
func isHabitCompletedToday(_ habit: Habit) -> Bool {
|
func isHabitCompletedToday(_ habit: ArcHabit) -> Bool {
|
||||||
let dayID = dayIdentifier(for: Date())
|
let dayID = dayIdentifier(for: Date())
|
||||||
return habit.completedDayIDs.contains(dayID)
|
return habit.completedDayIDs.contains(dayID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleHabitCompletion(_ habit: Habit) {
|
func toggleHabitCompletion(_ habit: ArcHabit) {
|
||||||
let dayID = dayIdentifier(for: Date())
|
let dayID = dayIdentifier(for: Date())
|
||||||
let wasCompleted = habit.completedDayIDs.contains(dayID)
|
let wasCompleted = habit.completedDayIDs.contains(dayID)
|
||||||
|
|
||||||
@ -94,10 +95,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ritualDayIndex(for ritual: Ritual) -> Int {
|
func ritualDayIndex(for ritual: Ritual) -> Int {
|
||||||
let start = calendar.startOfDay(for: ritual.startDate)
|
guard let arc = ritual.currentArc else { return 0 }
|
||||||
let today = calendar.startOfDay(for: Date())
|
return arc.dayIndex(for: Date())
|
||||||
let delta = calendar.dateComponents([.day], from: start, to: today).day ?? 0
|
|
||||||
return max(0, min(delta + 1, ritual.durationDays))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ritualDayLabel(for ritual: Ritual) -> String {
|
func ritualDayLabel(for ritual: Ritual) -> String {
|
||||||
@ -119,28 +118,162 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Ritual Management
|
||||||
|
|
||||||
|
/// Rituals with active arcs, sorted by time of day
|
||||||
|
var currentRituals: [Ritual] {
|
||||||
|
rituals
|
||||||
|
.filter { $0.hasActiveArc }
|
||||||
|
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rituals without active arcs (completed or not renewed), sorted by most recently ended
|
||||||
|
var pastRituals: [Ritual] {
|
||||||
|
rituals
|
||||||
|
.filter { !$0.hasActiveArc }
|
||||||
|
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
|
||||||
|
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
||||||
|
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||||
|
func ritualsForToday() -> [Ritual] {
|
||||||
|
let hour = calendar.component(.hour, from: Date())
|
||||||
|
let currentPeriod: TimeOfDay = {
|
||||||
|
switch hour {
|
||||||
|
case 0..<11: return .morning
|
||||||
|
case 11..<14: return .midday
|
||||||
|
case 14..<17: return .afternoon
|
||||||
|
case 17..<21: return .evening
|
||||||
|
default: return .night
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return currentRituals.filter { ritual in
|
||||||
|
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
|
||||||
|
return ritual.timeOfDay == .anytime || ritual.timeOfDay == currentPeriod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Groups current rituals by time of day for display
|
||||||
|
func currentRitualsGroupedByTime() -> [(timeOfDay: TimeOfDay, rituals: [Ritual])] {
|
||||||
|
let grouped = Dictionary(grouping: currentRituals) { $0.timeOfDay }
|
||||||
|
return TimeOfDay.allCases
|
||||||
|
.compactMap { time in
|
||||||
|
guard let rituals = grouped[time], !rituals.isEmpty else { return nil }
|
||||||
|
return (timeOfDay: time, rituals: rituals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Arc Management
|
||||||
|
|
||||||
|
/// Returns all arcs that were active on a specific date.
|
||||||
|
func arcsActive(on date: Date) -> [RitualArc] {
|
||||||
|
rituals.flatMap { $0.arcs }.filter { $0.contains(date: date) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns habits from all arcs that were active on a specific date.
|
||||||
|
func habitsActive(on date: Date) -> [ArcHabit] {
|
||||||
|
arcsActive(on: date).flatMap { $0.habits }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a ritual's current arc has completed (past end date).
|
||||||
|
func isArcCompleted(_ ritual: Ritual) -> Bool {
|
||||||
|
guard let arc = ritual.currentArc else { return false }
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let endDate = calendar.startOfDay(for: arc.endDate)
|
||||||
|
return today > endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks for rituals that need renewal and triggers the prompt.
|
||||||
|
func checkForCompletedArcs() {
|
||||||
|
for ritual in currentRituals {
|
||||||
|
if isArcCompleted(ritual) {
|
||||||
|
ritualNeedingRenewal = ritual
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renews a ritual by creating a new arc, optionally copying habits from the previous arc.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - ritual: The ritual to renew
|
||||||
|
/// - durationDays: Duration for the new arc (defaults to ritual's default)
|
||||||
|
/// - copyHabits: Whether to copy habits from the previous arc
|
||||||
|
func renewArc(for ritual: Ritual, durationDays: Int? = nil, copyHabits: Bool = true) {
|
||||||
|
// Mark current arc as inactive
|
||||||
|
if let currentArc = ritual.currentArc {
|
||||||
|
currentArc.isActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new arc
|
||||||
|
let duration = durationDays ?? ritual.defaultDurationDays
|
||||||
|
let newArcNumber = (ritual.latestArc?.arcNumber ?? 0) + 1
|
||||||
|
|
||||||
|
let newHabits: [ArcHabit]
|
||||||
|
if copyHabits, let previousArc = ritual.latestArc {
|
||||||
|
newHabits = previousArc.habits.map { $0.copyForNewArc() }
|
||||||
|
} else {
|
||||||
|
newHabits = []
|
||||||
|
}
|
||||||
|
|
||||||
|
let newArc = RitualArc(
|
||||||
|
startDate: Date(),
|
||||||
|
durationDays: duration,
|
||||||
|
arcNumber: newArcNumber,
|
||||||
|
isActive: true,
|
||||||
|
habits: newHabits
|
||||||
|
)
|
||||||
|
|
||||||
|
ritual.arcs.append(newArc)
|
||||||
|
saveContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a new arc for a past ritual (one without an active arc).
|
||||||
|
/// - Parameters:
|
||||||
|
/// - ritual: The ritual to start
|
||||||
|
/// - durationDays: Duration for the new arc (defaults to ritual's default)
|
||||||
|
func startNewArc(for ritual: Ritual, durationDays: Int? = nil) {
|
||||||
|
renewArc(for: ritual, durationDays: durationDays, copyHabits: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends a ritual without renewal (marks it as having no active arc).
|
||||||
|
func endArc(for ritual: Ritual) {
|
||||||
|
if let currentArc = ritual.currentArc {
|
||||||
|
currentArc.isActive = false
|
||||||
|
saveContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses the renewal prompt without taking action.
|
||||||
|
func dismissRenewalPrompt() {
|
||||||
|
ritualNeedingRenewal = nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Streak Tracking
|
// MARK: - Streak Tracking
|
||||||
|
|
||||||
/// Returns the set of all day IDs that had 100% completion across all rituals
|
/// Returns the set of all day IDs that had 100% completion across all active arcs for that day
|
||||||
private func perfectDays() -> Set<String> {
|
private func perfectDays() -> Set<String> {
|
||||||
guard !rituals.isEmpty else { return [] }
|
// Get all dates that have any activity
|
||||||
|
let activeDates = datesWithActivity()
|
||||||
|
guard !activeDates.isEmpty else { return [] }
|
||||||
|
|
||||||
// Get all completed day IDs from all habits
|
// For each date, check if all habits in all active arcs were completed
|
||||||
var allDayIDs: Set<String> = []
|
var perfectDayIDs: Set<String> = []
|
||||||
for ritual in rituals {
|
|
||||||
for habit in ritual.habits {
|
for date in activeDates {
|
||||||
allDayIDs.formUnion(habit.completedDayIDs)
|
let dayID = dayIdentifier(for: date)
|
||||||
|
let activeHabits = habitsActive(on: date)
|
||||||
|
|
||||||
|
guard !activeHabits.isEmpty else { continue }
|
||||||
|
|
||||||
|
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||||
|
if allCompleted {
|
||||||
|
perfectDayIDs.insert(dayID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to days where ALL habits were completed
|
return perfectDayIDs
|
||||||
return allDayIDs.filter { dayID in
|
|
||||||
rituals.allSatisfy { ritual in
|
|
||||||
ritual.habits.allSatisfy { habit in
|
|
||||||
habit.completedDayIDs.contains(dayID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||||
@ -208,17 +341,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
for daysAgo in (0..<7).reversed() {
|
for daysAgo in (0..<7).reversed() {
|
||||||
guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { continue }
|
guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { continue }
|
||||||
let dayID = dayIdentifier(for: date)
|
|
||||||
|
|
||||||
// Calculate completion rate for this day
|
let rate = completionRate(for: date)
|
||||||
let allHabits = rituals.flatMap { $0.habits }
|
|
||||||
guard !allHabits.isEmpty else {
|
|
||||||
dataPoints.append(TrendDataPoint(date: date, value: 0, label: shortWeekdayFormatter.string(from: date)))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let completed = allHabits.filter { $0.completedDayIDs.contains(dayID) }.count
|
|
||||||
let rate = Double(completed) / Double(allHabits.count)
|
|
||||||
|
|
||||||
dataPoints.append(TrendDataPoint(
|
dataPoints.append(TrendDataPoint(
|
||||||
date: date,
|
date: date,
|
||||||
@ -237,24 +361,75 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
let sum = trend.reduce(0.0) { $0 + $1.value }
|
let sum = trend.reduce(0.0) { $0 + $1.value }
|
||||||
return Int((sum / Double(trend.count)) * 100)
|
return Int((sum / Double(trend.count)) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a breakdown showing how Days Active is calculated
|
||||||
|
private func daysActiveBreakdown() -> [BreakdownItem] {
|
||||||
|
let activeDates = datesWithActivity()
|
||||||
|
let totalDays = activeDates.count
|
||||||
|
|
||||||
|
// Get first and last active dates
|
||||||
|
let sortedDates = activeDates.sorted()
|
||||||
|
|
||||||
|
var breakdown: [BreakdownItem] = []
|
||||||
|
|
||||||
|
// Total check-ins
|
||||||
|
let totalCheckIns = rituals.flatMap { $0.arcs }.flatMap { $0.habits }.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||||
|
breakdown.append(BreakdownItem(
|
||||||
|
label: String(localized: "Total check-ins"),
|
||||||
|
value: "\(totalCheckIns)"
|
||||||
|
))
|
||||||
|
|
||||||
|
// Unique days
|
||||||
|
breakdown.append(BreakdownItem(
|
||||||
|
label: String(localized: "Unique days with activity"),
|
||||||
|
value: "\(totalDays)"
|
||||||
|
))
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
if let first = sortedDates.first, let last = sortedDates.last {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateStyle = .medium
|
||||||
|
|
||||||
|
breakdown.append(BreakdownItem(
|
||||||
|
label: String(localized: "First check-in"),
|
||||||
|
value: dateFormatter.string(from: first)
|
||||||
|
))
|
||||||
|
|
||||||
|
if first != last {
|
||||||
|
breakdown.append(BreakdownItem(
|
||||||
|
label: String(localized: "Most recent"),
|
||||||
|
value: dateFormatter.string(from: last)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-ritual breakdown
|
||||||
|
for ritual in rituals {
|
||||||
|
let ritualDays = Set(ritual.arcs.flatMap { $0.habits }.flatMap { $0.completedDayIDs }).count
|
||||||
|
breakdown.append(BreakdownItem(
|
||||||
|
label: ritual.title,
|
||||||
|
value: String(localized: "\(ritualDays) days")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return breakdown
|
||||||
|
}
|
||||||
|
|
||||||
func insightCards() -> [InsightCard] {
|
func insightCards() -> [InsightCard] {
|
||||||
let totalHabits = rituals.flatMap { $0.habits }.count
|
// Only count habits from active arcs for today's stats
|
||||||
let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count
|
let activeHabitsToday = habitsActive(on: Date())
|
||||||
let completionRate = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
let totalHabits = activeHabitsToday.count
|
||||||
|
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||||
|
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
||||||
|
|
||||||
// Days active = unique calendar days with at least one check-in
|
// Days active = unique calendar days with at least one check-in
|
||||||
let daysActiveCount = datesWithActivity().count
|
let daysActiveCount = datesWithActivity().count
|
||||||
|
|
||||||
// Build per-ritual progress breakdown
|
// Count rituals with active arcs
|
||||||
let ritualProgressBreakdown = rituals.map { ritual in
|
let activeRitualCount = currentRituals.count
|
||||||
BreakdownItem(
|
|
||||||
label: ritual.title,
|
|
||||||
value: ritualDayLabel(for: ritual)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let habitsBreakdown = rituals.map { ritual in
|
// Build per-ritual progress breakdown
|
||||||
|
let habitsBreakdown = currentRituals.map { ritual in
|
||||||
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
|
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
|
||||||
return BreakdownItem(
|
return BreakdownItem(
|
||||||
label: ritual.title,
|
label: ritual.title,
|
||||||
@ -262,7 +437,6 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Streak tracking
|
// Streak tracking
|
||||||
let current = currentStreak()
|
let current = currentStreak()
|
||||||
let longest = longestStreak()
|
let longest = longestStreak()
|
||||||
@ -273,7 +447,6 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
// Weekly trend
|
// Weekly trend
|
||||||
let trendData = weeklyTrendData()
|
let trendData = weeklyTrendData()
|
||||||
let weeklyAverage = weeklyAverageCompletion()
|
|
||||||
let trendBreakdown = trendData.map { point in
|
let trendBreakdown = trendData.map { point in
|
||||||
BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%")
|
BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%")
|
||||||
}
|
}
|
||||||
@ -281,11 +454,11 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
return [
|
return [
|
||||||
InsightCard(
|
InsightCard(
|
||||||
title: String(localized: "Active"),
|
title: String(localized: "Active"),
|
||||||
value: "\(rituals.count)",
|
value: "\(activeRitualCount)",
|
||||||
caption: String(localized: "In progress now"),
|
caption: String(localized: "In progress now"),
|
||||||
explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
|
explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
|
||||||
symbolName: "sparkles",
|
symbolName: "sparkles",
|
||||||
breakdown: rituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||||
),
|
),
|
||||||
InsightCard(
|
InsightCard(
|
||||||
title: String(localized: "Streak"),
|
title: String(localized: "Streak"),
|
||||||
@ -305,7 +478,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
),
|
),
|
||||||
InsightCard(
|
InsightCard(
|
||||||
title: String(localized: "Completion"),
|
title: String(localized: "Completion"),
|
||||||
value: "\(completionRate)%",
|
value: "\(completionRateValue)%",
|
||||||
caption: String(localized: "Today's progress"),
|
caption: String(localized: "Today's progress"),
|
||||||
explanation: String(localized: "Your completion percentage for today across all rituals. The chart shows your last 7 days—this helps you spot patterns and stay consistent."),
|
explanation: String(localized: "Your completion percentage for today across all rituals. The chart shows your last 7 days—this helps you spot patterns and stay consistent."),
|
||||||
symbolName: "chart.bar.fill",
|
symbolName: "chart.bar.fill",
|
||||||
@ -316,53 +489,37 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
title: String(localized: "Days Active"),
|
title: String(localized: "Days Active"),
|
||||||
value: "\(daysActiveCount)",
|
value: "\(daysActiveCount)",
|
||||||
caption: String(localized: "Days you checked in"),
|
caption: String(localized: "Days you checked in"),
|
||||||
explanation: String(localized: "The number of days you've completed at least one habit. Each day you check in counts toward your journey."),
|
explanation: String(localized: "This counts every unique calendar day where you completed at least one habit. It's calculated by scanning all your habit check-ins across all rituals and counting the distinct days. For example, if you checked in on Monday, skipped Tuesday, then checked in Wednesday and Thursday, your Days Active would be 3."),
|
||||||
symbolName: "calendar",
|
symbolName: "calendar",
|
||||||
breakdown: ritualProgressBreakdown
|
breakdown: daysActiveBreakdown()
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
func createQuickRitual() {
|
func createQuickRitual() {
|
||||||
let habits = [
|
let habits = [
|
||||||
Habit(title: String(localized: "Hydrate"), symbolName: "drop.fill"),
|
ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"),
|
||||||
Habit(title: String(localized: "Move"), symbolName: "figure.walk"),
|
ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk"),
|
||||||
Habit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard")
|
ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard")
|
||||||
]
|
]
|
||||||
|
let arc = RitualArc(
|
||||||
|
startDate: Date(),
|
||||||
|
durationDays: Int(settingsStore.ritualLengthDays),
|
||||||
|
arcNumber: 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: habits
|
||||||
|
)
|
||||||
let ritual = Ritual(
|
let ritual = Ritual(
|
||||||
title: String(localized: "Custom Ritual"),
|
title: String(localized: "Custom Ritual"),
|
||||||
theme: String(localized: "Your next chapter"),
|
theme: String(localized: "Your next chapter"),
|
||||||
startDate: Date(),
|
defaultDurationDays: Int(settingsStore.ritualLengthDays),
|
||||||
durationDays: Int(settingsStore.ritualLengthDays),
|
notes: String(localized: "A fresh ritual created from your focus today."),
|
||||||
habits: habits,
|
arcs: [arc]
|
||||||
notes: String(localized: "A fresh ritual created from your focus today.")
|
|
||||||
)
|
)
|
||||||
modelContext.insert(ritual)
|
modelContext.insert(ritual)
|
||||||
saveContext()
|
saveContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ritual Management
|
|
||||||
|
|
||||||
/// Rituals that are enabled and not archived (visible in Today/Rituals views)
|
|
||||||
var enabledRituals: [Ritual] {
|
|
||||||
rituals.filter { $0.isEnabled && !$0.isArchived }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rituals that have been archived
|
|
||||||
var archivedRituals: [Ritual] {
|
|
||||||
rituals.filter { $0.isArchived }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns rituals appropriate for the current time of day
|
|
||||||
func ritualsForCurrentTime() -> [Ritual] {
|
|
||||||
let hour = calendar.component(.hour, from: Date())
|
|
||||||
let currentPeriod: TimeOfDay = hour < 12 ? .morning : .evening
|
|
||||||
|
|
||||||
return enabledRituals.filter { ritual in
|
|
||||||
ritual.timeOfDay == .anytime || ritual.timeOfDay == currentPeriod
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new ritual with the given properties
|
/// Creates a new ritual with the given properties
|
||||||
func createRitual(
|
func createRitual(
|
||||||
title: String,
|
title: String,
|
||||||
@ -372,18 +529,24 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
timeOfDay: TimeOfDay = .anytime,
|
timeOfDay: TimeOfDay = .anytime,
|
||||||
iconName: String = "sparkles",
|
iconName: String = "sparkles",
|
||||||
category: String = "",
|
category: String = "",
|
||||||
habits: [Habit] = []
|
habits: [ArcHabit] = []
|
||||||
) {
|
) {
|
||||||
|
let arc = RitualArc(
|
||||||
|
startDate: Date(),
|
||||||
|
durationDays: durationDays,
|
||||||
|
arcNumber: 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: habits
|
||||||
|
)
|
||||||
let ritual = Ritual(
|
let ritual = Ritual(
|
||||||
title: title,
|
title: title,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
startDate: Date(),
|
defaultDurationDays: durationDays,
|
||||||
durationDays: durationDays,
|
|
||||||
habits: habits,
|
|
||||||
notes: notes,
|
notes: notes,
|
||||||
timeOfDay: timeOfDay,
|
timeOfDay: timeOfDay,
|
||||||
iconName: iconName,
|
iconName: iconName,
|
||||||
category: category
|
category: category,
|
||||||
|
arcs: [arc]
|
||||||
)
|
)
|
||||||
modelContext.insert(ritual)
|
modelContext.insert(ritual)
|
||||||
saveContext()
|
saveContext()
|
||||||
@ -392,7 +555,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Creates a ritual from a preset template
|
/// Creates a ritual from a preset template
|
||||||
func createRitualFromPreset(_ preset: RitualPreset) {
|
func createRitualFromPreset(_ preset: RitualPreset) {
|
||||||
let habits = preset.habits.map { habitPreset in
|
let habits = preset.habits.map { habitPreset in
|
||||||
Habit(title: habitPreset.title, symbolName: habitPreset.symbolName)
|
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName)
|
||||||
}
|
}
|
||||||
createRitual(
|
createRitual(
|
||||||
title: preset.title,
|
title: preset.title,
|
||||||
@ -420,7 +583,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
ritual.title = title
|
ritual.title = title
|
||||||
ritual.theme = theme
|
ritual.theme = theme
|
||||||
ritual.notes = notes
|
ritual.notes = notes
|
||||||
ritual.durationDays = durationDays
|
ritual.defaultDurationDays = durationDays
|
||||||
ritual.timeOfDay = timeOfDay
|
ritual.timeOfDay = timeOfDay
|
||||||
ritual.iconName = iconName
|
ritual.iconName = iconName
|
||||||
ritual.category = category
|
ritual.category = category
|
||||||
@ -433,45 +596,26 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
saveContext()
|
saveContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggles whether a ritual is enabled (shows in Today view)
|
/// Adds a habit to the current arc of a ritual
|
||||||
func toggleEnabled(_ ritual: Ritual) {
|
|
||||||
ritual.isEnabled.toggle()
|
|
||||||
saveContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Archives a ritual (hides it but preserves history)
|
|
||||||
func archiveRitual(_ ritual: Ritual) {
|
|
||||||
ritual.isArchived = true
|
|
||||||
saveContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unarchives a ritual (restores it to active list)
|
|
||||||
func unarchiveRitual(_ ritual: Ritual) {
|
|
||||||
ritual.isArchived = false
|
|
||||||
saveContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds a habit to an existing ritual
|
|
||||||
func addHabit(to ritual: Ritual, title: String, symbolName: String) {
|
func addHabit(to ritual: Ritual, title: String, symbolName: String) {
|
||||||
let habit = Habit(title: title, symbolName: symbolName)
|
guard let arc = ritual.currentArc else { return }
|
||||||
ritual.habits.append(habit)
|
let habit = ArcHabit(title: title, symbolName: symbolName)
|
||||||
|
arc.habits.append(habit)
|
||||||
saveContext()
|
saveContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a habit from a ritual
|
/// Removes a habit from the current arc of a ritual
|
||||||
func removeHabit(_ habit: Habit, from ritual: Ritual) {
|
func removeHabit(_ habit: ArcHabit, from ritual: Ritual) {
|
||||||
ritual.habits.removeAll { $0.id == habit.id }
|
guard let arc = ritual.currentArc else { return }
|
||||||
|
arc.habits.removeAll { $0.id == habit.id }
|
||||||
modelContext.delete(habit)
|
modelContext.delete(habit)
|
||||||
saveContext()
|
saveContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadRitualsIfNeeded() {
|
private func loadRitualsIfNeeded() {
|
||||||
reloadRituals()
|
reloadRituals()
|
||||||
guard rituals.isEmpty else { return }
|
// No longer auto-seed rituals on fresh install
|
||||||
let seeds = seedService.makeSeedRituals(startDate: Date())
|
// Users start with empty state and create their own rituals
|
||||||
seeds.forEach { modelContext.insert($0) }
|
|
||||||
saveContext()
|
|
||||||
reloadRituals()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadRituals() {
|
private func reloadRituals() {
|
||||||
@ -498,18 +642,21 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
// MARK: - History / Calendar Support
|
// MARK: - History / Calendar Support
|
||||||
|
|
||||||
/// Returns the completion rate for a specific date, optionally filtered by ritual.
|
/// Returns the completion rate for a specific date, optionally filtered by ritual.
|
||||||
/// - Parameters:
|
/// This correctly queries habits from arcs that were active on that date.
|
||||||
/// - date: The date to check
|
|
||||||
/// - ritual: If provided, only check habits from this ritual. If nil, check all habits.
|
|
||||||
/// - Returns: Completion rate from 0.0 to 1.0
|
|
||||||
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
|
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
|
||||||
let dayID = dayIdentifier(for: date)
|
let dayID = dayIdentifier(for: date)
|
||||||
let habits: [Habit]
|
let habits: [ArcHabit]
|
||||||
|
|
||||||
if let ritual = ritual {
|
if let ritual = ritual {
|
||||||
habits = ritual.habits
|
// Get habits from the arc that was active on this date
|
||||||
|
if let arc = ritual.arc(for: date) {
|
||||||
|
habits = arc.habits
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
habits = rituals.flatMap { $0.habits }
|
// Get all habits from all arcs that were active on this date
|
||||||
|
habits = habitsActive(on: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !habits.isEmpty else { return 0 }
|
guard !habits.isEmpty else { return 0 }
|
||||||
@ -523,10 +670,12 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
var dates: Set<Date> = []
|
var dates: Set<Date> = []
|
||||||
|
|
||||||
for ritual in rituals {
|
for ritual in rituals {
|
||||||
for habit in ritual.habits {
|
for arc in ritual.arcs {
|
||||||
for dayID in habit.completedDayIDs {
|
for habit in arc.habits {
|
||||||
if let date = dayFormatter.date(from: dayID) {
|
for dayID in habit.completedDayIDs {
|
||||||
dates.insert(calendar.startOfDay(for: date))
|
if let date = dayFormatter.date(from: dayID) {
|
||||||
|
dates.insert(calendar.startOfDay(for: date))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -541,23 +690,35 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns habit completion details for a specific date.
|
/// Returns habit completion details for a specific date.
|
||||||
/// - Parameters:
|
/// This correctly queries habits from arcs that were active on that date.
|
||||||
/// - date: The date to check
|
|
||||||
/// - ritual: If provided, only return habits from this ritual. If nil, return all habits.
|
|
||||||
/// - Returns: Array of habit completions with ritual context
|
|
||||||
func habitCompletions(for date: Date, ritual: Ritual? = nil) -> [HabitCompletion] {
|
func habitCompletions(for date: Date, ritual: Ritual? = nil) -> [HabitCompletion] {
|
||||||
let dayID = dayIdentifier(for: date)
|
let dayID = dayIdentifier(for: date)
|
||||||
let targetRituals = ritual.map { [$0] } ?? rituals
|
|
||||||
|
|
||||||
var completions: [HabitCompletion] = []
|
var completions: [HabitCompletion] = []
|
||||||
|
|
||||||
for r in targetRituals {
|
if let ritual = ritual {
|
||||||
for habit in r.habits {
|
// Get habits from the arc that was active on this date
|
||||||
completions.append(HabitCompletion(
|
if let arc = ritual.arc(for: date) {
|
||||||
habit: habit,
|
for habit in arc.habits {
|
||||||
ritualTitle: r.title,
|
completions.append(HabitCompletion(
|
||||||
isCompleted: habit.completedDayIDs.contains(dayID)
|
habit: habit,
|
||||||
))
|
ritualTitle: ritual.title,
|
||||||
|
isCompleted: habit.completedDayIDs.contains(dayID)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get all habits from all arcs that were active on this date
|
||||||
|
for r in rituals {
|
||||||
|
if let arc = r.arc(for: date) {
|
||||||
|
for habit in arc.habits {
|
||||||
|
completions.append(HabitCompletion(
|
||||||
|
habit: habit,
|
||||||
|
ritualTitle: r.title,
|
||||||
|
isCompleted: habit.completedDayIDs.contains(dayID)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -565,7 +726,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if a habit was completed on a specific date.
|
/// Checks if a habit was completed on a specific date.
|
||||||
func isHabitCompleted(_ habit: Habit, on date: Date) -> Bool {
|
func isHabitCompleted(_ habit: ArcHabit, on date: Date) -> Bool {
|
||||||
let dayID = dayIdentifier(for: date)
|
let dayID = dayIdentifier(for: date)
|
||||||
return habit.completedDayIDs.contains(dayID)
|
return habit.completedDayIDs.contains(dayID)
|
||||||
}
|
}
|
||||||
@ -573,8 +734,6 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
// MARK: - Enhanced Analytics
|
// MARK: - Enhanced Analytics
|
||||||
|
|
||||||
/// Returns the weekly average completion rate for the week containing the given date.
|
/// Returns the weekly average completion rate for the week containing the given date.
|
||||||
/// - Parameter date: A date within the week to analyze
|
|
||||||
/// - Returns: Average completion rate from 0.0 to 1.0
|
|
||||||
func weeklyAverageForDate(_ date: Date) -> Double {
|
func weeklyAverageForDate(_ date: Date) -> Double {
|
||||||
guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) else {
|
guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) else {
|
||||||
return 0
|
return 0
|
||||||
@ -586,7 +745,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
for dayOffset in 0..<7 {
|
for dayOffset in 0..<7 {
|
||||||
guard let day = calendar.date(byAdding: .day, value: dayOffset, to: weekStart) else { continue }
|
guard let day = calendar.date(byAdding: .day, value: dayOffset, to: weekStart) else { continue }
|
||||||
let rate = completionRate(for: day)
|
let rate = completionRate(for: day)
|
||||||
if rate > 0 || hasAnyHabitsOnDate(day) {
|
if rate > 0 || !habitsActive(on: day).isEmpty {
|
||||||
totalRate += rate
|
totalRate += rate
|
||||||
daysWithData += 1
|
daysWithData += 1
|
||||||
}
|
}
|
||||||
@ -595,17 +754,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
return daysWithData > 0 ? totalRate / Double(daysWithData) : 0
|
return daysWithData > 0 ? totalRate / Double(daysWithData) : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if there were any habits tracked on the given date.
|
|
||||||
private func hasAnyHabitsOnDate(_ date: Date) -> Bool {
|
|
||||||
let dayID = dayIdentifier(for: date)
|
|
||||||
return rituals.flatMap { $0.habits }.contains { habit in
|
|
||||||
habit.completedDayIDs.contains(dayID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the streak length that includes the given date, or nil if the date wasn't a perfect day.
|
/// Returns the streak length that includes the given date, or nil if the date wasn't a perfect day.
|
||||||
/// - Parameter date: The date to check
|
|
||||||
/// - Returns: The streak length if the date was part of a streak, nil otherwise
|
|
||||||
func streakIncluding(_ date: Date) -> Int? {
|
func streakIncluding(_ date: Date) -> Int? {
|
||||||
let dayID = dayIdentifier(for: date)
|
let dayID = dayIdentifier(for: date)
|
||||||
let perfect = perfectDays()
|
let perfect = perfectDays()
|
||||||
@ -631,32 +780,26 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
return streakBefore + 1 + streakAfter
|
return streakBefore + 1 + streakAfter
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the number of days remaining in the ritual arc.
|
/// Returns the number of days remaining in the ritual's current arc.
|
||||||
/// - Parameter ritual: The ritual to check
|
|
||||||
/// - Returns: Days remaining (0 if completed or past end date)
|
|
||||||
func daysRemaining(for ritual: Ritual) -> Int {
|
func daysRemaining(for ritual: Ritual) -> Int {
|
||||||
|
guard let arc = ritual.currentArc else { return 0 }
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: Date())
|
||||||
let start = calendar.startOfDay(for: ritual.startDate)
|
let endDate = calendar.startOfDay(for: arc.endDate)
|
||||||
guard let endDate = calendar.date(byAdding: .day, value: ritual.durationDays - 1, to: start) else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
|
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
|
||||||
return max(0, days)
|
return max(0, days)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current streak for a specific ritual.
|
/// Returns the current streak for a specific ritual's current arc.
|
||||||
/// - Parameter ritual: The ritual to check
|
|
||||||
/// - Returns: Current streak count for this ritual only
|
|
||||||
func streakForRitual(_ ritual: Ritual) -> Int {
|
func streakForRitual(_ ritual: Ritual) -> Int {
|
||||||
guard !ritual.habits.isEmpty else { return 0 }
|
guard let arc = ritual.currentArc, !arc.habits.isEmpty else { return 0 }
|
||||||
|
|
||||||
var streak = 0
|
var streak = 0
|
||||||
var checkDate = calendar.startOfDay(for: Date())
|
var checkDate = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
while true {
|
while arc.contains(date: checkDate) {
|
||||||
let dayID = dayIdentifier(for: checkDate)
|
let dayID = dayIdentifier(for: checkDate)
|
||||||
let allCompleted = ritual.habits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
let allCompleted = arc.habits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||||
|
|
||||||
if allCompleted {
|
if allCompleted {
|
||||||
streak += 1
|
streak += 1
|
||||||
@ -669,10 +812,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
return streak
|
return streak
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns completion rates for each habit in a ritual.
|
/// Returns completion rates for each habit in a ritual's current arc.
|
||||||
/// - Parameter ritual: The ritual to analyze
|
func habitCompletionRates(for ritual: Ritual) -> [(habit: ArcHabit, rate: Double)] {
|
||||||
/// - Returns: Array of tuples with habit and its completion rate
|
|
||||||
func habitCompletionRates(for ritual: Ritual) -> [(habit: Habit, rate: Double)] {
|
|
||||||
let currentDay = ritualDayIndex(for: ritual)
|
let currentDay = ritualDayIndex(for: ritual)
|
||||||
guard currentDay > 0 else { return ritual.habits.map { ($0, 0.0) } }
|
guard currentDay > 0 else { return ritual.habits.map { ($0, 0.0) } }
|
||||||
|
|
||||||
@ -684,15 +825,12 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns milestones for a ritual with achievement status.
|
/// Returns milestones for a ritual with achievement status.
|
||||||
/// - Parameter ritual: The ritual to check
|
|
||||||
/// - Returns: Array of milestones with achievement status
|
|
||||||
func milestonesAchieved(for ritual: Ritual) -> [Milestone] {
|
func milestonesAchieved(for ritual: Ritual) -> [Milestone] {
|
||||||
let currentDay = ritualDayIndex(for: ritual)
|
let currentDay = ritualDayIndex(for: ritual)
|
||||||
return Milestone.standardMilestones(currentDay: currentDay, totalDays: ritual.durationDays)
|
return Milestone.standardMilestones(currentDay: currentDay, totalDays: ritual.durationDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the week-over-week change in completion rate.
|
/// Returns the week-over-week change in completion rate.
|
||||||
/// - Returns: Percentage change (e.g., 0.15 for 15% improvement)
|
|
||||||
func weekOverWeekChange() -> Double {
|
func weekOverWeekChange() -> Double {
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: Date())
|
||||||
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
|
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
|
||||||
@ -707,8 +845,6 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a motivational message based on the completion rate.
|
/// Returns a motivational message based on the completion rate.
|
||||||
/// - Parameter rate: The completion rate (0.0 to 1.0)
|
|
||||||
/// - Returns: A localized motivational message
|
|
||||||
func motivationalMessage(for rate: Double) -> String {
|
func motivationalMessage(for rate: Double) -> String {
|
||||||
switch rate {
|
switch rate {
|
||||||
case 1.0:
|
case 1.0:
|
||||||
@ -728,8 +864,9 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the insight context for tips generation.
|
/// Returns the insight context for tips generation.
|
||||||
func insightContext() -> InsightContext {
|
func insightContext() -> InsightContext {
|
||||||
let totalHabits = rituals.flatMap { $0.habits }.count
|
let activeHabitsToday = habitsActive(on: Date())
|
||||||
let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count
|
let totalHabits = activeHabitsToday.count
|
||||||
|
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||||
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
||||||
|
|
||||||
return InsightContext(
|
return InsightContext(
|
||||||
@ -746,17 +883,40 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Preloads 6 months of random completion data for testing the history view.
|
/// Preloads 6 months of random completion data for testing the history view.
|
||||||
/// Each day has a random chance (60-90%) of completing each habit.
|
|
||||||
func preloadDemoData() {
|
func preloadDemoData() {
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
// Go back 6 months
|
// Go back 6 months
|
||||||
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
|
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
|
||||||
|
|
||||||
// Update each ritual's start date to be 6 months ago
|
// If no rituals exist, nothing to preload
|
||||||
|
guard !rituals.isEmpty else { return }
|
||||||
|
|
||||||
|
// Update each ritual's arcs to cover a longer period
|
||||||
for ritual in rituals {
|
for ritual in rituals {
|
||||||
ritual.startDate = sixMonthsAgo
|
// For each arc (active or not), extend it to cover the demo period
|
||||||
ritual.durationDays = 180 + 28 // Cover 6 months plus buffer
|
for arc in ritual.arcs {
|
||||||
|
// Set the arc to start 6 months ago and be active
|
||||||
|
arc.startDate = sixMonthsAgo
|
||||||
|
arc.endDate = calendar.date(byAdding: .day, value: 180 + 28 - 1, to: sixMonthsAgo) ?? today
|
||||||
|
arc.isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no arcs exist, create one
|
||||||
|
if ritual.arcs.isEmpty {
|
||||||
|
let demoHabits = [
|
||||||
|
ArcHabit(title: "Demo Habit 1", symbolName: "star.fill"),
|
||||||
|
ArcHabit(title: "Demo Habit 2", symbolName: "heart.fill")
|
||||||
|
]
|
||||||
|
let demoArc = RitualArc(
|
||||||
|
startDate: sixMonthsAgo,
|
||||||
|
durationDays: 180 + 28,
|
||||||
|
arcNumber: 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: demoHabits
|
||||||
|
)
|
||||||
|
ritual.arcs.append(demoArc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate completions for each day from 6 months ago to yesterday
|
// Generate completions for each day from 6 months ago to yesterday
|
||||||
@ -766,14 +926,18 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
let dayID = dayIdentifier(for: currentDate)
|
let dayID = dayIdentifier(for: currentDate)
|
||||||
|
|
||||||
for ritual in rituals {
|
for ritual in rituals {
|
||||||
for habit in ritual.habits {
|
for arc in ritual.arcs {
|
||||||
// Random completion with ~70% average success rate
|
// Only generate completions if the arc covers this date
|
||||||
// Vary between 50-90% to create realistic patterns
|
guard arc.contains(date: currentDate) else { continue }
|
||||||
let threshold = Double.random(in: 0.5...0.9)
|
|
||||||
let shouldComplete = Double.random(in: 0...1) < threshold
|
|
||||||
|
|
||||||
if shouldComplete && !habit.completedDayIDs.contains(dayID) {
|
for habit in arc.habits {
|
||||||
habit.completedDayIDs.append(dayID)
|
// Random completion with ~70% average success rate
|
||||||
|
let threshold = Double.random(in: 0.5...0.9)
|
||||||
|
let shouldComplete = Double.random(in: 0...1) < threshold
|
||||||
|
|
||||||
|
if shouldComplete && !habit.completedDayIDs.contains(dayID) {
|
||||||
|
habit.completedDayIDs.append(dayID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -787,11 +951,39 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Clears all completion data (for testing).
|
/// Clears all completion data (for testing).
|
||||||
func clearAllCompletions() {
|
func clearAllCompletions() {
|
||||||
for ritual in rituals {
|
for ritual in rituals {
|
||||||
for habit in ritual.habits {
|
for arc in ritual.arcs {
|
||||||
habit.completedDayIDs.removeAll()
|
for habit in arc.habits {
|
||||||
|
habit.completedDayIDs.removeAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveContext()
|
saveContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simulates arc completion by setting the first active arc's end date to yesterday.
|
||||||
|
/// This triggers the renewal workflow when the user navigates to the Today tab.
|
||||||
|
func simulateArcCompletion() {
|
||||||
|
// Find the first ritual with an active arc
|
||||||
|
guard let ritual = currentRituals.first,
|
||||||
|
let arc = ritual.currentArc else {
|
||||||
|
print("No active arcs to complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the end date to yesterday so the arc appears completed
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())) ?? Date()
|
||||||
|
arc.endDate = yesterday
|
||||||
|
|
||||||
|
// Also backdate the start date so the arc looks like it ran for a reasonable period
|
||||||
|
let arcDuration = arc.durationDays
|
||||||
|
arc.startDate = calendar.date(byAdding: .day, value: -arcDuration, to: yesterday) ?? yesterday
|
||||||
|
|
||||||
|
saveContext()
|
||||||
|
|
||||||
|
// Trigger the completion check - this will set ritualNeedingRenewal
|
||||||
|
checkForCompletedArcs()
|
||||||
|
|
||||||
|
print("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,9 @@ import Bedrock
|
|||||||
|
|
||||||
/// A view showing habit completion performance within a ritual.
|
/// A view showing habit completion performance within a ritual.
|
||||||
struct HabitPerformanceView: View {
|
struct HabitPerformanceView: View {
|
||||||
let habitRates: [(habit: Habit, rate: Double)]
|
let habitRates: [(habit: ArcHabit, rate: Double)]
|
||||||
|
|
||||||
private var sortedByRate: [(habit: Habit, rate: Double)] {
|
private var sortedByRate: [(habit: ArcHabit, rate: Double)] {
|
||||||
habitRates.sorted { $0.rate > $1.rate }
|
habitRates.sorted { $0.rate > $1.rate }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ struct HabitPerformanceView: View {
|
|||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func habitRow(_ habit: Habit, rate: Double) -> some View {
|
private func habitRow(_ habit: ArcHabit, rate: Double) -> some View {
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: habit.symbolName)
|
Image(systemName: habit.symbolName)
|
||||||
@ -83,9 +83,9 @@ struct HabitPerformanceView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
HabitPerformanceView(habitRates: [
|
HabitPerformanceView(habitRates: [
|
||||||
(Habit(title: "Meditate", symbolName: "brain.fill"), 0.85),
|
(ArcHabit(title: "Meditate", symbolName: "brain.fill"), 0.85),
|
||||||
(Habit(title: "Exercise", symbolName: "figure.walk"), 0.65),
|
(ArcHabit(title: "Exercise", symbolName: "figure.walk"), 0.65),
|
||||||
(Habit(title: "Read", symbolName: "book.fill"), 0.40)
|
(ArcHabit(title: "Read", symbolName: "book.fill"), 0.40)
|
||||||
])
|
])
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
|
|||||||
@ -8,7 +8,7 @@ struct RitualCardView: View {
|
|||||||
private let completionSummary: String
|
private let completionSummary: String
|
||||||
private let iconName: String
|
private let iconName: String
|
||||||
private let timeOfDay: TimeOfDay
|
private let timeOfDay: TimeOfDay
|
||||||
private let isEnabled: Bool
|
private let hasActiveArc: Bool
|
||||||
|
|
||||||
init(
|
init(
|
||||||
title: String,
|
title: String,
|
||||||
@ -17,7 +17,7 @@ struct RitualCardView: View {
|
|||||||
completionSummary: String,
|
completionSummary: String,
|
||||||
iconName: String = "sparkles",
|
iconName: String = "sparkles",
|
||||||
timeOfDay: TimeOfDay = .anytime,
|
timeOfDay: TimeOfDay = .anytime,
|
||||||
isEnabled: Bool = true
|
hasActiveArc: Bool = true
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.theme = theme
|
self.theme = theme
|
||||||
@ -25,44 +25,37 @@ struct RitualCardView: View {
|
|||||||
self.completionSummary = completionSummary
|
self.completionSummary = completionSummary
|
||||||
self.iconName = iconName
|
self.iconName = iconName
|
||||||
self.timeOfDay = timeOfDay
|
self.timeOfDay = timeOfDay
|
||||||
self.isEnabled = isEnabled
|
self.hasActiveArc = hasActiveArc
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
// Icon
|
||||||
Image(systemName: iconName)
|
Image(systemName: iconName)
|
||||||
.foregroundStyle(isEnabled ? AppAccent.primary : AppTextColors.tertiary)
|
.foregroundStyle(hasActiveArc ? AppAccent.primary : AppTextColors.tertiary)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
// Title
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(isEnabled ? AppTextColors.primary : AppTextColors.tertiary)
|
.foregroundStyle(hasActiveArc ? AppTextColors.primary : AppTextColors.tertiary)
|
||||||
|
|
||||||
if !isEnabled {
|
|
||||||
Text(String(localized: "Disabled"))
|
|
||||||
.font(.caption2)
|
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
|
||||||
.background(AppSurface.secondary)
|
|
||||||
.clipShape(.capsule)
|
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: Design.Spacing.medium)
|
Spacer(minLength: Design.Spacing.medium)
|
||||||
|
|
||||||
// Time of day badge
|
// Time of day badge - more prominent
|
||||||
Image(systemName: timeOfDay.symbolName)
|
timeOfDayBadge
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
|
||||||
.accessibilityLabel(timeOfDay.displayName)
|
|
||||||
|
|
||||||
|
// Day label
|
||||||
Text(dayLabel)
|
Text(dayLabel)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(theme)
|
Text(theme)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(isEnabled ? AppTextColors.secondary : AppTextColors.tertiary)
|
.foregroundStyle(hasActiveArc ? AppTextColors.secondary : AppTextColors.tertiary)
|
||||||
|
|
||||||
Text(completionSummary)
|
Text(completionSummary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
@ -70,9 +63,47 @@ struct RitualCardView: View {
|
|||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
.background(AppSurface.card)
|
.background(AppSurface.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
.opacity(isEnabled ? 1.0 : Design.Opacity.medium)
|
.opacity(hasActiveArc ? 1.0 : Design.Opacity.medium)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var timeOfDayBadge: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: timeOfDay.symbolName)
|
||||||
|
.font(.caption2)
|
||||||
|
Text(timeOfDay.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(timeOfDayColor)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(timeOfDayColor.opacity(0.15))
|
||||||
|
.clipShape(.capsule)
|
||||||
|
|
||||||
|
Text(timeOfDay.timeRange)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(timeOfDay.displayNameWithRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeOfDayColor: Color {
|
||||||
|
switch timeOfDay {
|
||||||
|
case .morning:
|
||||||
|
return Color.orange
|
||||||
|
case .midday:
|
||||||
|
return Color.yellow
|
||||||
|
case .afternoon:
|
||||||
|
return Color.orange.opacity(0.8)
|
||||||
|
case .evening:
|
||||||
|
return Color.purple
|
||||||
|
case .night:
|
||||||
|
return Color.indigo
|
||||||
|
case .anytime:
|
||||||
|
return AppTextColors.secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@ -84,17 +115,37 @@ struct RitualCardView: View {
|
|||||||
completionSummary: "2 of 3 habits complete",
|
completionSummary: "2 of 3 habits complete",
|
||||||
iconName: "sunrise.fill",
|
iconName: "sunrise.fill",
|
||||||
timeOfDay: .morning,
|
timeOfDay: .morning,
|
||||||
isEnabled: true
|
hasActiveArc: true
|
||||||
)
|
)
|
||||||
|
|
||||||
RitualCardView(
|
RitualCardView(
|
||||||
title: "Evening Reset",
|
title: "Midday Reset",
|
||||||
|
theme: "Lunch break rituals",
|
||||||
|
dayLabel: "Day 14 of 28",
|
||||||
|
completionSummary: "1 of 2 habits complete",
|
||||||
|
iconName: "sun.max.fill",
|
||||||
|
timeOfDay: .midday,
|
||||||
|
hasActiveArc: true
|
||||||
|
)
|
||||||
|
|
||||||
|
RitualCardView(
|
||||||
|
title: "Evening Wind Down",
|
||||||
theme: "Soft landings",
|
theme: "Soft landings",
|
||||||
dayLabel: "Day 14 of 28",
|
dayLabel: "Day 14 of 28",
|
||||||
completionSummary: "0 of 3 habits complete",
|
completionSummary: "0 of 3 habits complete",
|
||||||
iconName: "moon.stars.fill",
|
iconName: "moon.stars.fill",
|
||||||
timeOfDay: .evening,
|
timeOfDay: .evening,
|
||||||
isEnabled: false
|
hasActiveArc: true
|
||||||
|
)
|
||||||
|
|
||||||
|
RitualCardView(
|
||||||
|
title: "Flexible Routine",
|
||||||
|
theme: "Anytime habits",
|
||||||
|
dayLabel: "Day 7 of 21",
|
||||||
|
completionSummary: "3 of 3 habits complete",
|
||||||
|
iconName: "clock.fill",
|
||||||
|
timeOfDay: .anytime,
|
||||||
|
hasActiveArc: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
|
|||||||
@ -9,7 +9,8 @@ struct RitualDetailView: View {
|
|||||||
|
|
||||||
@State private var showingEditSheet = false
|
@State private var showingEditSheet = false
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var showingDeleteConfirmation = false
|
||||||
@State private var showingArchiveConfirmation = false
|
@State private var showingEndArcConfirmation = false
|
||||||
|
@State private var showingStartArcConfirmation = false
|
||||||
|
|
||||||
init(store: RitualStore, ritual: Ritual) {
|
init(store: RitualStore, ritual: Ritual) {
|
||||||
self.store = store
|
self.store = store
|
||||||
@ -28,37 +29,35 @@ struct RitualDetailView: View {
|
|||||||
store.milestonesAchieved(for: ritual)
|
store.milestonesAchieved(for: ritual)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var habitRates: [(habit: Habit, rate: Double)] {
|
private var habitRates: [(habit: ArcHabit, rate: Double)] {
|
||||||
store.habitCompletionRates(for: ritual)
|
store.habitCompletionRates(for: ritual)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasMultipleArcs: Bool {
|
||||||
|
ritual.arcs.count > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completedArcs: [RitualArc] {
|
||||||
|
ritual.arcs.filter { !$0.isActive }.sorted { $0.startDate > $1.startDate }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
// Header with icon
|
// Header with icon
|
||||||
headerSection
|
headerSection
|
||||||
|
|
||||||
// Progress card
|
if ritual.hasActiveArc {
|
||||||
RitualFocusCardView(
|
// Active arc content
|
||||||
title: ritual.title,
|
activeArcContent
|
||||||
theme: ritual.theme,
|
} else {
|
||||||
dayLabel: store.ritualDayLabel(for: ritual),
|
// Past ritual - show summary and restart option
|
||||||
completionSummary: store.completionSummary(for: ritual),
|
pastRitualContent
|
||||||
progress: store.ritualProgress(for: ritual)
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// Status badges with time remaining and streak
|
// Arc history (if multiple arcs exist)
|
||||||
statusBadges
|
if hasMultipleArcs || !ritual.hasActiveArc {
|
||||||
|
arcHistorySection
|
||||||
// Time remaining and streak info
|
|
||||||
timeAndStreakSection
|
|
||||||
|
|
||||||
// Milestones
|
|
||||||
RitualMilestonesView(milestones: milestones)
|
|
||||||
|
|
||||||
// Habit performance
|
|
||||||
if !habitRates.isEmpty {
|
|
||||||
HabitPerformanceView(habitRates: habitRates)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
@ -71,21 +70,9 @@ struct RitualDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Habits
|
// Habits (only show for active rituals)
|
||||||
SectionHeaderView(
|
if ritual.hasActiveArc {
|
||||||
title: String(localized: "Habits"),
|
habitsSection
|
||||||
subtitle: String(localized: "Tap to check in")
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
|
||||||
ForEach(store.habits(for: ritual)) { habit in
|
|
||||||
TodayHabitRowView(
|
|
||||||
title: habit.title,
|
|
||||||
symbolName: habit.symbolName,
|
|
||||||
isCompleted: store.isHabitCompletedToday(habit),
|
|
||||||
action: { store.toggleHabitCompletion(habit) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
@ -106,23 +93,22 @@ struct RitualDetailView: View {
|
|||||||
Label(String(localized: "Edit"), systemImage: "pencil")
|
Label(String(localized: "Edit"), systemImage: "pencil")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
if ritual.hasActiveArc {
|
||||||
store.toggleEnabled(ritual)
|
Button {
|
||||||
} label: {
|
showingEndArcConfirmation = true
|
||||||
Label(
|
} label: {
|
||||||
ritual.isEnabled ? String(localized: "Disable") : String(localized: "Enable"),
|
Label(String(localized: "End Arc"), systemImage: "stop.circle")
|
||||||
systemImage: ritual.isEnabled ? "pause.circle" : "play.circle"
|
}
|
||||||
)
|
} else {
|
||||||
|
Button {
|
||||||
|
showingStartArcConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Start New Arc"), systemImage: "arrow.clockwise.circle")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button {
|
|
||||||
showingArchiveConfirmation = true
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "Archive"), systemImage: "archivebox")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showingDeleteConfirmation = true
|
showingDeleteConfirmation = true
|
||||||
} label: {
|
} label: {
|
||||||
@ -146,14 +132,21 @@ struct RitualDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "This will permanently remove \"\(ritual.title)\" and all its completion history. This cannot be undone."))
|
Text(String(localized: "This will permanently remove \"\(ritual.title)\" and all its completion history. This cannot be undone."))
|
||||||
}
|
}
|
||||||
.alert(String(localized: "Archive Ritual?"), isPresented: $showingArchiveConfirmation) {
|
.alert(String(localized: "End Arc?"), isPresented: $showingEndArcConfirmation) {
|
||||||
Button(String(localized: "Cancel"), role: .cancel) {}
|
Button(String(localized: "Cancel"), role: .cancel) {}
|
||||||
Button(String(localized: "Archive")) {
|
Button(String(localized: "End Arc")) {
|
||||||
store.archiveRitual(ritual)
|
store.endArc(for: ritual)
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "This ritual will be hidden from your active list but its history will be preserved."))
|
Text(String(localized: "This will end the current arc. You can start a new arc anytime from the Past tab."))
|
||||||
|
}
|
||||||
|
.alert(String(localized: "Start New Arc?"), isPresented: $showingStartArcConfirmation) {
|
||||||
|
Button(String(localized: "Cancel"), role: .cancel) {}
|
||||||
|
Button(String(localized: "Start")) {
|
||||||
|
store.startNewArc(for: ritual)
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: "This will start a new arc for this ritual with the same habits. You can modify habits after starting."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,9 +156,9 @@ struct RitualDetailView: View {
|
|||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: ritual.iconName)
|
Image(systemName: ritual.iconName)
|
||||||
.font(.system(size: Design.BaseFontSize.largeTitle))
|
.font(.system(size: Design.BaseFontSize.largeTitle))
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(ritual.hasActiveArc ? AppAccent.primary : AppTextColors.secondary)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
.background(AppAccent.primary.opacity(0.1))
|
.background((ritual.hasActiveArc ? AppAccent.primary : AppTextColors.secondary).opacity(0.1))
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
@ -183,18 +176,123 @@ struct RitualDetailView: View {
|
|||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Active Arc Content
|
||||||
|
|
||||||
|
private var activeArcContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
|
// Progress card
|
||||||
|
RitualFocusCardView(
|
||||||
|
title: ritual.title,
|
||||||
|
theme: ritual.theme,
|
||||||
|
dayLabel: store.ritualDayLabel(for: ritual),
|
||||||
|
completionSummary: store.completionSummary(for: ritual),
|
||||||
|
progress: store.ritualProgress(for: ritual)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status badges
|
||||||
|
statusBadges
|
||||||
|
|
||||||
|
// Time remaining and streak info
|
||||||
|
timeAndStreakSection
|
||||||
|
|
||||||
|
// Milestones
|
||||||
|
RitualMilestonesView(milestones: milestones)
|
||||||
|
|
||||||
|
// Habit performance
|
||||||
|
if !habitRates.isEmpty {
|
||||||
|
HabitPerformanceView(habitRates: habitRates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Past Ritual Content
|
||||||
|
|
||||||
|
private var pastRitualContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
|
// Status badges
|
||||||
|
statusBadges
|
||||||
|
|
||||||
|
// Summary card
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
Text(String(localized: "This ritual is not currently active"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastArc = ritual.latestArc {
|
||||||
|
let totalCheckIns = lastArc.habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||||
|
let possibleCheckIns = lastArc.habits.count * lastArc.durationDays
|
||||||
|
let completionRate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0
|
||||||
|
|
||||||
|
Text(String(localized: "Last arc completed with \(completionRate)% habit completion over \(lastArc.durationDays) days."))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingStartArcConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Start New Arc"), systemImage: "arrow.clockwise.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(AppAccent.primary)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Habits Section
|
||||||
|
|
||||||
|
private var habitsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
SectionHeaderView(
|
||||||
|
title: String(localized: "Habits"),
|
||||||
|
subtitle: String(localized: "Tap to check in")
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
ForEach(store.habits(for: ritual)) { habit in
|
||||||
|
TodayHabitRowView(
|
||||||
|
title: habit.title,
|
||||||
|
symbolName: habit.symbolName,
|
||||||
|
isCompleted: store.isHabitCompletedToday(habit),
|
||||||
|
action: { store.toggleHabitCompletion(habit) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Status Badges
|
// MARK: - Status Badges
|
||||||
|
|
||||||
private var statusBadges: some View {
|
private var statusBadges: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Current arc indicator
|
||||||
|
if let arc = ritual.currentArc {
|
||||||
|
Text(String(localized: "Arc \(arc.arcNumber)"))
|
||||||
|
.font(.caption.bold())
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(AppAccent.primary.opacity(0.2))
|
||||||
|
.clipShape(.capsule)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
} else {
|
||||||
|
Text(String(localized: "No active arc"))
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(AppTextColors.tertiary.opacity(0.2))
|
||||||
|
.clipShape(.capsule)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
// Time of day badge
|
// Time of day badge
|
||||||
Label(ritual.timeOfDay.displayName, systemImage: ritual.timeOfDay.symbolName)
|
timeOfDayBadge
|
||||||
.font(.caption)
|
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
|
||||||
.background(AppSurface.card)
|
|
||||||
.clipShape(.capsule)
|
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
|
||||||
|
|
||||||
// Category badge (if set)
|
// Category badge (if set)
|
||||||
if !ritual.category.isEmpty {
|
if !ritual.category.isEmpty {
|
||||||
@ -207,19 +305,105 @@ struct RitualDetailView: View {
|
|||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabled/Disabled badge
|
Spacer()
|
||||||
if !ritual.isEnabled {
|
}
|
||||||
Text(String(localized: "Disabled"))
|
}
|
||||||
|
|
||||||
|
private var timeOfDayBadge: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: ritual.timeOfDay.symbolName)
|
||||||
|
.font(.caption2)
|
||||||
|
Text(ritual.timeOfDay.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(timeOfDayColor)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(timeOfDayColor.opacity(0.15))
|
||||||
|
.clipShape(.capsule)
|
||||||
|
|
||||||
|
Text(ritual.timeOfDay.timeRange)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.padding(.leading, Design.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeOfDayColor: Color {
|
||||||
|
switch ritual.timeOfDay {
|
||||||
|
case .morning:
|
||||||
|
return Color.orange
|
||||||
|
case .midday:
|
||||||
|
return Color.yellow
|
||||||
|
case .afternoon:
|
||||||
|
return Color.orange.opacity(0.8)
|
||||||
|
case .evening:
|
||||||
|
return Color.purple
|
||||||
|
case .night:
|
||||||
|
return Color.indigo
|
||||||
|
case .anytime:
|
||||||
|
return AppTextColors.secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Arc History Section
|
||||||
|
|
||||||
|
private var arcHistorySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
SectionHeaderView(
|
||||||
|
title: String(localized: "Arc History"),
|
||||||
|
subtitle: ritual.arcs.isEmpty ? nil : String(localized: "\(ritual.arcs.count) total")
|
||||||
|
)
|
||||||
|
|
||||||
|
if completedArcs.isEmpty {
|
||||||
|
Text(String(localized: "No completed arcs yet."))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
} else {
|
||||||
.background(AppStatus.warning.opacity(0.2))
|
VStack(spacing: Design.Spacing.small) {
|
||||||
.clipShape(.capsule)
|
ForEach(completedArcs) { arc in
|
||||||
.foregroundStyle(AppStatus.warning)
|
arcHistoryRow(arc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func arcHistoryRow(_ arc: RitualArc) -> some View {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateStyle = .medium
|
||||||
|
|
||||||
|
let totalCheckIns = arc.habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||||
|
let possibleCheckIns = arc.habits.count * arc.durationDays
|
||||||
|
let completionRate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0
|
||||||
|
|
||||||
|
return HStack {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(String(localized: "Arc \(arc.arcNumber)"))
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text("\(dateFormatter.string(from: arc.startDate)) – \(dateFormatter.string(from: arc.endDate))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text("\(completionRate)%")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(completionRate >= 70 ? AppStatus.success : AppTextColors.secondary)
|
||||||
|
|
||||||
|
Text(String(localized: "\(arc.durationDays) days"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Time and Streak Section
|
// MARK: - Time and Streak Section
|
||||||
|
|||||||
@ -3,38 +3,41 @@ import Bedrock
|
|||||||
|
|
||||||
struct RitualsView: View {
|
struct RitualsView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
|
@State private var selectedTab: RitualsTab = .current
|
||||||
@State private var showingPresetLibrary = false
|
@State private var showingPresetLibrary = false
|
||||||
@State private var showingCreateRitual = false
|
@State private var showingCreateRitual = false
|
||||||
@State private var ritualToDelete: Ritual?
|
@State private var ritualToDelete: Ritual?
|
||||||
@State private var ritualToArchive: Ritual?
|
@State private var ritualToRestart: Ritual?
|
||||||
|
|
||||||
|
enum RitualsTab: String, CaseIterable {
|
||||||
|
case current
|
||||||
|
case past
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .current: return String(localized: "Current")
|
||||||
|
case .past: return String(localized: "Past")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
SectionHeaderView(
|
// Segmented picker
|
||||||
title: String(localized: "Rituals"),
|
Picker(String(localized: "View"), selection: $selectedTab) {
|
||||||
subtitle: String(localized: "Your active and recent arcs")
|
ForEach(RitualsTab.allCases, id: \.self) { tab in
|
||||||
)
|
Text(tab.displayName).tag(tab)
|
||||||
|
}
|
||||||
// Active rituals
|
|
||||||
if !store.enabledRituals.isEmpty {
|
|
||||||
activeRitualsSection
|
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
|
||||||
// Disabled rituals
|
switch selectedTab {
|
||||||
let disabledRituals = store.rituals.filter { !$0.isEnabled && !$0.isArchived }
|
case .current:
|
||||||
if !disabledRituals.isEmpty {
|
currentRitualsContent
|
||||||
disabledRitualsSection(disabledRituals)
|
case .past:
|
||||||
}
|
pastRitualsContent
|
||||||
|
|
||||||
// Archived rituals
|
|
||||||
if !store.archivedRituals.isEmpty {
|
|
||||||
archivedRitualsSection
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if store.rituals.isEmpty {
|
|
||||||
emptyState
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
@ -88,69 +91,78 @@ struct RitualsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "This will permanently remove this ritual and all its completion history. This cannot be undone."))
|
Text(String(localized: "This will permanently remove this ritual and all its completion history. This cannot be undone."))
|
||||||
}
|
}
|
||||||
.alert(String(localized: "Archive Ritual?"), isPresented: .init(
|
.alert(String(localized: "Start New Arc?"), isPresented: .init(
|
||||||
get: { ritualToArchive != nil },
|
get: { ritualToRestart != nil },
|
||||||
set: { if !$0 { ritualToArchive = nil } }
|
set: { if !$0 { ritualToRestart = nil } }
|
||||||
)) {
|
)) {
|
||||||
Button(String(localized: "Cancel"), role: .cancel) {
|
Button(String(localized: "Cancel"), role: .cancel) {
|
||||||
ritualToArchive = nil
|
ritualToRestart = nil
|
||||||
}
|
}
|
||||||
Button(String(localized: "Archive")) {
|
Button(String(localized: "Start")) {
|
||||||
if let ritual = ritualToArchive {
|
if let ritual = ritualToRestart {
|
||||||
store.archiveRitual(ritual)
|
store.startNewArc(for: ritual)
|
||||||
}
|
}
|
||||||
ritualToArchive = nil
|
ritualToRestart = nil
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "This ritual will be hidden from your active list but its history will be preserved."))
|
Text(String(localized: "This will start a new arc for this ritual with the same habits. You can modify habits after starting."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sections
|
// MARK: - Current Tab Content
|
||||||
|
|
||||||
private var activeRitualsSection: some View {
|
@ViewBuilder
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
private var currentRitualsContent: some View {
|
||||||
ForEach(store.enabledRituals) { ritual in
|
let groupedRituals = store.currentRitualsGroupedByTime()
|
||||||
ritualRow(for: ritual)
|
|
||||||
|
if groupedRituals.isEmpty {
|
||||||
|
currentEmptyState
|
||||||
|
} else {
|
||||||
|
ForEach(groupedRituals, id: \.timeOfDay) { group in
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
// Time of day header
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: group.timeOfDay.symbolName)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
Text(group.timeOfDay.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
|
||||||
|
ForEach(group.rituals) { ritual in
|
||||||
|
currentRitualRow(for: ritual)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func disabledRitualsSection(_ rituals: [Ritual]) -> some View {
|
// MARK: - Past Tab Content
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
||||||
Text(String(localized: "Disabled"))
|
@ViewBuilder
|
||||||
.font(.subheadline)
|
private var pastRitualsContent: some View {
|
||||||
.bold()
|
if store.pastRituals.isEmpty {
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
pastEmptyState
|
||||||
.padding(.top, Design.Spacing.medium)
|
} else {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
ForEach(rituals) { ritual in
|
ForEach(store.pastRituals) { ritual in
|
||||||
ritualRow(for: ritual)
|
pastRitualRow(for: ritual)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var archivedRitualsSection: some View {
|
// MARK: - Empty States
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
||||||
Text(String(localized: "Archived"))
|
|
||||||
.font(.subheadline)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
|
||||||
.padding(.top, Design.Spacing.medium)
|
|
||||||
|
|
||||||
ForEach(store.archivedRituals) { ritual in
|
|
||||||
archivedRitualRow(for: ritual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var currentEmptyState: some View {
|
||||||
VStack(spacing: Design.Spacing.large) {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
|
||||||
Text(String(localized: "No Rituals Yet"))
|
Text(String(localized: "No Active Rituals"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
@ -175,6 +187,32 @@ struct RitualsView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !store.pastRituals.isEmpty {
|
||||||
|
Text(String(localized: "Or restart a past ritual from the Past tab."))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.xxxLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pastEmptyState: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
|
||||||
|
Text(String(localized: "No Past Rituals"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(String(localized: "Rituals that have ended will appear here. You can restart them anytime."))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, Design.Spacing.xxxLarge)
|
.padding(.vertical, Design.Spacing.xxxLarge)
|
||||||
@ -182,7 +220,7 @@ struct RitualsView: View {
|
|||||||
|
|
||||||
// MARK: - Ritual Rows
|
// MARK: - Ritual Rows
|
||||||
|
|
||||||
private func ritualRow(for ritual: Ritual) -> some View {
|
private func currentRitualRow(for ritual: Ritual) -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
RitualDetailView(store: store, ritual: ritual)
|
RitualDetailView(store: store, ritual: ritual)
|
||||||
} label: {
|
} label: {
|
||||||
@ -193,12 +231,24 @@ struct RitualsView: View {
|
|||||||
completionSummary: store.completionSummary(for: ritual),
|
completionSummary: store.completionSummary(for: ritual),
|
||||||
iconName: ritual.iconName,
|
iconName: ritual.iconName,
|
||||||
timeOfDay: ritual.timeOfDay,
|
timeOfDay: ritual.timeOfDay,
|
||||||
isEnabled: ritual.isEnabled
|
hasActiveArc: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
contextMenuItems(for: ritual)
|
Button {
|
||||||
|
store.endArc(for: ritual)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "End Arc"), systemImage: "stop.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
ritualToDelete = ritual
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Delete"), systemImage: "trash")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@ -208,83 +258,110 @@ struct RitualsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
ritualToArchive = ritual
|
store.endArc(for: ritual)
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "Archive"), systemImage: "archivebox")
|
Label(String(localized: "End"), systemImage: "stop.circle")
|
||||||
}
|
}
|
||||||
.tint(AppAccent.secondary)
|
.tint(AppAccent.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pastRitualRow(for ritual: Ritual) -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
RitualDetailView(store: store, ritual: ritual)
|
||||||
|
} label: {
|
||||||
|
pastRitualCardView(for: ritual)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
ritualToRestart = ritual
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Start New Arc"), systemImage: "arrow.clockwise.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
ritualToDelete = ritual
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Delete"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
ritualToDelete = ritual
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Delete"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
store.toggleEnabled(ritual)
|
ritualToRestart = ritual
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(String(localized: "Restart"), systemImage: "arrow.clockwise.circle")
|
||||||
ritual.isEnabled ? String(localized: "Disable") : String(localized: "Enable"),
|
|
||||||
systemImage: ritual.isEnabled ? "pause.circle" : "play.circle"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.tint(ritual.isEnabled ? AppTextColors.tertiary : AppStatus.success)
|
.tint(AppStatus.success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func archivedRitualRow(for ritual: Ritual) -> some View {
|
private func pastRitualCardView(for ritual: Ritual) -> some View {
|
||||||
HStack {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: ritual.iconName)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(AppSurface.secondary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
Text(ritual.title)
|
Text(ritual.title)
|
||||||
.font(.subheadline)
|
.font(.headline)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
Text(ritual.theme)
|
Text(ritual.theme)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
if let lastArc = ritual.latestArc {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
Text(formattedEndDate(lastArc.endDate))
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
// Arc count badge
|
||||||
store.unarchiveRitual(ritual)
|
if ritual.completedArcCount > 0 {
|
||||||
} label: {
|
Text("\(ritual.completedArcCount) arc\(ritual.completedArcCount == 1 ? "" : "s")")
|
||||||
Text(String(localized: "Restore"))
|
.font(.caption2)
|
||||||
.font(.caption)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(AppSurface.secondary)
|
||||||
|
.clipShape(.capsule)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
.background(AppSurface.card)
|
.background(AppSurface.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
.opacity(Design.Opacity.medium)
|
.opacity(Design.Opacity.heavy)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
ritualToDelete = ritual
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "Delete"), systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func formattedEndDate(_ date: Date) -> String {
|
||||||
private func contextMenuItems(for ritual: Ritual) -> some View {
|
let formatter = DateFormatter()
|
||||||
Button {
|
formatter.dateStyle = .medium
|
||||||
store.toggleEnabled(ritual)
|
formatter.timeStyle = .none
|
||||||
} label: {
|
return String(localized: "Ended \(formatter.string(from: date))")
|
||||||
Label(
|
|
||||||
ritual.isEnabled ? String(localized: "Disable") : String(localized: "Enable"),
|
|
||||||
systemImage: ritual.isEnabled ? "pause.circle" : "play.circle"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
ritualToArchive = ritual
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "Archive"), systemImage: "archivebox")
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
ritualToDelete = ritual
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "Delete"), systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
220
Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift
Normal file
220
Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// Sheet presented when a ritual's arc completes, prompting the user to renew or end.
|
||||||
|
struct ArcRenewalSheet: View {
|
||||||
|
@Bindable var store: RitualStore
|
||||||
|
let ritual: Ritual
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var durationDays: Double
|
||||||
|
@State private var showingEditSheet = false
|
||||||
|
|
||||||
|
init(store: RitualStore, ritual: Ritual) {
|
||||||
|
self.store = store
|
||||||
|
self.ritual = ritual
|
||||||
|
_durationDays = State(initialValue: Double(ritual.defaultDurationDays))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completedArc: RitualArc? {
|
||||||
|
ritual.latestArc
|
||||||
|
}
|
||||||
|
|
||||||
|
private var arcSummary: String {
|
||||||
|
guard let arc = completedArc else { return "" }
|
||||||
|
let totalHabits = arc.habits.count
|
||||||
|
let totalCheckIns = arc.habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||||
|
let possibleCheckIns = totalHabits * arc.durationDays
|
||||||
|
let rate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0
|
||||||
|
return String(localized: "\(rate)% completion over \(arc.durationDays) days")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
|
||||||
|
celebrationHeader
|
||||||
|
summaryCard
|
||||||
|
durationSection
|
||||||
|
actionButtons
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.navigationTitle(String(localized: "Ritual Complete"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "Later")) {
|
||||||
|
store.dismissRenewalPrompt()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingEditSheet) {
|
||||||
|
RitualEditSheet(store: store, ritual: ritual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var celebrationHeader: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(AppStatus.success)
|
||||||
|
|
||||||
|
Text(ritual.title)
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
if let arc = completedArc {
|
||||||
|
Text(String(localized: "Arc \(arc.arcNumber) Complete"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summaryCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text(String(localized: "Your Journey"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
Text(arcSummary)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
|
||||||
|
if let arc = completedArc {
|
||||||
|
let habitCount = arc.habits.count
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
Text(String(localized: "\(habitCount) habits tracked"))
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(AppSurface.secondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var durationSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text(String(localized: "Next Arc Duration"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "\(Int(durationDays)) days"))
|
||||||
|
.font(.title3.bold())
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(value: $durationDays, in: 7...365, step: 1)
|
||||||
|
.tint(AppAccent.primary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "1 week"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(localized: "1 year"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick presets
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach([14, 21, 28, 30, 60, 90], id: \.self) { days in
|
||||||
|
Button {
|
||||||
|
durationDays = Double(days)
|
||||||
|
} label: {
|
||||||
|
Text("\(days)")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(Int(durationDays) == days ? AppTextColors.primary : AppTextColors.secondary)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(Int(durationDays) == days ? AppAccent.primary : AppSurface.tertiary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(AppSurface.secondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionButtons: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Continue with same habits
|
||||||
|
Button {
|
||||||
|
store.renewArc(for: ritual, durationDays: Int(durationDays), copyHabits: true)
|
||||||
|
store.dismissRenewalPrompt()
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
Text(String(localized: "Continue with Same Habits"))
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with changes
|
||||||
|
Button {
|
||||||
|
store.renewArc(for: ritual, durationDays: Int(durationDays), copyHabits: true)
|
||||||
|
store.dismissRenewalPrompt()
|
||||||
|
showingEditSheet = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
Text(String(localized: "Continue with Changes"))
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppSurface.secondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.stroke(AppAccent.primary, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// End ritual
|
||||||
|
Button {
|
||||||
|
store.endArc(for: ritual)
|
||||||
|
store.dismissRenewalPrompt()
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
Text(String(localized: "End This Ritual"))
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,9 +16,13 @@ struct RitualEditSheet: View {
|
|||||||
@State private var timeOfDay: TimeOfDay = .anytime
|
@State private var timeOfDay: TimeOfDay = .anytime
|
||||||
@State private var iconName: String = "sparkles"
|
@State private var iconName: String = "sparkles"
|
||||||
@State private var category: String = ""
|
@State private var category: String = ""
|
||||||
|
@State private var customCategory: String = ""
|
||||||
|
@State private var isUsingCustomCategory: Bool = false
|
||||||
@State private var habits: [EditableHabit] = []
|
@State private var habits: [EditableHabit] = []
|
||||||
|
|
||||||
@State private var showingIconPicker = false
|
@State private var showingIconPicker = false
|
||||||
|
@State private var showingHabitIconPicker = false
|
||||||
|
@State private var editingHabitIndex: Int?
|
||||||
@State private var newHabitTitle: String = ""
|
@State private var newHabitTitle: String = ""
|
||||||
@State private var newHabitIcon: String = "circle.fill"
|
@State private var newHabitIcon: String = "circle.fill"
|
||||||
|
|
||||||
@ -48,6 +52,7 @@ struct RitualEditSheet: View {
|
|||||||
// Notes section
|
// Notes section
|
||||||
notesSection
|
notesSection
|
||||||
}
|
}
|
||||||
|
.environment(\.editMode, .constant(.active))
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
.navigationTitle(isEditing ? String(localized: "Edit Ritual") : String(localized: "New Ritual"))
|
.navigationTitle(isEditing ? String(localized: "Edit Ritual") : String(localized: "New Ritual"))
|
||||||
@ -75,6 +80,16 @@ struct RitualEditSheet: View {
|
|||||||
.sheet(isPresented: $showingIconPicker) {
|
.sheet(isPresented: $showingIconPicker) {
|
||||||
IconPickerSheet(selectedIcon: $iconName)
|
IconPickerSheet(selectedIcon: $iconName)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingHabitIconPicker) {
|
||||||
|
HabitIconPickerSheet(
|
||||||
|
selectedIcon: editingHabitIndex != nil
|
||||||
|
? Binding(
|
||||||
|
get: { habits[editingHabitIndex!].symbolName },
|
||||||
|
set: { habits[editingHabitIndex!].symbolName = $0 }
|
||||||
|
)
|
||||||
|
: $newHabitIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
@ -107,10 +122,22 @@ struct RitualEditSheet: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
|
|
||||||
Picker(String(localized: "Category"), selection: $category) {
|
// Category selection with custom option
|
||||||
Text(String(localized: "None")).tag("")
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
ForEach(PresetCategory.allCases, id: \.self) { cat in
|
Picker(String(localized: "Category"), selection: $category) {
|
||||||
Text(cat.displayName).tag(cat.rawValue)
|
Text(String(localized: "None")).tag("")
|
||||||
|
ForEach(PresetCategory.allCases, id: \.self) { cat in
|
||||||
|
Text(cat.displayName).tag(cat.rawValue)
|
||||||
|
}
|
||||||
|
Text(String(localized: "Custom...")).tag("__custom__")
|
||||||
|
}
|
||||||
|
.onChange(of: category) { _, newValue in
|
||||||
|
isUsingCustomCategory = (newValue == "__custom__")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isUsingCustomCategory {
|
||||||
|
TextField(String(localized: "Enter custom category"), text: $customCategory)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
@ -119,13 +146,27 @@ struct RitualEditSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@State private var isEditingDuration: Bool = false
|
||||||
|
@State private var customDurationText: String = ""
|
||||||
|
|
||||||
private var scheduleSection: some View {
|
private var scheduleSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker(String(localized: "Time of Day"), selection: $timeOfDay) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
ForEach(TimeOfDay.allCases, id: \.self) { time in
|
Picker(String(localized: "Time of Day"), selection: $timeOfDay) {
|
||||||
Label(time.displayName, systemImage: time.symbolName)
|
ForEach(TimeOfDay.allCases, id: \.self) { time in
|
||||||
.tag(time)
|
Label(time.displayName, systemImage: time.symbolName)
|
||||||
|
.tag(time)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the time range for the selected time of day
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: timeOfDay.symbolName)
|
||||||
|
.font(.caption)
|
||||||
|
Text(timeOfDay.timeRange)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
|
|
||||||
@ -133,28 +174,108 @@ struct RitualEditSheet: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text(String(localized: "Duration"))
|
Text(String(localized: "Duration"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(localized: "\(Int(durationDays)) days"))
|
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
if isEditingDuration {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
TextField("", text: $customDurationText)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 60)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit {
|
||||||
|
applyCustomDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(String(localized: "days"))
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
applyCustomDuration()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
customDurationText = "\(Int(durationDays))"
|
||||||
|
isEditingDuration = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(String(localized: "\(Int(durationDays)) days"))
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
Image(systemName: "pencil.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider(value: $durationDays, in: 7...90, step: 1)
|
if !isEditingDuration {
|
||||||
.tint(AppAccent.primary)
|
Slider(value: $durationDays, in: 7...365, step: 1)
|
||||||
|
.tint(AppAccent.primary)
|
||||||
|
|
||||||
|
// Quick duration presets
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach([14, 21, 28, 30, 90], id: \.self) { days in
|
||||||
|
Button {
|
||||||
|
durationDays = Double(days)
|
||||||
|
} label: {
|
||||||
|
Text("\(days)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Int(durationDays) == days ? AppAccent.primary : AppTextColors.tertiary)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(Int(durationDays) == days ? AppAccent.primary.opacity(0.2) : AppSurface.secondary)
|
||||||
|
.clipShape(.capsule)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(AppSurface.card)
|
.listRowBackground(AppSurface.card)
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "Schedule"))
|
Text(String(localized: "Schedule"))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "Tap the duration to enter a custom number of days (up to 365)."))
|
||||||
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyCustomDuration() {
|
||||||
|
if let value = Int(customDurationText), value >= 1, value <= 365 {
|
||||||
|
durationDays = Double(value)
|
||||||
|
}
|
||||||
|
isEditingDuration = false
|
||||||
|
}
|
||||||
|
|
||||||
private var habitsSection: some View {
|
private var habitsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach($habits) { $habit in
|
ForEach(Array(habits.enumerated()), id: \.element.id) { index, habit in
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
Image(systemName: habit.symbolName)
|
// Drag handle
|
||||||
.foregroundStyle(AppAccent.primary)
|
Image(systemName: "line.3.horizontal")
|
||||||
.frame(width: 24)
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.frame(width: 20)
|
||||||
|
|
||||||
TextField(String(localized: "Habit name"), text: $habit.title)
|
// Icon picker button
|
||||||
|
Button {
|
||||||
|
editingHabitIndex = index
|
||||||
|
showingHabitIconPicker = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: habit.symbolName)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.frame(width: 24)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
TextField(String(localized: "Habit name"), text: $habits[index].title)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
habits.removeAll { $0.id == habit.id }
|
habits.removeAll { $0.id == habit.id }
|
||||||
@ -173,14 +294,14 @@ struct RitualEditSheet: View {
|
|||||||
// Add new habit row
|
// Add new habit row
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Button {
|
Button {
|
||||||
// Simple icon rotation for new habits
|
editingHabitIndex = nil
|
||||||
let icons = ["circle.fill", "star.fill", "heart.fill", "bolt.fill", "leaf.fill"]
|
showingHabitIconPicker = true
|
||||||
newHabitIcon = icons.randomElement() ?? "circle.fill"
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: newHabitIcon)
|
Image(systemName: newHabitIcon)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
TextField(String(localized: "Add a habit..."), text: $newHabitTitle)
|
TextField(String(localized: "Add a habit..."), text: $newHabitTitle)
|
||||||
.onSubmit {
|
.onSubmit {
|
||||||
@ -205,6 +326,11 @@ struct RitualEditSheet: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
}
|
}
|
||||||
|
} footer: {
|
||||||
|
if habits.count > 1 {
|
||||||
|
Text(String(localized: "Drag the handle to reorder habits."))
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,8 +355,21 @@ struct RitualEditSheet: View {
|
|||||||
durationDays = Double(ritual.durationDays)
|
durationDays = Double(ritual.durationDays)
|
||||||
timeOfDay = ritual.timeOfDay
|
timeOfDay = ritual.timeOfDay
|
||||||
iconName = ritual.iconName
|
iconName = ritual.iconName
|
||||||
category = ritual.category
|
|
||||||
habits = ritual.habits.map { EditableHabit(from: $0) }
|
habits = ritual.habits.map { EditableHabit(from: $0) }
|
||||||
|
|
||||||
|
// Check if category is a preset or custom
|
||||||
|
let presetCategories = PresetCategory.allCases.map { $0.rawValue }
|
||||||
|
if ritual.category.isEmpty {
|
||||||
|
category = ""
|
||||||
|
isUsingCustomCategory = false
|
||||||
|
} else if presetCategories.contains(ritual.category) {
|
||||||
|
category = ritual.category
|
||||||
|
isUsingCustomCategory = false
|
||||||
|
} else {
|
||||||
|
category = "__custom__"
|
||||||
|
customCategory = ritual.category
|
||||||
|
isUsingCustomCategory = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addNewHabit() {
|
private func addNewHabit() {
|
||||||
@ -247,6 +386,16 @@ struct RitualEditSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var effectiveCategory: String {
|
||||||
|
if isUsingCustomCategory {
|
||||||
|
return customCategory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
} else if category == "__custom__" {
|
||||||
|
return ""
|
||||||
|
} else {
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func saveRitual() {
|
private func saveRitual() {
|
||||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let trimmedTheme = theme.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedTheme = theme.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@ -262,7 +411,7 @@ struct RitualEditSheet: View {
|
|||||||
durationDays: Int(durationDays),
|
durationDays: Int(durationDays),
|
||||||
timeOfDay: timeOfDay,
|
timeOfDay: timeOfDay,
|
||||||
iconName: iconName,
|
iconName: iconName,
|
||||||
category: category
|
category: effectiveCategory
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update habits - remove old, add new
|
// Update habits - remove old, add new
|
||||||
@ -275,7 +424,7 @@ struct RitualEditSheet: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new ritual
|
// Create new ritual
|
||||||
let newHabits = habits.map { Habit(title: $0.title, symbolName: $0.symbolName) }
|
let newHabits = habits.map { ArcHabit(title: $0.title, symbolName: $0.symbolName) }
|
||||||
store.createRitual(
|
store.createRitual(
|
||||||
title: trimmedTitle,
|
title: trimmedTitle,
|
||||||
theme: trimmedTheme,
|
theme: trimmedTheme,
|
||||||
@ -283,7 +432,7 @@ struct RitualEditSheet: View {
|
|||||||
durationDays: Int(durationDays),
|
durationDays: Int(durationDays),
|
||||||
timeOfDay: timeOfDay,
|
timeOfDay: timeOfDay,
|
||||||
iconName: iconName,
|
iconName: iconName,
|
||||||
category: category,
|
category: effectiveCategory,
|
||||||
habits: newHabits
|
habits: newHabits
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -301,54 +450,74 @@ private struct EditableHabit: Identifiable {
|
|||||||
self.symbolName = symbolName
|
self.symbolName = symbolName
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from habit: Habit) {
|
init(from habit: ArcHabit) {
|
||||||
self.title = habit.title
|
self.title = habit.title
|
||||||
self.symbolName = habit.symbolName
|
self.symbolName = habit.symbolName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple icon picker sheet
|
/// Icon picker sheet for ritual icons
|
||||||
struct IconPickerSheet: View {
|
struct IconPickerSheet: View {
|
||||||
@Binding var selectedIcon: String
|
@Binding var selectedIcon: String
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
private let icons = [
|
private let iconGroups: [(name: String, icons: [String])] = [
|
||||||
// Wellness
|
("Wellness", ["heart.fill", "drop.fill", "leaf.fill", "flame.fill", "bolt.fill", "pill.fill", "cross.fill", "stethoscope.circle.fill"]),
|
||||||
"heart.fill", "drop.fill", "leaf.fill", "flame.fill", "bolt.fill",
|
("Time", ["sunrise.fill", "sun.max.fill", "moon.stars.fill", "clock.fill", "hourglass", "timer", "alarm.fill"]),
|
||||||
// Time
|
("Activity", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "dumbbell.fill"]),
|
||||||
"sunrise.fill", "sun.max.fill", "moon.stars.fill", "clock.fill",
|
("Mind", ["brain", "brain.head.profile", "sparkles", "star.fill", "lightbulb.fill", "eye.fill", "ear.fill"]),
|
||||||
// Activity
|
("Objects", ["book.fill", "book.closed.fill", "pencil.and.list.clipboard", "cup.and.saucer.fill", "bed.double.fill", "tshirt.fill", "fork.knife"]),
|
||||||
"figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility",
|
("Nature", ["tree.fill", "wind", "cloud.fill", "snowflake", "sun.horizon.fill", "moon.fill", "mountain.2.fill"]),
|
||||||
// Mind
|
("Actions", ["checkmark.circle.fill", "target", "scope", "hand.thumbsup.fill", "hand.raised.fill", "bell.fill", "megaphone.fill"]),
|
||||||
"brain", "brain.head.profile", "sparkles", "star.fill",
|
("Arrows", ["arrow.up.circle.fill", "arrow.down.circle.fill", "arrow.counterclockwise.circle.fill", "arrow.2.circlepath"])
|
||||||
// Objects
|
|
||||||
"book.fill", "pencil.and.list.clipboard", "cup.and.saucer.fill", "bed.double.fill",
|
|
||||||
// Misc
|
|
||||||
"checkmark.circle.fill", "target", "scope", "wind"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
private var allIcons: [String] {
|
||||||
|
iconGroups.flatMap { $0.icons }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredIcons: [String] {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return allIcons
|
||||||
|
}
|
||||||
|
return allIcons.filter { $0.localizedStandardContains(searchText) }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.medium) {
|
if searchText.isEmpty {
|
||||||
ForEach(icons, id: \.self) { icon in
|
// Show grouped icons
|
||||||
Button {
|
LazyVStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
selectedIcon = icon
|
ForEach(iconGroups, id: \.name) { group in
|
||||||
dismiss()
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
} label: {
|
Text(group.name)
|
||||||
Image(systemName: icon)
|
.font(.caption)
|
||||||
.font(.title2)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
.foregroundStyle(selectedIcon == icon ? AppAccent.primary : AppTextColors.secondary)
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
.frame(width: 50, height: 50)
|
|
||||||
.background(selectedIcon == icon ? AppAccent.primary.opacity(0.2) : AppSurface.card)
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
ForEach(group.icons, id: \.self) { icon in
|
||||||
|
iconButton(icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
} else {
|
||||||
|
// Show filtered results
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
||||||
|
ForEach(filteredIcons, id: \.self) { icon in
|
||||||
|
iconButton(icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
|
||||||
}
|
}
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
|
.searchable(text: $searchText, prompt: String(localized: "Search icons"))
|
||||||
.navigationTitle(String(localized: "Choose Icon"))
|
.navigationTitle(String(localized: "Choose Icon"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -360,9 +529,135 @@ struct IconPickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func iconButton(_ icon: String) -> some View {
|
||||||
|
Button {
|
||||||
|
selectedIcon = icon
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(selectedIcon == icon ? AppAccent.primary : AppTextColors.secondary)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(selectedIcon == icon ? AppAccent.primary.opacity(0.2) : AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon picker sheet for habit icons with extensive SF Symbols
|
||||||
|
struct HabitIconPickerSheet: View {
|
||||||
|
@Binding var selectedIcon: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
|
private let iconGroups: [(name: String, icons: [String])] = [
|
||||||
|
("Common", ["circle.fill", "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "flame.fill", "drop.fill", "sparkles"]),
|
||||||
|
("Wellness", ["heart.fill", "heart.circle.fill", "cross.fill", "pill.fill", "stethoscope.circle.fill", "bandage.fill", "medical.thermometer.fill"]),
|
||||||
|
("Fitness", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "figure.strengthtraining.traditional", "dumbbell.fill", "sportscourt.fill"]),
|
||||||
|
("Food & Drink", ["drop.fill", "cup.and.saucer.fill", "mug.fill", "fork.knife", "carrot.fill", "fish.fill", "leaf.fill"]),
|
||||||
|
("Mind", ["brain", "brain.head.profile", "lightbulb.fill", "eye.fill", "ear.fill", "sparkles", "wand.and.stars"]),
|
||||||
|
("Reading & Writing", ["book.fill", "book.closed.fill", "books.vertical.fill", "text.book.closed.fill", "pencil", "pencil.and.list.clipboard", "note.text", "doc.text.fill"]),
|
||||||
|
("Time", ["clock.fill", "alarm.fill", "timer", "hourglass", "sunrise.fill", "sunset.fill", "moon.fill", "moon.stars.fill"]),
|
||||||
|
("Home", ["bed.double.fill", "bathtub.fill", "shower.fill", "washer.fill", "house.fill", "lamp.desk.fill", "chair.fill"]),
|
||||||
|
("Work", ["briefcase.fill", "folder.fill", "tray.full.fill", "archivebox.fill", "calendar", "calendar.badge.plus", "checkmark.square.fill"]),
|
||||||
|
("Social", ["person.fill", "person.2.fill", "person.wave.2.fill", "bubble.left.fill", "phone.fill", "envelope.fill", "hand.wave.fill"]),
|
||||||
|
("Nature", ["sun.max.fill", "cloud.fill", "wind", "snowflake", "tree.fill", "flower.fill", "mountain.2.fill"]),
|
||||||
|
("Tech", ["iphone", "desktopcomputer", "keyboard.fill", "headphones", "wifi", "antenna.radiowaves.left.and.right"]),
|
||||||
|
("Status", ["checkmark.circle.fill", "xmark.circle.fill", "exclamationmark.circle.fill", "questionmark.circle.fill", "target", "scope", "flag.fill"]),
|
||||||
|
("Shapes", ["circle.fill", "square.fill", "triangle.fill", "diamond.fill", "hexagon.fill", "octagon.fill", "seal.fill"]),
|
||||||
|
("Arrows", ["arrow.up.circle.fill", "arrow.down.circle.fill", "arrow.left.circle.fill", "arrow.right.circle.fill", "arrow.counterclockwise.circle.fill", "arrow.2.circlepath"]),
|
||||||
|
("Misc", ["gift.fill", "ticket.fill", "tag.fill", "pin.fill", "mappin.circle.fill", "key.fill", "lock.fill", "bell.fill", "megaphone.fill", "music.note", "photo.fill", "camera.fill"])
|
||||||
|
]
|
||||||
|
|
||||||
|
private var allIcons: [String] {
|
||||||
|
// Remove duplicates while maintaining order
|
||||||
|
var seen = Set<String>()
|
||||||
|
return iconGroups.flatMap { $0.icons }.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredIcons: [String] {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return allIcons
|
||||||
|
}
|
||||||
|
return allIcons.filter { $0.localizedStandardContains(searchText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
// Show grouped icons
|
||||||
|
LazyVStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
|
ForEach(iconGroups, id: \.name) { group in
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text(group.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
||||||
|
ForEach(group.icons, id: \.self) { icon in
|
||||||
|
iconButton(icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
} else {
|
||||||
|
// Show filtered results
|
||||||
|
if filteredIcons.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
String(localized: "No icons found"),
|
||||||
|
systemImage: "magnifyingglass",
|
||||||
|
description: Text(String(localized: "Try a different search term"))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: Design.Spacing.small) {
|
||||||
|
ForEach(filteredIcons, id: \.self) { icon in
|
||||||
|
iconButton(icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.searchable(text: $searchText, prompt: String(localized: "Search icons (e.g., heart, star, book)"))
|
||||||
|
.navigationTitle(String(localized: "Choose Habit Icon"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String(localized: "Done")) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconButton(_ icon: String) -> some View {
|
||||||
|
Button {
|
||||||
|
selectedIcon = icon
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(selectedIcon == icon ? AppAccent.primary : AppTextColors.secondary)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(selectedIcon == icon ? AppAccent.primary.opacity(0.2) : AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@ -176,6 +176,14 @@ struct SettingsView: View {
|
|||||||
ritualStore.preloadDemoData()
|
ritualStore.preloadDemoData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsRow(
|
||||||
|
systemImage: "checkmark.circle.badge.xmark",
|
||||||
|
title: String(localized: "Complete First Active Arc (Test Renewal)"),
|
||||||
|
iconColor: AppStatus.success
|
||||||
|
) {
|
||||||
|
ritualStore.simulateArcCompletion()
|
||||||
|
}
|
||||||
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
systemImage: "trash",
|
systemImage: "trash",
|
||||||
title: String(localized: "Clear All Completions"),
|
title: String(localized: "Clear All Completions"),
|
||||||
|
|||||||
@ -3,24 +3,80 @@ import Bedrock
|
|||||||
|
|
||||||
struct TodayEmptyStateView: View {
|
struct TodayEmptyStateView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
|
@State private var showingPresetLibrary = false
|
||||||
|
@State private var showingCreateRitual = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
SectionHeaderView(
|
SectionHeaderView(
|
||||||
title: String(localized: "No ritual yet"),
|
title: String(localized: "No Active Rituals"),
|
||||||
subtitle: String(localized: "Begin a four-week arc")
|
subtitle: String(localized: "Start building better habits")
|
||||||
)
|
)
|
||||||
|
|
||||||
EmptyStateCardView(
|
VStack(spacing: Design.Spacing.large) {
|
||||||
title: String(localized: "Start your first ritual"),
|
// Icon
|
||||||
message: String(localized: "Choose a theme and keep your focus clear for 28 days."),
|
Image(systemName: "sparkles")
|
||||||
actionTitle: String(localized: "Create ritual"),
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
||||||
action: { store.createQuickRitual() }
|
.foregroundStyle(AppAccent.primary)
|
||||||
)
|
.padding(.top, Design.Spacing.large)
|
||||||
|
|
||||||
|
Text(String(localized: "Rituals help you build consistent habits through focused, time-bound journeys."))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Button {
|
||||||
|
showingCreateRitual = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
Text(String(localized: "Create Custom Ritual"))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(AppAccent.primary)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingPresetLibrary = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sparkles.rectangle.stack")
|
||||||
|
Text(String(localized: "Browse Presets"))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
|
// Past rituals hint
|
||||||
|
if !store.pastRituals.isEmpty {
|
||||||
|
Text(String(localized: "You can also restart a past ritual from the Rituals tab."))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingPresetLibrary) {
|
||||||
|
PresetLibrarySheet(store: store)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingCreateRitual) {
|
||||||
|
RitualEditSheet(store: store, ritual: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
TodayEmptyStateView(store: RitualStore.preview)
|
TodayEmptyStateView(store: RitualStore.preview)
|
||||||
|
.padding()
|
||||||
|
.background(AppSurface.primary)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// Shown when there are active rituals but none scheduled for the current time of day.
|
||||||
|
struct TodayNoRitualsForTimeView: View {
|
||||||
|
@Bindable var store: RitualStore
|
||||||
|
|
||||||
|
private var currentTimePeriod: TimeOfDay {
|
||||||
|
let hour = Calendar.current.component(.hour, from: Date())
|
||||||
|
switch hour {
|
||||||
|
case 0..<11: return .morning
|
||||||
|
case 11..<14: return .midday
|
||||||
|
case 14..<17: return .afternoon
|
||||||
|
case 17..<21: return .evening
|
||||||
|
default: return .night
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextRituals: [Ritual] {
|
||||||
|
// Find rituals scheduled for later time periods
|
||||||
|
store.currentRituals.filter { ritual in
|
||||||
|
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
|
||||||
|
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
|
SectionHeaderView(
|
||||||
|
title: String(localized: "All caught up"),
|
||||||
|
subtitle: currentTimePeriod.displayName
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: currentTimePeriod.symbolName)
|
||||||
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
||||||
|
.foregroundStyle(AppAccent.primary.opacity(0.6))
|
||||||
|
.padding(.top, Design.Spacing.large)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(String(localized: "No rituals scheduled for \(currentTimePeriod.displayName.lowercased())."))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(currentTimePeriod.timeRange)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show upcoming rituals if any
|
||||||
|
if !nextRituals.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text(String(localized: "Coming up later:"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
|
||||||
|
ForEach(nextRituals) { ritual in
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: ritual.timeOfDay.symbolName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Text(ritual.title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(ritual.timeOfDay.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.small)
|
||||||
|
.background(AppSurface.secondary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motivational message
|
||||||
|
Text(String(localized: "Enjoy this moment. Your next ritual will appear when it's time."))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TodayNoRitualsForTimeView(store: RitualStore.preview)
|
||||||
|
.padding()
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
}
|
||||||
@ -4,9 +4,19 @@ import Bedrock
|
|||||||
struct TodayView: View {
|
struct TodayView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
|
|
||||||
/// Rituals to show today: enabled, not archived, and appropriate for current time
|
/// Rituals to show now based on current time of day
|
||||||
private var todayRituals: [Ritual] {
|
private var todayRituals: [Ritual] {
|
||||||
store.ritualsForCurrentTime()
|
store.ritualsForToday()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether there are active rituals but none for the current time
|
||||||
|
private var hasRitualsButNotNow: Bool {
|
||||||
|
todayRituals.isEmpty && !store.currentRituals.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to show the renewal sheet
|
||||||
|
private var showRenewalSheet: Bool {
|
||||||
|
store.ritualNeedingRenewal != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -15,7 +25,13 @@ struct TodayView: View {
|
|||||||
TodayHeaderView(dateText: store.todayDisplayString)
|
TodayHeaderView(dateText: store.todayDisplayString)
|
||||||
|
|
||||||
if todayRituals.isEmpty {
|
if todayRituals.isEmpty {
|
||||||
TodayEmptyStateView(store: store)
|
if hasRitualsButNotNow {
|
||||||
|
// Has active rituals but none for current time of day
|
||||||
|
TodayNoRitualsForTimeView(store: store)
|
||||||
|
} else {
|
||||||
|
// No active rituals at all
|
||||||
|
TodayEmptyStateView(store: store)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ForEach(todayRituals) { ritual in
|
ForEach(todayRituals) { ritual in
|
||||||
TodayRitualSectionView(
|
TodayRitualSectionView(
|
||||||
@ -38,6 +54,17 @@ struct TodayView: View {
|
|||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
))
|
))
|
||||||
|
.onAppear {
|
||||||
|
store.checkForCompletedArcs()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: .init(
|
||||||
|
get: { showRenewalSheet },
|
||||||
|
set: { if !$0 { store.dismissRenewalPrompt() } }
|
||||||
|
)) {
|
||||||
|
if let ritual = store.ritualNeedingRenewal {
|
||||||
|
ArcRenewalSheet(store: store, ritual: ritual)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func habitRows(for ritual: Ritual) -> [HabitRowModel] {
|
private func habitRows(for ritual: Ritual) -> [HabitRowModel] {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Andromida
|
@testable import Andromida
|
||||||
@ -24,10 +25,36 @@ struct RitualStoreTests {
|
|||||||
store.toggleHabitCompletion(habit)
|
store.toggleHabitCompletion(habit)
|
||||||
#expect(store.isHabitCompletedToday(habit) == true)
|
#expect(store.isHabitCompletedToday(habit) == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func arcRenewalCreatesNewArc() throws {
|
||||||
|
let store = makeStore()
|
||||||
|
store.createQuickRitual()
|
||||||
|
|
||||||
|
guard let ritual = store.activeRitual else {
|
||||||
|
throw TestError.missingHabit
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(ritual.arcs.count == 1)
|
||||||
|
#expect(ritual.currentArc?.arcNumber == 1)
|
||||||
|
|
||||||
|
// End the current arc
|
||||||
|
store.endArc(for: ritual)
|
||||||
|
|
||||||
|
#expect(ritual.currentArc == nil)
|
||||||
|
|
||||||
|
// Renew the arc
|
||||||
|
store.renewArc(for: ritual, durationDays: 30, copyHabits: true)
|
||||||
|
|
||||||
|
#expect(ritual.arcs.count == 2)
|
||||||
|
#expect(ritual.currentArc?.arcNumber == 2)
|
||||||
|
#expect(ritual.currentArc?.habits.count == 3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private func makeStore() -> RitualStore {
|
private func makeStore() -> RitualStore {
|
||||||
let schema = Schema([Ritual.self, Habit.self])
|
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
do {
|
do {
|
||||||
@ -36,7 +63,7 @@ private func makeStore() -> RitualStore {
|
|||||||
fatalError("Test container failed: \(error)")
|
fatalError("Test container failed: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return RitualStore(modelContext: container.mainContext, seedService: EmptySeedService())
|
return RitualStore(modelContext: container.mainContext, seedService: EmptySeedService(), settingsStore: SettingsStore())
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct EmptySeedService: RitualSeedProviding {
|
private struct EmptySeedService: RitualSeedProviding {
|
||||||
|
|||||||
115
README.md
115
README.md
@ -1,29 +1,87 @@
|
|||||||
# Rituals (Andromida)
|
# Rituals (Andromida)
|
||||||
|
|
||||||
Rituals is a paid, offline-first habit tracker built around 4-week "ritual" arcs. It focuses on steady, daily check-ins with a calm visual language, zero paid backend dependencies, and optional iCloud sync for settings.
|
Rituals is a paid, offline-first habit tracker built around customizable "ritual" arcs. It focuses on steady, daily check-ins with a calm visual language, zero paid backend dependencies, and optional iCloud sync for settings.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
- **Concept**: Habits are grouped into 4-week ritual arcs ("chapters") rather than endless streaks.
|
- **Concept**: Habits are grouped into ritual arcs (7-365 days) rather than endless streaks.
|
||||||
- **Tech**: SwiftUI + SwiftData, Clean Architecture layering, Bedrock design system.
|
- **Tech**: SwiftUI + SwiftData, Clean Architecture layering, Bedrock design system.
|
||||||
- **Data**: Local persistence with SwiftData; settings sync via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore).
|
- **Data**: Local persistence with SwiftData; settings sync via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore).
|
||||||
- **No paid APIs**: No external services required.
|
- **No paid APIs**: No external services required.
|
||||||
|
|
||||||
## Feature Set
|
## Feature Set
|
||||||
|
|
||||||
- **Today dashboard**: Focus ritual, progress ring, and tap-to-complete habits.
|
### Today Tab
|
||||||
- **Ritual library**: View active and recent rituals.
|
- Focus ritual cards with progress rings
|
||||||
- **Ritual detail**: Full ritual summary + habit check-ins.
|
- Tap-to-complete habit check-ins with haptic/sound feedback
|
||||||
- **Insights**: Lightweight metrics generated locally.
|
- Time-of-day filtering (morning/evening/anytime rituals)
|
||||||
- **Settings**:
|
- Smart empty states (distinguishes "no rituals" from "no rituals for this time")
|
||||||
- Reminders, haptics, sound toggles
|
- Fresh install starts clean (no pre-seeded rituals)
|
||||||
- Ritual pacing options (focus style + length)
|
|
||||||
- iCloud settings sync
|
### Rituals Tab
|
||||||
- DEBUG tools for icon generation and branding preview
|
- View all active rituals
|
||||||
- **Branding**:
|
- Create new rituals from scratch or browse preset library
|
||||||
- Bedrock AppLaunchView with custom theme
|
- 13 categorized presets (Health, Productivity, Mindfulness, Self-Care)
|
||||||
- Native LaunchScreen.storyboard to prevent flash
|
- Full ritual management: edit, enable/disable, archive, delete
|
||||||
- Centralized branding config (colors, icons, launch)
|
- Drag-to-reorder habits within rituals
|
||||||
|
|
||||||
|
### Ritual Detail View
|
||||||
|
- Progress card with day count and completion summary
|
||||||
|
- Time remaining countdown ("12 days remaining")
|
||||||
|
- Ritual-specific streak tracking
|
||||||
|
- Milestone achievements (Day 1, Week 1, Halfway, Complete)
|
||||||
|
- Habit performance breakdown with completion rates
|
||||||
|
- Status badges (time of day, category, enabled/disabled)
|
||||||
|
- Action menu for edit, archive, and delete
|
||||||
|
|
||||||
|
### Ritual Editor
|
||||||
|
- Custom ritual creation with title, theme, notes
|
||||||
|
- Icon picker with 50+ categorized SF Symbols and search
|
||||||
|
- Habit icon picker with 100+ icons organized by category
|
||||||
|
- Custom category input (beyond preset categories)
|
||||||
|
- Flexible duration: slider (7-365 days) + quick presets + custom input
|
||||||
|
- Time-of-day scheduling (morning, evening, anytime)
|
||||||
|
- Drag-to-reorder habits
|
||||||
|
|
||||||
|
### History Tab
|
||||||
|
- Scrollable month calendar grid
|
||||||
|
- Daily progress rings with color coding (green=100%, accent=50%+, gray=<50%)
|
||||||
|
- Filter by ritual using horizontal pill picker
|
||||||
|
- Tap any day for detail sheet showing:
|
||||||
|
- Progress ring with percentage
|
||||||
|
- Comparison to weekly average
|
||||||
|
- Streak context (if part of a streak)
|
||||||
|
- Motivational message
|
||||||
|
- Grouped habit list by ritual
|
||||||
|
|
||||||
|
### Insights Tab
|
||||||
|
- Tappable insight cards with detail sheets
|
||||||
|
- **Active Rituals**: Count with per-ritual breakdown
|
||||||
|
- **Streak**: Current and longest streak tracking
|
||||||
|
- **Habits Today**: Completed count with per-ritual breakdown
|
||||||
|
- **Completion**: Today's percentage with 7-day trend chart
|
||||||
|
- **Days Active**: Total active days with detailed breakdown (first check-in, most recent, per-ritual counts)
|
||||||
|
- Trend indicators (up/down/stable) with week-over-week comparison
|
||||||
|
- Contextual tips based on performance
|
||||||
|
|
||||||
|
### Settings Tab
|
||||||
|
- Daily reminder notifications with time picker
|
||||||
|
- Haptics and sound toggles (wired to habit check-ins)
|
||||||
|
- Ritual length default setting
|
||||||
|
- iCloud settings sync
|
||||||
|
- Pro upgrade placeholder
|
||||||
|
- Debug tools: reset onboarding, app icon generation, branding preview
|
||||||
|
|
||||||
|
### Onboarding
|
||||||
|
- Sherpa-powered walkthrough on first launch
|
||||||
|
- Highlights focus ritual card and habit check-in flow
|
||||||
|
- Debug reset available in Settings
|
||||||
|
|
||||||
|
### Branding & Launch
|
||||||
|
- Bedrock AppLaunchView with custom theme
|
||||||
|
- Native LaunchScreen.storyboard (no white flash)
|
||||||
|
- Dark theme enforced throughout
|
||||||
|
- Centralized branding config (colors, icons, launch)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@ -42,10 +100,28 @@ Andromida/
|
|||||||
├── Andromida/ # App target
|
├── Andromida/ # App target
|
||||||
│ ├── App/
|
│ ├── App/
|
||||||
│ │ ├── Models/ # SwiftData + DTOs
|
│ │ ├── Models/ # SwiftData + DTOs
|
||||||
|
│ │ │ ├── Ritual.swift
|
||||||
|
│ │ │ ├── Habit.swift
|
||||||
|
│ │ │ ├── HabitCompletion.swift
|
||||||
|
│ │ │ ├── InsightCard.swift
|
||||||
|
│ │ │ ├── Milestone.swift
|
||||||
|
│ │ │ ├── TrendDirection.swift
|
||||||
|
│ │ │ └── RitualPresets.swift
|
||||||
│ │ ├── Protocols/ # Interfaces for stores/services
|
│ │ ├── Protocols/ # Interfaces for stores/services
|
||||||
|
│ │ │ ├── RitualStoreProviding.swift
|
||||||
|
│ │ │ ├── RitualSeedProviding.swift
|
||||||
|
│ │ │ └── InsightTipsProviding.swift
|
||||||
│ │ ├── Services/ # Stateless logic
|
│ │ ├── Services/ # Stateless logic
|
||||||
|
│ │ │ └── RitualSeedService.swift
|
||||||
│ │ ├── State/ # @Observable stores
|
│ │ ├── State/ # @Observable stores
|
||||||
|
│ │ │ ├── RitualStore.swift
|
||||||
|
│ │ │ └── SettingsStore.swift
|
||||||
│ │ └── Views/ # SwiftUI features + components
|
│ │ └── Views/ # SwiftUI features + components
|
||||||
|
│ │ ├── Today/
|
||||||
|
│ │ ├── Rituals/
|
||||||
|
│ │ ├── History/
|
||||||
|
│ │ ├── Insights/
|
||||||
|
│ │ └── Settings/
|
||||||
│ ├── Shared/ # Bedrock theme + branding config
|
│ ├── Shared/ # Bedrock theme + branding config
|
||||||
│ └── Resources/ # LaunchScreen.storyboard
|
│ └── Resources/ # LaunchScreen.storyboard
|
||||||
├── AndromidaTests/ # Unit tests
|
├── AndromidaTests/ # Unit tests
|
||||||
@ -64,8 +140,9 @@ Andromida/
|
|||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
- **Ritual**: Title, theme, start date, duration (days), notes, habits
|
- **Ritual**: Title, theme, start date, duration (days), notes, habits, isEnabled, isArchived, timeOfDay, iconName, category
|
||||||
- **Habit**: Title, symbol, goal, completion by day IDs
|
- **Habit**: Title, symbol, completedDayIDs
|
||||||
|
- **Milestone**: Day, title, symbol, isAchieved
|
||||||
- **Settings**: Stored via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore)
|
- **Settings**: Stored via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore)
|
||||||
|
|
||||||
## Bedrock Integration
|
## Bedrock Integration
|
||||||
@ -74,6 +151,7 @@ Andromida/
|
|||||||
- **Branding**: AppLaunchView, AppIconConfig, LaunchScreenConfig
|
- **Branding**: AppLaunchView, AppIconConfig, LaunchScreenConfig
|
||||||
- **Settings UI**: SettingsToggle, SettingsSlider, SettingsSegmentedPicker, SettingsCard
|
- **Settings UI**: SettingsToggle, SettingsSlider, SettingsSegmentedPicker, SettingsCard
|
||||||
- **Cloud Sync**: iCloud sync for settings using CloudSyncManager
|
- **Cloud Sync**: iCloud sync for settings using CloudSyncManager
|
||||||
|
- **Onboarding**: Sherpa walkthrough system
|
||||||
|
|
||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
@ -95,10 +173,11 @@ String catalogs are used for English, Spanish (Mexico), and French (Canada):
|
|||||||
|
|
||||||
- Unit tests in `AndromidaTests/`
|
- Unit tests in `AndromidaTests/`
|
||||||
- Run via Xcode Test navigator or:
|
- Run via Xcode Test navigator or:
|
||||||
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 15'`
|
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'`
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- App is configured with a dark theme; the root view enforces `.preferredColorScheme(.dark)` to ensure semantic text legibility.
|
- App is configured with a dark theme; the root view enforces `.preferredColorScheme(.dark)` to ensure semantic text legibility.
|
||||||
- The launch storyboard matches the branding primary color to avoid a white flash.
|
- The launch storyboard matches the branding primary color to avoid a white flash.
|
||||||
- App icon generation is available in DEBUG builds from Settings.
|
- App icon generation is available in DEBUG builds from Settings.
|
||||||
|
- Fresh installs start with no rituals; users create their own from scratch or presets.
|
||||||
|
|||||||
37
TODO.md
37
TODO.md
@ -14,6 +14,8 @@
|
|||||||
## 3) Today tab UX polish
|
## 3) Today tab UX polish
|
||||||
- [x] Re-add accessibility value/hint for habit rows once Sherpa-related ambiguity is resolved.
|
- [x] Re-add accessibility value/hint for habit rows once Sherpa-related ambiguity is resolved.
|
||||||
- [x] Confirm focus ritual card and habit rows still match the intended visual hierarchy after refactors.
|
- [x] Confirm focus ritual card and habit rows still match the intended visual hierarchy after refactors.
|
||||||
|
- [x] Smart empty states: distinguish "no rituals" vs "no rituals for current time of day".
|
||||||
|
- [x] Fresh install starts clean (no pre-seeded rituals).
|
||||||
|
|
||||||
## 4) Settings & product readiness
|
## 4) Settings & product readiness
|
||||||
- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements.
|
- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements.
|
||||||
@ -26,6 +28,7 @@
|
|||||||
## 5) Data & defaults
|
## 5) Data & defaults
|
||||||
- [x] Confirm seed ritual creation and quick ritual creation behave as expected.
|
- [x] Confirm seed ritual creation and quick ritual creation behave as expected.
|
||||||
- [x] Validate SwiftData sync (if enabled) doesn't require any external API.
|
- [x] Validate SwiftData sync (if enabled) doesn't require any external API.
|
||||||
|
- [x] Remove automatic seed rituals on fresh install.
|
||||||
|
|
||||||
## 6) QA checklist
|
## 6) QA checklist
|
||||||
- [x] First-launch walkthrough appears on a clean install.
|
- [x] First-launch walkthrough appears on a clean install.
|
||||||
@ -33,15 +36,19 @@
|
|||||||
- [x] No build warnings or Swift compiler crashes.
|
- [x] No build warnings or Swift compiler crashes.
|
||||||
- [x] iPhone 17 Pro Max simulator layout verified on Today, Rituals, Insights, Settings.
|
- [x] iPhone 17 Pro Max simulator layout verified on Today, Rituals, Insights, Settings.
|
||||||
|
|
||||||
## 7) Future enhancements
|
## 7) Completed enhancements
|
||||||
- [ ] **HealthKit integration** – Sync habit completions (water, mindfulness, exercise) to Apple Health. See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
|
- [x] **History view** – View past/completed rituals with completion percentages.
|
||||||
- [x] **History view** – View past/completed rituals with completion percentages. See plan: `.cursor/plans/calendar_history_view_88026c7b.plan.md`
|
|
||||||
- [x] Scrollable month calendar grid
|
- [x] Scrollable month calendar grid
|
||||||
- [x] Daily progress rings with color coding
|
- [x] Daily progress rings with color coding
|
||||||
- [x] Filter by ritual using horizontal pill picker
|
- [x] Filter by ritual using horizontal pill picker
|
||||||
- [x] Tap day for detail sheet with habit list
|
- [x] Tap day for detail sheet with habit list
|
||||||
- [x] New History tab in tab bar
|
- [x] New History tab in tab bar
|
||||||
- [x] **Ritual management** – Create, edit, delete, and archive rituals. See plan: `.cursor/plans/ritual_management_system_1496c6a9.plan.md`
|
- [x] Percentage display inside progress ring
|
||||||
|
- [x] Comparison to weekly average badge
|
||||||
|
- [x] Streak context badge
|
||||||
|
- [x] Motivational messages
|
||||||
|
|
||||||
|
- [x] **Ritual management** – Create, edit, delete, and archive rituals.
|
||||||
- [x] Model enhancements (isEnabled, isArchived, timeOfDay, iconName, category)
|
- [x] Model enhancements (isEnabled, isArchived, timeOfDay, iconName, category)
|
||||||
- [x] RitualStore CRUD methods (create, update, delete, enable, archive)
|
- [x] RitualStore CRUD methods (create, update, delete, enable, archive)
|
||||||
- [x] Preset library with 13 categorized presets (Health, Productivity, Mindfulness, Self-Care)
|
- [x] Preset library with 13 categorized presets (Health, Productivity, Mindfulness, Self-Care)
|
||||||
@ -51,11 +58,31 @@
|
|||||||
- [x] RitualDetailView action menu (Edit, Enable/Disable, Archive, Delete)
|
- [x] RitualDetailView action menu (Edit, Enable/Disable, Archive, Delete)
|
||||||
- [x] Destructive action confirmations with history warning
|
- [x] Destructive action confirmations with history warning
|
||||||
- [x] Today view filtering by isEnabled, isArchived, and timeOfDay
|
- [x] Today view filtering by isEnabled, isArchived, and timeOfDay
|
||||||
- [x] **Insights enhancements** – Weekly/monthly trends, streak data, charts. See plan: `.cursor/plans/insights_overhaul_50b59fa7.plan.md`
|
- [x] Custom category input (beyond preset categories)
|
||||||
|
- [x] Habit icon picker with 100+ icons, search, and categories
|
||||||
|
- [x] Flexible duration: slider (7-365 days) + quick presets + custom input
|
||||||
|
- [x] Drag-to-reorder habits
|
||||||
|
|
||||||
|
- [x] **Ritual detail enhancements**
|
||||||
|
- [x] Time remaining countdown
|
||||||
|
- [x] Ritual-specific streak tracking
|
||||||
|
- [x] Milestone achievements (Day 1, Week 1, Halfway, Complete)
|
||||||
|
- [x] Habit performance breakdown with completion rates
|
||||||
|
|
||||||
|
- [x] **Insights enhancements** – Weekly/monthly trends, streak data, charts.
|
||||||
- [x] Tappable insight cards with detail sheets
|
- [x] Tappable insight cards with detail sheets
|
||||||
- [x] Explanations for each metric
|
- [x] Explanations for each metric
|
||||||
- [x] Per-ritual breakdowns
|
- [x] Per-ritual breakdowns
|
||||||
- [x] Streak tracking (current & longest)
|
- [x] Streak tracking (current & longest)
|
||||||
- [x] 7-day trend chart with sparkline preview
|
- [x] 7-day trend chart with sparkline preview
|
||||||
|
- [x] Trend indicators (up/down/stable) with week-over-week comparison
|
||||||
|
- [x] Contextual tips based on performance
|
||||||
|
- [x] Days Active breakdown showing calculation details
|
||||||
|
|
||||||
|
## 8) Future enhancements
|
||||||
|
- [ ] **HealthKit integration** – Sync habit completions (water, mindfulness, exercise) to Apple Health. See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
|
||||||
- [ ] **Widget** – Home screen widget showing today's progress.
|
- [ ] **Widget** – Home screen widget showing today's progress.
|
||||||
- [ ] **Watch app** – Companion app for quick habit check-ins.
|
- [ ] **Watch app** – Companion app for quick habit check-ins.
|
||||||
|
- [ ] **Notifications** – Smart reminders based on habit completion patterns.
|
||||||
|
- [ ] **Export/Import** – Backup and restore ritual data.
|
||||||
|
- [ ] **Statistics** – Monthly/yearly summary views.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user