From 845abfd24dae374ba3740113ce8b0fe666cc6dac Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 25 Jan 2026 23:09:16 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida/AndromidaApp.swift | 3 +- .../App/Localization/Localizable.xcstrings | 335 +++++++++- Andromida/App/Models/ArcHabit.swift | 41 ++ Andromida/App/Models/Habit.swift | 28 - Andromida/App/Models/HabitCompletion.swift | 2 +- Andromida/App/Models/Ritual.swift | 159 ++++- Andromida/App/Models/RitualArc.swift | 101 +++ Andromida/App/Models/RitualPresets.swift | 2 +- .../App/Protocols/RitualStoreProviding.swift | 19 +- .../App/Services/RitualSeedService.swift | 48 +- Andromida/App/State/RitualStore+Preview.swift | 2 +- Andromida/App/State/RitualStore.swift | 582 ++++++++++++------ .../Components/HabitPerformanceView.swift | 12 +- .../Rituals/Components/RitualCardView.swift | 101 ++- .../App/Views/Rituals/RitualDetailView.swift | 328 +++++++--- Andromida/App/Views/Rituals/RitualsView.swift | 307 +++++---- .../Rituals/Sheets/ArcRenewalSheet.swift | 220 +++++++ .../Rituals/Sheets/RitualEditSheet.swift | 405 ++++++++++-- .../App/Views/Settings/SettingsView.swift | 8 + .../Components/TodayEmptyStateView.swift | 72 ++- .../TodayNoRitualsForTimeView.swift | 101 +++ Andromida/App/Views/Today/TodayView.swift | 33 +- AndromidaTests/RitualStoreTests.swift | 31 +- README.md | 115 +++- TODO.md | 37 +- 25 files changed, 2481 insertions(+), 611 deletions(-) create mode 100644 Andromida/App/Models/ArcHabit.swift delete mode 100644 Andromida/App/Models/Habit.swift create mode 100644 Andromida/App/Models/RitualArc.swift create mode 100644 Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift create mode 100644 Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index f8573e4..0cf5257 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -10,7 +10,8 @@ struct AndromidaApp: App { @State private var settingsStore: SettingsStore 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 container: ModelContainer do { diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index 5533ecd..9d49164 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -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.", "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" : { "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, @@ -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.", "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" : { "comment" : "A label displaying the duration of a preset in days. The argument is the number of days the preset is active.", "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.", "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!" : { "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 @@ -86,6 +114,10 @@ "comment" : "A value describing the completion rate of a habit. The value is a percentage.", "isCommentAutoGenerated" : true }, + "%lld total" : { + "comment" : "The subtitle of the \"Arc History\" section in the Ritual Detail view.", + "isCommentAutoGenerated" : true + }, "%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.", "isCommentAutoGenerated" : true @@ -94,6 +126,18 @@ "comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.", "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" : { "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 @@ -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.", "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" : { "comment" : "Title of a habit preset within a mindfulness ritual preset.", "isCommentAutoGenerated" : true }, + "5pm – 9pm" : { + "comment" : "Time range description for the \"Evening\" time of day.", + "isCommentAutoGenerated" : true + }, "7-Day Trend" : { "comment" : "A heading for the 7-day trend section of an insight detail sheet.", "isCommentAutoGenerated" : true }, + "11am – 2pm" : { + "comment" : "Time range description for the \"Midday\" time of day.", + "isCommentAutoGenerated" : true + }, "21 days is when habits start to stick." : { "comment" : "Tip text indicating that 21 days is when habits start to stick.", "isCommentAutoGenerated" : true @@ -299,13 +363,28 @@ "Advanced Insights" : { "comment" : "Subtitle for a feature in the \"Rituals Pro\" upgrade screen, related to \"Advanced Insights\".", "isCommentAutoGenerated" : true + }, + "After 9pm" : { + "comment" : "Time range description for the \"Night\" time of day.", + "isCommentAutoGenerated" : true + }, + "Afternoon" : { + }, "All" : { "comment" : "Title for the \"All\" option in the history ritual filter picker.", "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." : { + }, + "Always visible" : { + "comment" : "Combined display name with time range for rituals that can be performed at any time.", + "isCommentAutoGenerated" : true }, "Anytime" : { "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.", "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!" : { "comment" : "A message displayed when a ritual's streak reaches its maximum value.", "isCommentAutoGenerated" : true }, - "Archive" : { - "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 + "Arc History" : { + }, "Balanced daily check-ins" : { "comment" : "Description of what the \"Steady\" focus style means for the user.", "isCommentAutoGenerated" : true }, + "Before 11am" : { + "comment" : "Time range description for the \"Morning\" time of day.", + "isCommentAutoGenerated" : true + }, "Begin a four-week arc" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -459,6 +542,7 @@ } }, "Choose a theme and keep your focus clear for 28 days." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -480,6 +564,10 @@ } } }, + "Choose Habit Icon" : { + "comment" : "The title of the icon picker sheet.", + "isCommentAutoGenerated" : true + }, "Choose Icon" : { "comment" : "The title of the icon picker sheet.", "isCommentAutoGenerated" : true @@ -545,6 +633,10 @@ "comment" : "A label indicating that a feature is coming soon.", "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" : { "comment" : "Title of the final milestone in a 28-day ritual arc.", "isCommentAutoGenerated" : true @@ -552,6 +644,9 @@ "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.", "isCommentAutoGenerated" : true + }, + "Complete First Active Arc (Test Renewal)" : { + }, "Completed" : { "localizations" : { @@ -608,6 +703,14 @@ "comment" : "Tip to consider focusing on fewer habits for better consistency.", "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" : { "comment" : "A button label that says \"Create\".", "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.", "isCommentAutoGenerated" : true }, + "Create Custom Ritual" : { + "comment" : "A button label that triggers the creation of a custom ritual.", + "isCommentAutoGenerated" : true + }, "Create New" : { "comment" : "A button label that suggests creating a new ritual.", "isCommentAutoGenerated" : true }, "Create ritual" : { + "extractionState" : "stale", "localizations" : { "en" : { "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" : { "comment" : "Label for a breakdown item in the \"Streak\" insight card, indicating the current streak of consecutive days with 100% habit completion.", "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" : { "localizations" : { "en" : { @@ -838,14 +954,6 @@ "comment" : "Habit title for dimming lights one hour before bedtime.", "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" : { "comment" : "Theme for the \"Digital Detox\" ritual preset.", "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" : { "comment" : "Title of a habit within a preset ritual.", "isCommentAutoGenerated" : true @@ -953,13 +1065,37 @@ "comment" : "The title of the navigation bar for editing a ritual.", "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" : { "comment" : "Title of a ritual preset that encourages reviewing completed tasks and planning for the next day.", "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" : { "comment" : "Description of a ritual scheduling option when the ritual should be visible in the Today view after 5pm.", "isCommentAutoGenerated" : true @@ -1032,6 +1168,10 @@ "comment" : "Title of a habit in a ritual preset focused on practicing gratitude.", "isCommentAutoGenerated" : true }, + "First check-in" : { + "comment" : "Label for the first check-in date in the \"Days Active\" breakdown.", + "isCommentAutoGenerated" : true + }, "First Day" : { "comment" : "Title of the first milestone in a 28-day ritual arc.", "isCommentAutoGenerated" : true @@ -1457,6 +1597,18 @@ "comment" : "Habit title for keeping the bedroom cool at night.", "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 %@" : { "extractionState" : "stale", "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" : { "comment" : "Habit title for a mindfulness ritual where the user lets go of their worries or stresses of the day.", "isCommentAutoGenerated" : true @@ -1492,6 +1648,10 @@ "comment" : "Label for the longest streak breakdown item.", "isCommentAutoGenerated" : true }, + "Midday" : { + "comment" : "Description of a ritual is typically performed during the day.", + "isCommentAutoGenerated" : true + }, "Midday Movement" : { "comment" : "Title of a ritual preset that encourages regular physical activity during the day.", "isCommentAutoGenerated" : true @@ -1582,6 +1742,10 @@ "comment" : "Title of a ritual preset focused on skincare.", "isCommentAutoGenerated" : true }, + "Most recent" : { + "comment" : "A description of the most recent check-in date.", + "isCommentAutoGenerated" : true + }, "Move" : { "localizations" : { "en" : { @@ -1608,15 +1772,44 @@ "comment" : "The title of the view when creating a new ritual.", "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" : { "comment" : "Habit title for not consuming caffeine after 2 PM.", "isCommentAutoGenerated" : true }, + "No completed arcs yet." : { + "comment" : "A message displayed when a ritual has no completed arcs.", + "isCommentAutoGenerated" : true + }, "No habits tracked" : { "comment" : "A message displayed when a user has not tracked any habits on a given day.", "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" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1638,8 +1831,8 @@ } } }, - "No Rituals Yet" : { - "comment" : "A message displayed when a user has not created any rituals yet.", + "No rituals scheduled for %@." : { + "comment" : "A message indicating that there are no rituals scheduled for the current time of day.", "isCommentAutoGenerated" : true }, "No screens" : { @@ -1732,6 +1925,14 @@ "comment" : "Title for a milestone that occurs one week into a ritual arc.", "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." : { "comment" : "A motivational message displayed when a user achieves 100% completion on a ritual.", "isCommentAutoGenerated" : true @@ -1915,8 +2116,8 @@ "comment" : "Notes for a ritual preset focused on sleep preparation.", "isCommentAutoGenerated" : true }, - "Restore" : { - "comment" : "A button label that restores a deleted or archived ritual.", + "Restart" : { + "comment" : "A button label that says \"Restart\".", "isCommentAutoGenerated" : true }, "Review completed tasks" : { @@ -1957,6 +2158,10 @@ "comment" : "Explanation of the insight card titled \"Active\".", "isCommentAutoGenerated" : true }, + "Ritual Complete" : { + "comment" : "The title of the view that appears when a ritual's arc completes.", + "isCommentAutoGenerated" : true + }, "Ritual days" : { "extractionState" : "stale", "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." : { "localizations" : { "en" : { @@ -2102,6 +2311,10 @@ "comment" : "A description of what \"Rituals Pro\" offers.", "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" : { "comment" : "The text for a button that saves data.", "isCommentAutoGenerated" : true @@ -2110,6 +2323,14 @@ "comment" : "A label displayed above the ritual's scheduling information.", "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" : { "comment" : "Habit title for a ritual preset focused on setting an intention for the day.", "isCommentAutoGenerated" : true @@ -2243,6 +2464,22 @@ "comment" : "Description of a trend direction when there is no significant change.", "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" : { "comment" : "Theme of the \"Morning Meditation\" ritual preset.", "isCommentAutoGenerated" : true @@ -2252,6 +2489,7 @@ "isCommentAutoGenerated" : true }, "Start your first ritual" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2535,6 +2773,10 @@ "comment" : "A hint that appears when a user taps on an element to learn more about it.", "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" : { "localizations" : { "en" : { @@ -2565,10 +2807,6 @@ "comment" : "Habit title for a ritual preset focused on gratitude practice.", "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." : { "comment" : "Explanation of the value for the Insight Card titled \"Habits today\".", "isCommentAutoGenerated" : true @@ -2577,12 +2815,20 @@ "comment" : "A label for an optional tagline or theme associated with a ritual.", "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." : { "comment" : "A description displayed when a day has no habit completion data.", "isCommentAutoGenerated" : true }, - "This ritual will be hidden from your active list but its history will be preserved." : { - "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.", + "This ritual is not currently active" : { + "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 }, "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.", "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" : { "comment" : "Title of a milestone that is achieved after three weeks of a ritual journey.", "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" : { "extractionState" : "stale", "localizations" : { @@ -2705,10 +2959,18 @@ "comment" : "Accessibility label for a trend direction indicating an increase.", "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." : { "comment" : "Tip to start with a single habit to build momentum.", "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" : { "comment" : "Description of a feature in the \"Pro\" upgrade section, describing that users can create as many rituals as they need.", "isCommentAutoGenerated" : true @@ -2731,6 +2993,10 @@ }, "Vibrate when completing habits" : { + }, + "View" : { + "comment" : "A label describing the segmented control in the rituals view.", + "isCommentAutoGenerated" : true }, "Weekly completion chart" : { "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.", "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." : { "comment" : "Tip provided when the user is at their longest streak and it is greater than zero.", "isCommentAutoGenerated" : true }, "Your active and recent arcs" : { + "extractionState" : "stale", "localizations" : { "en" : { "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" : { "comment" : "Subtitle for the History view, describing what the view is about.", "isCommentAutoGenerated" : true diff --git a/Andromida/App/Models/ArcHabit.swift b/Andromida/App/Models/ArcHabit.swift new file mode 100644 index 0000000..9e64b3c --- /dev/null +++ b/Andromida/App/Models/ArcHabit.swift @@ -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: [] + ) + } +} diff --git a/Andromida/App/Models/Habit.swift b/Andromida/App/Models/Habit.swift deleted file mode 100644 index ee24d19..0000000 --- a/Andromida/App/Models/Habit.swift +++ /dev/null @@ -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 - } -} diff --git a/Andromida/App/Models/HabitCompletion.swift b/Andromida/App/Models/HabitCompletion.swift index c519d9d..efcc8cd 100644 --- a/Andromida/App/Models/HabitCompletion.swift +++ b/Andromida/App/Models/HabitCompletion.swift @@ -10,7 +10,7 @@ import Foundation /// Represents a habit's completion status with its ritual context. struct HabitCompletion: Identifiable { var id: UUID { habit.id } - let habit: Habit + let habit: ArcHabit let ritualTitle: String let isCompleted: Bool } diff --git a/Andromida/App/Models/Ritual.swift b/Andromida/App/Models/Ritual.swift index c72bef1..3ddf5dc 100644 --- a/Andromida/App/Models/Ritual.swift +++ b/Andromida/App/Models/Ritual.swift @@ -1,43 +1,85 @@ import Foundation import SwiftData -/// Represents when a ritual should appear in the Today view. -enum TimeOfDay: String, Codable, CaseIterable { - case morning // Before noon - case evening // After 5pm - case anytime // Always visible +/// Represents when a ritual is typically performed during the day. +/// Used for sorting and display purposes. +enum TimeOfDay: String, Codable, CaseIterable, Comparable { + case morning // Before 11am + case midday // 11am - 2pm + case afternoon // 2pm - 5pm + case evening // 5pm - 9pm + case night // After 9pm + case anytime // Flexible timing var displayName: String { switch self { 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 .night: return String(localized: "Night") 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 { switch self { 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" } } + + /// 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 final class Ritual { var id: UUID var title: String var theme: String - var startDate: Date - var durationDays: Int - @Relationship(deleteRule: .cascade) - var habits: [Habit] var notes: String - // Management - var isEnabled: Bool - var isArchived: Bool + // Default duration for new arcs + var defaultDurationDays: Int // Scheduling var timeOfDay: TimeOfDay @@ -45,32 +87,101 @@ final class Ritual { // Organization var iconName: String var category: String + + // Arcs - each arc represents a time-bound period with its own habits + @Relationship(deleteRule: .cascade) + var arcs: [RitualArc] init( id: UUID = UUID(), title: String, theme: String, - startDate: Date = Date(), - durationDays: Int = 28, - habits: [Habit] = [], + defaultDurationDays: Int = 28, notes: String = "", - isEnabled: Bool = true, - isArchived: Bool = false, timeOfDay: TimeOfDay = .anytime, iconName: String = "sparkles", - category: String = "" + category: String = "", + arcs: [RitualArc] = [] ) { self.id = id self.title = title self.theme = theme - self.startDate = startDate - self.durationDays = durationDays - self.habits = habits + self.defaultDurationDays = defaultDurationDays self.notes = notes - self.isEnabled = isEnabled - self.isArchived = isArchived self.timeOfDay = timeOfDay self.iconName = iconName 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) -> [RitualArc] { + arcs.filter { arc in + // Arc overlaps if its range intersects with the query range + arc.endDate >= range.lowerBound && arc.startDate <= range.upperBound + } } } diff --git a/Andromida/App/Models/RitualArc.swift b/Andromida/App/Models/RitualArc.swift new file mode 100644 index 0000000..09a5416 --- /dev/null +++ b/Andromida/App/Models/RitualArc.swift @@ -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 + ) + } +} diff --git a/Andromida/App/Models/RitualPresets.swift b/Andromida/App/Models/RitualPresets.swift index 610daca..8d58838 100644 --- a/Andromida/App/Models/RitualPresets.swift +++ b/Andromida/App/Models/RitualPresets.swift @@ -79,7 +79,7 @@ enum RitualPresetLibrary { theme: String(localized: "Break up your day"), notes: String(localized: "Combat sedentary habits with midday activity."), durationDays: 28, - timeOfDay: .anytime, + timeOfDay: .midday, iconName: "figure.walk", category: PresetCategory.health.rawValue, habits: [ diff --git a/Andromida/App/Protocols/RitualStoreProviding.swift b/Andromida/App/Protocols/RitualStoreProviding.swift index 0289925..52f84c6 100644 --- a/Andromida/App/Protocols/RitualStoreProviding.swift +++ b/Andromida/App/Protocols/RitualStoreProviding.swift @@ -2,25 +2,36 @@ import Foundation protocol RitualStoreProviding { var rituals: [Ritual] { get } + var currentRituals: [Ritual] { get } + var pastRituals: [Ritual] { get } var activeRitual: Ritual? { get } var todayDisplayString: String { get } var activeRitualProgress: Double { get } func ritualProgress(for ritual: Ritual) -> Double - func habits(for ritual: Ritual) -> [Habit] - func isHabitCompletedToday(_ habit: Habit) -> Bool - func toggleHabitCompletion(_ habit: Habit) + func habits(for ritual: Ritual) -> [ArcHabit] + func isHabitCompletedToday(_ habit: ArcHabit) -> Bool + func toggleHabitCompletion(_ habit: ArcHabit) func ritualDayIndex(for ritual: Ritual) -> Int func ritualDayLabel(for ritual: Ritual) -> String func completionSummary(for ritual: Ritual) -> String func insightCards() -> [InsightCard] 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 func weeklyAverageForDate(_ date: Date) -> Double func streakIncluding(_ date: Date) -> Int? func daysRemaining(for 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 weekOverWeekChange() -> Double func motivationalMessage(for rate: Double) -> String diff --git a/Andromida/App/Services/RitualSeedService.swift b/Andromida/App/Services/RitualSeedService.swift index 33e1798..08106df 100644 --- a/Andromida/App/Services/RitualSeedService.swift +++ b/Andromida/App/Services/RitualSeedService.swift @@ -2,39 +2,53 @@ import Foundation struct RitualSeedService: RitualSeedProviding { func makeSeedRituals(startDate: Date) -> [Ritual] { + // Create morning ritual with arc let morningHabits = [ - Habit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), - Habit(title: String(localized: "Stretch"), symbolName: "figure.walk"), - Habit(title: String(localized: "Mindful minute"), symbolName: "sparkles") + ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), + ArcHabit(title: String(localized: "Stretch"), symbolName: "figure.walk"), + ArcHabit(title: String(localized: "Mindful minute"), symbolName: "sparkles") ] - let eveningHabits = [ - Habit(title: String(localized: "No screens"), symbolName: "moon.stars.fill"), - Habit(title: String(localized: "Read 10 pages"), symbolName: "book.fill"), - Habit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") - ] - + let morningArc = RitualArc( + startDate: startDate, + durationDays: 28, + arcNumber: 1, + isActive: true, + habits: morningHabits + ) let morningRitual = Ritual( title: String(localized: "Morning Clarity"), theme: String(localized: "Fresh starts"), - startDate: startDate, - durationDays: 28, - habits: morningHabits, + defaultDurationDays: 28, notes: String(localized: "A gentle 4-week arc for energy and focus."), timeOfDay: .morning, 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( title: String(localized: "Evening Reset"), theme: String(localized: "Soft landings"), - startDate: Calendar.current.date(byAdding: .day, value: -14, to: startDate) ?? startDate, - durationDays: 28, - habits: eveningHabits, + defaultDurationDays: 28, notes: String(localized: "Wind down with quiet, consistent cues."), timeOfDay: .evening, iconName: "moon.stars.fill", - category: String(localized: "Wellness") + category: String(localized: "Wellness"), + arcs: [eveningArc] ) return [morningRitual, eveningRitual] diff --git a/Andromida/App/State/RitualStore+Preview.swift b/Andromida/App/State/RitualStore+Preview.swift index 6c72cd1..73b90cc 100644 --- a/Andromida/App/State/RitualStore+Preview.swift +++ b/Andromida/App/State/RitualStore+Preview.swift @@ -3,7 +3,7 @@ import SwiftData extension 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 container: ModelContainer do { diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index a947fa9..556db5c 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -15,6 +15,9 @@ final class RitualStore: RitualStoreProviding { private(set) var rituals: [Ritual] = [] private(set) var lastErrorMessage: String? + + /// Ritual that needs renewal prompt (arc just completed) + var ritualNeedingRenewal: Ritual? init( modelContext: ModelContext, @@ -37,13 +40,11 @@ final class RitualStore: RitualStoreProviding { } var activeRitual: Ritual? { - let today = calendar.startOfDay(for: Date()) - let candidates = rituals.filter { ritual in - let start = calendar.startOfDay(for: ritual.startDate) - let end = calendar.date(byAdding: .day, value: ritual.durationDays - 1, to: start) ?? start - return today >= start && today <= end + // Return the first ritual with an active arc that covers today + currentRituals.first { ritual in + guard let arc = ritual.currentArc else { return false } + return arc.contains(date: Date()) } - return candidates.sorted { $0.startDate > $1.startDate }.first } var todayDisplayString: String { @@ -65,16 +66,16 @@ final class RitualStore: RitualStoreProviding { return Double(completed) / Double(habits.count) } - func habits(for ritual: Ritual) -> [Habit] { + func habits(for ritual: Ritual) -> [ArcHabit] { ritual.habits } - func isHabitCompletedToday(_ habit: Habit) -> Bool { + func isHabitCompletedToday(_ habit: ArcHabit) -> Bool { let dayID = dayIdentifier(for: Date()) return habit.completedDayIDs.contains(dayID) } - func toggleHabitCompletion(_ habit: Habit) { + func toggleHabitCompletion(_ habit: ArcHabit) { let dayID = dayIdentifier(for: Date()) let wasCompleted = habit.completedDayIDs.contains(dayID) @@ -94,10 +95,8 @@ final class RitualStore: RitualStoreProviding { } func ritualDayIndex(for ritual: Ritual) -> Int { - let start = calendar.startOfDay(for: ritual.startDate) - let today = calendar.startOfDay(for: Date()) - let delta = calendar.dateComponents([.day], from: start, to: today).day ?? 0 - return max(0, min(delta + 1, ritual.durationDays)) + guard let arc = ritual.currentArc else { return 0 } + return arc.dayIndex(for: Date()) } 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 - /// 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 { - 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 - var allDayIDs: Set = [] - for ritual in rituals { - for habit in ritual.habits { - allDayIDs.formUnion(habit.completedDayIDs) + // For each date, check if all habits in all active arcs were completed + var perfectDayIDs: Set = [] + + for date in activeDates { + 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 allDayIDs.filter { dayID in - rituals.allSatisfy { ritual in - ritual.habits.allSatisfy { habit in - habit.completedDayIDs.contains(dayID) - } - } - } + return perfectDayIDs } /// 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() { 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 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) + let rate = completionRate(for: date) dataPoints.append(TrendDataPoint( date: date, @@ -237,24 +361,75 @@ final class RitualStore: RitualStoreProviding { let sum = trend.reduce(0.0) { $0 + $1.value } 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] { - let totalHabits = rituals.flatMap { $0.habits }.count - let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count - let completionRate = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100) + // Only count habits from active arcs for today's stats + let activeHabitsToday = habitsActive(on: Date()) + 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 let daysActiveCount = datesWithActivity().count - // Build per-ritual progress breakdown - let ritualProgressBreakdown = rituals.map { ritual in - BreakdownItem( - label: ritual.title, - value: ritualDayLabel(for: ritual) - ) - } + // Count rituals with active arcs + let activeRitualCount = currentRituals.count - 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 return BreakdownItem( label: ritual.title, @@ -262,7 +437,6 @@ final class RitualStore: RitualStoreProviding { ) } - // Streak tracking let current = currentStreak() let longest = longestStreak() @@ -273,7 +447,6 @@ final class RitualStore: RitualStoreProviding { // Weekly trend let trendData = weeklyTrendData() - let weeklyAverage = weeklyAverageCompletion() let trendBreakdown = trendData.map { point in BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%") } @@ -281,11 +454,11 @@ final class RitualStore: RitualStoreProviding { return [ InsightCard( title: String(localized: "Active"), - value: "\(rituals.count)", + value: "\(activeRitualCount)", 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."), symbolName: "sparkles", - breakdown: rituals.map { BreakdownItem(label: $0.title, value: $0.theme) } + breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) } ), InsightCard( title: String(localized: "Streak"), @@ -305,7 +478,7 @@ final class RitualStore: RitualStoreProviding { ), InsightCard( title: String(localized: "Completion"), - value: "\(completionRate)%", + value: "\(completionRateValue)%", 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."), symbolName: "chart.bar.fill", @@ -316,53 +489,37 @@ final class RitualStore: RitualStoreProviding { title: String(localized: "Days Active"), value: "\(daysActiveCount)", 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", - breakdown: ritualProgressBreakdown + breakdown: daysActiveBreakdown() ) ] } func createQuickRitual() { let habits = [ - Habit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), - Habit(title: String(localized: "Move"), symbolName: "figure.walk"), - Habit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") + ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), + ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk"), + ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") ] + let arc = RitualArc( + startDate: Date(), + durationDays: Int(settingsStore.ritualLengthDays), + arcNumber: 1, + isActive: true, + habits: habits + ) let ritual = Ritual( title: String(localized: "Custom Ritual"), theme: String(localized: "Your next chapter"), - startDate: Date(), - durationDays: Int(settingsStore.ritualLengthDays), - habits: habits, - notes: String(localized: "A fresh ritual created from your focus today.") + defaultDurationDays: Int(settingsStore.ritualLengthDays), + notes: String(localized: "A fresh ritual created from your focus today."), + arcs: [arc] ) modelContext.insert(ritual) 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 func createRitual( title: String, @@ -372,18 +529,24 @@ final class RitualStore: RitualStoreProviding { timeOfDay: TimeOfDay = .anytime, iconName: String = "sparkles", category: String = "", - habits: [Habit] = [] + habits: [ArcHabit] = [] ) { + let arc = RitualArc( + startDate: Date(), + durationDays: durationDays, + arcNumber: 1, + isActive: true, + habits: habits + ) let ritual = Ritual( title: title, theme: theme, - startDate: Date(), - durationDays: durationDays, - habits: habits, + defaultDurationDays: durationDays, notes: notes, timeOfDay: timeOfDay, iconName: iconName, - category: category + category: category, + arcs: [arc] ) modelContext.insert(ritual) saveContext() @@ -392,7 +555,7 @@ final class RitualStore: RitualStoreProviding { /// Creates a ritual from a preset template func createRitualFromPreset(_ preset: RitualPreset) { let habits = preset.habits.map { habitPreset in - Habit(title: habitPreset.title, symbolName: habitPreset.symbolName) + ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName) } createRitual( title: preset.title, @@ -420,7 +583,7 @@ final class RitualStore: RitualStoreProviding { ritual.title = title ritual.theme = theme ritual.notes = notes - ritual.durationDays = durationDays + ritual.defaultDurationDays = durationDays ritual.timeOfDay = timeOfDay ritual.iconName = iconName ritual.category = category @@ -433,45 +596,26 @@ final class RitualStore: RitualStoreProviding { saveContext() } - /// Toggles whether a ritual is enabled (shows in Today view) - 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 + /// Adds a habit to the current arc of a ritual func addHabit(to ritual: Ritual, title: String, symbolName: String) { - let habit = Habit(title: title, symbolName: symbolName) - ritual.habits.append(habit) + guard let arc = ritual.currentArc else { return } + let habit = ArcHabit(title: title, symbolName: symbolName) + arc.habits.append(habit) saveContext() } - /// Removes a habit from a ritual - func removeHabit(_ habit: Habit, from ritual: Ritual) { - ritual.habits.removeAll { $0.id == habit.id } + /// Removes a habit from the current arc of a ritual + func removeHabit(_ habit: ArcHabit, from ritual: Ritual) { + guard let arc = ritual.currentArc else { return } + arc.habits.removeAll { $0.id == habit.id } modelContext.delete(habit) saveContext() } private func loadRitualsIfNeeded() { reloadRituals() - guard rituals.isEmpty else { return } - let seeds = seedService.makeSeedRituals(startDate: Date()) - seeds.forEach { modelContext.insert($0) } - saveContext() - reloadRituals() + // No longer auto-seed rituals on fresh install + // Users start with empty state and create their own rituals } private func reloadRituals() { @@ -498,18 +642,21 @@ final class RitualStore: RitualStoreProviding { // MARK: - History / Calendar Support /// Returns the completion rate for a specific date, optionally filtered by ritual. - /// - Parameters: - /// - 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 + /// This correctly queries habits from arcs that were active on that date. func completionRate(for date: Date, ritual: Ritual? = nil) -> Double { let dayID = dayIdentifier(for: date) - let habits: [Habit] + let habits: [ArcHabit] 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 { - 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 } @@ -523,10 +670,12 @@ final class RitualStore: RitualStoreProviding { var dates: Set = [] for ritual in rituals { - for habit in ritual.habits { - for dayID in habit.completedDayIDs { - if let date = dayFormatter.date(from: dayID) { - dates.insert(calendar.startOfDay(for: date)) + for arc in ritual.arcs { + for habit in arc.habits { + for dayID in habit.completedDayIDs { + 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. - /// - Parameters: - /// - 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 + /// This correctly queries habits from arcs that were active on that date. func habitCompletions(for date: Date, ritual: Ritual? = nil) -> [HabitCompletion] { let dayID = dayIdentifier(for: date) - let targetRituals = ritual.map { [$0] } ?? rituals var completions: [HabitCompletion] = [] - for r in targetRituals { - for habit in r.habits { - completions.append(HabitCompletion( - habit: habit, - ritualTitle: r.title, - isCompleted: habit.completedDayIDs.contains(dayID) - )) + if let ritual = ritual { + // Get habits from the arc that was active on this date + if let arc = ritual.arc(for: date) { + for habit in arc.habits { + completions.append(HabitCompletion( + 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. - func isHabitCompleted(_ habit: Habit, on date: Date) -> Bool { + func isHabitCompleted(_ habit: ArcHabit, on date: Date) -> Bool { let dayID = dayIdentifier(for: date) return habit.completedDayIDs.contains(dayID) } @@ -573,8 +734,6 @@ final class RitualStore: RitualStoreProviding { // MARK: - Enhanced Analytics /// 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 { guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) else { return 0 @@ -586,7 +745,7 @@ final class RitualStore: RitualStoreProviding { for dayOffset in 0..<7 { guard let day = calendar.date(byAdding: .day, value: dayOffset, to: weekStart) else { continue } let rate = completionRate(for: day) - if rate > 0 || hasAnyHabitsOnDate(day) { + if rate > 0 || !habitsActive(on: day).isEmpty { totalRate += rate daysWithData += 1 } @@ -595,17 +754,7 @@ final class RitualStore: RitualStoreProviding { 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. - /// - 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? { let dayID = dayIdentifier(for: date) let perfect = perfectDays() @@ -631,32 +780,26 @@ final class RitualStore: RitualStoreProviding { return streakBefore + 1 + streakAfter } - /// Returns the number of days remaining in the ritual arc. - /// - Parameter ritual: The ritual to check - /// - Returns: Days remaining (0 if completed or past end date) + /// Returns the number of days remaining in the ritual's current arc. func daysRemaining(for ritual: Ritual) -> Int { + guard let arc = ritual.currentArc else { return 0 } let today = calendar.startOfDay(for: Date()) - let start = calendar.startOfDay(for: ritual.startDate) - guard let endDate = calendar.date(byAdding: .day, value: ritual.durationDays - 1, to: start) else { - return 0 - } + let endDate = calendar.startOfDay(for: arc.endDate) let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0 return max(0, days) } - /// Returns the current streak for a specific ritual. - /// - Parameter ritual: The ritual to check - /// - Returns: Current streak count for this ritual only + /// Returns the current streak for a specific ritual's current arc. 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 checkDate = calendar.startOfDay(for: Date()) - while true { + while arc.contains(date: 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 { streak += 1 @@ -669,10 +812,8 @@ final class RitualStore: RitualStoreProviding { return streak } - /// Returns completion rates for each habit in a ritual. - /// - Parameter ritual: The ritual to analyze - /// - Returns: Array of tuples with habit and its completion rate - func habitCompletionRates(for ritual: Ritual) -> [(habit: Habit, rate: Double)] { + /// Returns completion rates for each habit in a ritual's current arc. + func habitCompletionRates(for ritual: Ritual) -> [(habit: ArcHabit, rate: Double)] { let currentDay = ritualDayIndex(for: ritual) 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. - /// - Parameter ritual: The ritual to check - /// - Returns: Array of milestones with achievement status func milestonesAchieved(for ritual: Ritual) -> [Milestone] { let currentDay = ritualDayIndex(for: ritual) return Milestone.standardMilestones(currentDay: currentDay, totalDays: ritual.durationDays) } /// Returns the week-over-week change in completion rate. - /// - Returns: Percentage change (e.g., 0.15 for 15% improvement) func weekOverWeekChange() -> Double { let today = calendar.startOfDay(for: Date()) 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. - /// - Parameter rate: The completion rate (0.0 to 1.0) - /// - Returns: A localized motivational message func motivationalMessage(for rate: Double) -> String { switch rate { case 1.0: @@ -728,8 +864,9 @@ final class RitualStore: RitualStoreProviding { /// Returns the insight context for tips generation. func insightContext() -> InsightContext { - let totalHabits = rituals.flatMap { $0.habits }.count - let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count + let activeHabitsToday = habitsActive(on: Date()) + let totalHabits = activeHabitsToday.count + let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0 return InsightContext( @@ -746,17 +883,40 @@ final class RitualStore: RitualStoreProviding { #if DEBUG /// 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() { let today = calendar.startOfDay(for: Date()) // Go back 6 months 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 { - ritual.startDate = sixMonthsAgo - ritual.durationDays = 180 + 28 // Cover 6 months plus buffer + // For each arc (active or not), extend it to cover the demo period + 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 @@ -766,14 +926,18 @@ final class RitualStore: RitualStoreProviding { let dayID = dayIdentifier(for: currentDate) for ritual in rituals { - for habit in ritual.habits { - // Random completion with ~70% average success rate - // Vary between 50-90% to create realistic patterns - let threshold = Double.random(in: 0.5...0.9) - let shouldComplete = Double.random(in: 0...1) < threshold + for arc in ritual.arcs { + // Only generate completions if the arc covers this date + guard arc.contains(date: currentDate) else { continue } - if shouldComplete && !habit.completedDayIDs.contains(dayID) { - habit.completedDayIDs.append(dayID) + for habit in arc.habits { + // 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). func clearAllCompletions() { for ritual in rituals { - for habit in ritual.habits { - habit.completedDayIDs.removeAll() + for arc in ritual.arcs { + for habit in arc.habits { + habit.completedDayIDs.removeAll() + } } } 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 } diff --git a/Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift b/Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift index b537eb6..30b6312 100644 --- a/Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift +++ b/Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift @@ -10,9 +10,9 @@ import Bedrock /// A view showing habit completion performance within a ritual. 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 } } @@ -34,7 +34,7 @@ struct HabitPerformanceView: View { .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) { HStack(spacing: Design.Spacing.medium) { Image(systemName: habit.symbolName) @@ -83,9 +83,9 @@ struct HabitPerformanceView: View { #Preview { HabitPerformanceView(habitRates: [ - (Habit(title: "Meditate", symbolName: "brain.fill"), 0.85), - (Habit(title: "Exercise", symbolName: "figure.walk"), 0.65), - (Habit(title: "Read", symbolName: "book.fill"), 0.40) + (ArcHabit(title: "Meditate", symbolName: "brain.fill"), 0.85), + (ArcHabit(title: "Exercise", symbolName: "figure.walk"), 0.65), + (ArcHabit(title: "Read", symbolName: "book.fill"), 0.40) ]) .padding(Design.Spacing.large) .background(AppSurface.primary) diff --git a/Andromida/App/Views/Rituals/Components/RitualCardView.swift b/Andromida/App/Views/Rituals/Components/RitualCardView.swift index a7e2443..4177e9c 100644 --- a/Andromida/App/Views/Rituals/Components/RitualCardView.swift +++ b/Andromida/App/Views/Rituals/Components/RitualCardView.swift @@ -8,7 +8,7 @@ struct RitualCardView: View { private let completionSummary: String private let iconName: String private let timeOfDay: TimeOfDay - private let isEnabled: Bool + private let hasActiveArc: Bool init( title: String, @@ -17,7 +17,7 @@ struct RitualCardView: View { completionSummary: String, iconName: String = "sparkles", timeOfDay: TimeOfDay = .anytime, - isEnabled: Bool = true + hasActiveArc: Bool = true ) { self.title = title self.theme = theme @@ -25,44 +25,37 @@ struct RitualCardView: View { self.completionSummary = completionSummary self.iconName = iconName self.timeOfDay = timeOfDay - self.isEnabled = isEnabled + self.hasActiveArc = hasActiveArc } var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.small) { + // Icon Image(systemName: iconName) - .foregroundStyle(isEnabled ? AppAccent.primary : AppTextColors.tertiary) + .foregroundStyle(hasActiveArc ? AppAccent.primary : AppTextColors.tertiary) .accessibilityHidden(true) + + // Title Text(title) .font(.headline) - .foregroundStyle(isEnabled ? 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) - } + .foregroundStyle(hasActiveArc ? AppTextColors.primary : AppTextColors.tertiary) Spacer(minLength: Design.Spacing.medium) - // Time of day badge - Image(systemName: timeOfDay.symbolName) - .font(.caption) - .foregroundStyle(AppTextColors.tertiary) - .accessibilityLabel(timeOfDay.displayName) + // Time of day badge - more prominent + timeOfDayBadge + // Day label Text(dayLabel) .font(.caption) .foregroundStyle(AppTextColors.secondary) } + Text(theme) .font(.subheadline) - .foregroundStyle(isEnabled ? AppTextColors.secondary : AppTextColors.tertiary) + .foregroundStyle(hasActiveArc ? AppTextColors.secondary : AppTextColors.tertiary) + Text(completionSummary) .font(.caption) .foregroundStyle(AppTextColors.secondary) @@ -70,9 +63,47 @@ struct RitualCardView: View { .padding(Design.Spacing.large) .background(AppSurface.card) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) - .opacity(isEnabled ? 1.0 : Design.Opacity.medium) + .opacity(hasActiveArc ? 1.0 : Design.Opacity.medium) .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 { @@ -84,17 +115,37 @@ struct RitualCardView: View { completionSummary: "2 of 3 habits complete", iconName: "sunrise.fill", timeOfDay: .morning, - isEnabled: true + hasActiveArc: true ) 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", dayLabel: "Day 14 of 28", completionSummary: "0 of 3 habits complete", iconName: "moon.stars.fill", 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) diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift index 3d2ad31..d6e705d 100644 --- a/Andromida/App/Views/Rituals/RitualDetailView.swift +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -9,7 +9,8 @@ struct RitualDetailView: View { @State private var showingEditSheet = 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) { self.store = store @@ -28,37 +29,35 @@ struct RitualDetailView: View { store.milestonesAchieved(for: ritual) } - private var habitRates: [(habit: Habit, rate: Double)] { + private var habitRates: [(habit: ArcHabit, rate: Double)] { 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 { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { // Header with icon headerSection - // Progress card - RitualFocusCardView( - title: ritual.title, - theme: ritual.theme, - dayLabel: store.ritualDayLabel(for: ritual), - completionSummary: store.completionSummary(for: ritual), - progress: store.ritualProgress(for: ritual) - ) + if ritual.hasActiveArc { + // Active arc content + activeArcContent + } else { + // Past ritual - show summary and restart option + pastRitualContent + } - // Status badges with time remaining and streak - statusBadges - - // Time remaining and streak info - timeAndStreakSection - - // Milestones - RitualMilestonesView(milestones: milestones) - - // Habit performance - if !habitRates.isEmpty { - HabitPerformanceView(habitRates: habitRates) + // Arc history (if multiple arcs exist) + if hasMultipleArcs || !ritual.hasActiveArc { + arcHistorySection } // Notes @@ -71,21 +70,9 @@ struct RitualDetailView: View { } } - // Habits - 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) } - ) - } + // Habits (only show for active rituals) + if ritual.hasActiveArc { + habitsSection } } .padding(Design.Spacing.large) @@ -106,23 +93,22 @@ struct RitualDetailView: View { Label(String(localized: "Edit"), systemImage: "pencil") } - Button { - store.toggleEnabled(ritual) - } label: { - Label( - ritual.isEnabled ? String(localized: "Disable") : String(localized: "Enable"), - systemImage: ritual.isEnabled ? "pause.circle" : "play.circle" - ) + if ritual.hasActiveArc { + Button { + showingEndArcConfirmation = true + } label: { + Label(String(localized: "End Arc"), systemImage: "stop.circle") + } + } else { + Button { + showingStartArcConfirmation = true + } label: { + Label(String(localized: "Start New Arc"), systemImage: "arrow.clockwise.circle") + } } Divider() - Button { - showingArchiveConfirmation = true - } label: { - Label(String(localized: "Archive"), systemImage: "archivebox") - } - Button(role: .destructive) { showingDeleteConfirmation = true } label: { @@ -146,14 +132,21 @@ struct RitualDetailView: View { } message: { 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: "Archive")) { - store.archiveRitual(ritual) - dismiss() + Button(String(localized: "End Arc")) { + store.endArc(for: ritual) } } 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) { Image(systemName: ritual.iconName) .font(.system(size: Design.BaseFontSize.largeTitle)) - .foregroundStyle(AppAccent.primary) + .foregroundStyle(ritual.hasActiveArc ? AppAccent.primary : AppTextColors.secondary) .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)) VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { @@ -183,18 +176,123 @@ struct RitualDetailView: View { .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 private var statusBadges: some View { 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 - Label(ritual.timeOfDay.displayName, systemImage: ritual.timeOfDay.symbolName) - .font(.caption) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background(AppSurface.card) - .clipShape(.capsule) - .foregroundStyle(AppTextColors.secondary) + timeOfDayBadge // Category badge (if set) if !ritual.category.isEmpty { @@ -207,19 +305,105 @@ struct RitualDetailView: View { .foregroundStyle(AppTextColors.secondary) } - // Enabled/Disabled badge - if !ritual.isEnabled { - Text(String(localized: "Disabled")) + Spacer() + } + } + + 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) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background(AppStatus.warning.opacity(0.2)) - .clipShape(.capsule) - .foregroundStyle(AppStatus.warning) + .foregroundStyle(AppTextColors.tertiary) + } else { + VStack(spacing: Design.Spacing.small) { + ForEach(completedArcs) { arc in + 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() + + 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 diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift index f0ffcf9..564687c 100644 --- a/Andromida/App/Views/Rituals/RitualsView.swift +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -3,38 +3,41 @@ import Bedrock struct RitualsView: View { @Bindable var store: RitualStore + @State private var selectedTab: RitualsTab = .current @State private var showingPresetLibrary = false @State private var showingCreateRitual = false @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 { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { - SectionHeaderView( - title: String(localized: "Rituals"), - subtitle: String(localized: "Your active and recent arcs") - ) - - // Active rituals - if !store.enabledRituals.isEmpty { - activeRitualsSection + // Segmented picker + Picker(String(localized: "View"), selection: $selectedTab) { + ForEach(RitualsTab.allCases, id: \.self) { tab in + Text(tab.displayName).tag(tab) + } } + .pickerStyle(.segmented) + .padding(.horizontal, Design.Spacing.small) - // Disabled rituals - let disabledRituals = store.rituals.filter { !$0.isEnabled && !$0.isArchived } - if !disabledRituals.isEmpty { - disabledRitualsSection(disabledRituals) - } - - // Archived rituals - if !store.archivedRituals.isEmpty { - archivedRitualsSection - } - - // Empty state - if store.rituals.isEmpty { - emptyState + switch selectedTab { + case .current: + currentRitualsContent + case .past: + pastRitualsContent } } .padding(Design.Spacing.large) @@ -88,69 +91,78 @@ struct RitualsView: View { } message: { 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( - get: { ritualToArchive != nil }, - set: { if !$0 { ritualToArchive = nil } } + .alert(String(localized: "Start New Arc?"), isPresented: .init( + get: { ritualToRestart != nil }, + set: { if !$0 { ritualToRestart = nil } } )) { Button(String(localized: "Cancel"), role: .cancel) { - ritualToArchive = nil + ritualToRestart = nil } - Button(String(localized: "Archive")) { - if let ritual = ritualToArchive { - store.archiveRitual(ritual) + Button(String(localized: "Start")) { + if let ritual = ritualToRestart { + store.startNewArc(for: ritual) } - ritualToArchive = nil + ritualToRestart = nil } } 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 { - VStack(spacing: Design.Spacing.medium) { - ForEach(store.enabledRituals) { ritual in - ritualRow(for: ritual) + @ViewBuilder + private var currentRitualsContent: some View { + let groupedRituals = store.currentRitualsGroupedByTime() + + 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 { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - Text(String(localized: "Disabled")) - .font(.subheadline) - .bold() - .foregroundStyle(AppTextColors.secondary) - .padding(.top, Design.Spacing.medium) - - ForEach(rituals) { ritual in - ritualRow(for: ritual) + // MARK: - Past Tab Content + + @ViewBuilder + private var pastRitualsContent: some View { + if store.pastRituals.isEmpty { + pastEmptyState + } else { + VStack(spacing: Design.Spacing.medium) { + ForEach(store.pastRituals) { ritual in + pastRitualRow(for: ritual) + } } } } - private var archivedRitualsSection: some View { - 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) - } - } - } + // MARK: - Empty States - private var emptyState: some View { + private var currentEmptyState: some View { VStack(spacing: Design.Spacing.large) { Image(systemName: "sparkles") .font(.system(size: Design.BaseFontSize.largeTitle * 2)) .foregroundStyle(AppAccent.primary) - Text(String(localized: "No Rituals Yet")) + Text(String(localized: "No Active Rituals")) .font(.headline) .foregroundStyle(AppTextColors.primary) @@ -175,6 +187,32 @@ struct RitualsView: View { } .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) .padding(.vertical, Design.Spacing.xxxLarge) @@ -182,7 +220,7 @@ struct RitualsView: View { // MARK: - Ritual Rows - private func ritualRow(for ritual: Ritual) -> some View { + private func currentRitualRow(for ritual: Ritual) -> some View { NavigationLink { RitualDetailView(store: store, ritual: ritual) } label: { @@ -193,12 +231,24 @@ struct RitualsView: View { completionSummary: store.completionSummary(for: ritual), iconName: ritual.iconName, timeOfDay: ritual.timeOfDay, - isEnabled: ritual.isEnabled + hasActiveArc: true ) } .buttonStyle(.plain) .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) { Button(role: .destructive) { @@ -208,83 +258,110 @@ struct RitualsView: View { } Button { - ritualToArchive = ritual + store.endArc(for: ritual) } label: { - Label(String(localized: "Archive"), systemImage: "archivebox") + Label(String(localized: "End"), systemImage: "stop.circle") } .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) { Button { - store.toggleEnabled(ritual) + ritualToRestart = ritual } label: { - Label( - ritual.isEnabled ? String(localized: "Disable") : String(localized: "Enable"), - systemImage: ritual.isEnabled ? "pause.circle" : "play.circle" - ) + Label(String(localized: "Restart"), systemImage: "arrow.clockwise.circle") } - .tint(ritual.isEnabled ? AppTextColors.tertiary : AppStatus.success) + .tint(AppStatus.success) } } - private func archivedRitualRow(for ritual: Ritual) -> some View { - HStack { + private func pastRitualCardView(for ritual: Ritual) -> some View { + 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) { Text(ritual.title) - .font(.subheadline) - .foregroundStyle(AppTextColors.secondary) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + Text(ritual.theme) .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) + } } Spacer() - Button { - store.unarchiveRitual(ritual) - } label: { - Text(String(localized: "Restore")) - .font(.caption) + // Arc count badge + if ritual.completedArcCount > 0 { + Text("\(ritual.completedArcCount) arc\(ritual.completedArcCount == 1 ? "" : "s")") + .font(.caption2) + .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) .background(AppSurface.card) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) - .opacity(Design.Opacity.medium) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - ritualToDelete = ritual - } label: { - Label(String(localized: "Delete"), systemImage: "trash") - } - } + .opacity(Design.Opacity.heavy) } - @ViewBuilder - private func contextMenuItems(for ritual: Ritual) -> some View { - Button { - store.toggleEnabled(ritual) - } label: { - 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") - } + private func formattedEndDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return String(localized: "Ended \(formatter.string(from: date))") } } diff --git a/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift b/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift new file mode 100644 index 0000000..758e193 --- /dev/null +++ b/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift @@ -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) + } + } + } +} diff --git a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift index e6fc769..5cf9a7a 100644 --- a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift @@ -16,9 +16,13 @@ struct RitualEditSheet: View { @State private var timeOfDay: TimeOfDay = .anytime @State private var iconName: String = "sparkles" @State private var category: String = "" + @State private var customCategory: String = "" + @State private var isUsingCustomCategory: Bool = false @State private var habits: [EditableHabit] = [] @State private var showingIconPicker = false + @State private var showingHabitIconPicker = false + @State private var editingHabitIndex: Int? @State private var newHabitTitle: String = "" @State private var newHabitIcon: String = "circle.fill" @@ -48,6 +52,7 @@ struct RitualEditSheet: View { // Notes section notesSection } + .environment(\.editMode, .constant(.active)) .scrollContentBackground(.hidden) .background(AppSurface.primary) .navigationTitle(isEditing ? String(localized: "Edit Ritual") : String(localized: "New Ritual")) @@ -75,6 +80,16 @@ struct RitualEditSheet: View { .sheet(isPresented: $showingIconPicker) { IconPickerSheet(selectedIcon: $iconName) } + .sheet(isPresented: $showingHabitIconPicker) { + HabitIconPickerSheet( + selectedIcon: editingHabitIndex != nil + ? Binding( + get: { habits[editingHabitIndex!].symbolName }, + set: { habits[editingHabitIndex!].symbolName = $0 } + ) + : $newHabitIcon + ) + } } .presentationDetents([.large]) .presentationDragIndicator(.visible) @@ -107,10 +122,22 @@ struct RitualEditSheet: View { } .listRowBackground(AppSurface.card) - Picker(String(localized: "Category"), selection: $category) { - Text(String(localized: "None")).tag("") - ForEach(PresetCategory.allCases, id: \.self) { cat in - Text(cat.displayName).tag(cat.rawValue) + // Category selection with custom option + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Picker(String(localized: "Category"), selection: $category) { + 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) @@ -119,13 +146,27 @@ struct RitualEditSheet: View { } } + @State private var isEditingDuration: Bool = false + @State private var customDurationText: String = "" + private var scheduleSection: some View { Section { - Picker(String(localized: "Time of Day"), selection: $timeOfDay) { - ForEach(TimeOfDay.allCases, id: \.self) { time in - Label(time.displayName, systemImage: time.symbolName) - .tag(time) + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Picker(String(localized: "Time of Day"), selection: $timeOfDay) { + ForEach(TimeOfDay.allCases, id: \.self) { time in + 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) @@ -133,28 +174,108 @@ struct RitualEditSheet: View { HStack { Text(String(localized: "Duration")) 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) - .tint(AppAccent.primary) + if !isEditingDuration { + 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) } header: { 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 { Section { - ForEach($habits) { $habit in - HStack(spacing: Design.Spacing.medium) { - Image(systemName: habit.symbolName) - .foregroundStyle(AppAccent.primary) - .frame(width: 24) + ForEach(Array(habits.enumerated()), id: \.element.id) { index, habit in + HStack(spacing: Design.Spacing.small) { + // Drag handle + Image(systemName: "line.3.horizontal") + .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 { habits.removeAll { $0.id == habit.id } @@ -173,14 +294,14 @@ struct RitualEditSheet: View { // Add new habit row HStack(spacing: Design.Spacing.medium) { Button { - // Simple icon rotation for new habits - let icons = ["circle.fill", "star.fill", "heart.fill", "bolt.fill", "leaf.fill"] - newHabitIcon = icons.randomElement() ?? "circle.fill" + editingHabitIndex = nil + showingHabitIconPicker = true } label: { Image(systemName: newHabitIcon) .foregroundStyle(AppTextColors.tertiary) .frame(width: 24) } + .buttonStyle(.plain) TextField(String(localized: "Add a habit..."), text: $newHabitTitle) .onSubmit { @@ -205,6 +326,11 @@ struct RitualEditSheet: View { .font(.caption) .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) timeOfDay = ritual.timeOfDay iconName = ritual.iconName - category = ritual.category 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() { @@ -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() { let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedTheme = theme.trimmingCharacters(in: .whitespacesAndNewlines) @@ -262,7 +411,7 @@ struct RitualEditSheet: View { durationDays: Int(durationDays), timeOfDay: timeOfDay, iconName: iconName, - category: category + category: effectiveCategory ) // Update habits - remove old, add new @@ -275,7 +424,7 @@ struct RitualEditSheet: View { } } else { // 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( title: trimmedTitle, theme: trimmedTheme, @@ -283,7 +432,7 @@ struct RitualEditSheet: View { durationDays: Int(durationDays), timeOfDay: timeOfDay, iconName: iconName, - category: category, + category: effectiveCategory, habits: newHabits ) } @@ -301,54 +450,74 @@ private struct EditableHabit: Identifiable { self.symbolName = symbolName } - init(from habit: Habit) { + init(from habit: ArcHabit) { self.title = habit.title self.symbolName = habit.symbolName } } -/// Simple icon picker sheet +/// Icon picker sheet for ritual icons struct IconPickerSheet: View { @Binding var selectedIcon: String @Environment(\.dismiss) private var dismiss + @State private var searchText: String = "" - private let icons = [ - // Wellness - "heart.fill", "drop.fill", "leaf.fill", "flame.fill", "bolt.fill", - // Time - "sunrise.fill", "sun.max.fill", "moon.stars.fill", "clock.fill", - // Activity - "figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", - // Mind - "brain", "brain.head.profile", "sparkles", "star.fill", - // Objects - "book.fill", "pencil.and.list.clipboard", "cup.and.saucer.fill", "bed.double.fill", - // Misc - "checkmark.circle.fill", "target", "scope", "wind" + private let iconGroups: [(name: String, icons: [String])] = [ + ("Wellness", ["heart.fill", "drop.fill", "leaf.fill", "flame.fill", "bolt.fill", "pill.fill", "cross.fill", "stethoscope.circle.fill"]), + ("Time", ["sunrise.fill", "sun.max.fill", "moon.stars.fill", "clock.fill", "hourglass", "timer", "alarm.fill"]), + ("Activity", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "dumbbell.fill"]), + ("Mind", ["brain", "brain.head.profile", "sparkles", "star.fill", "lightbulb.fill", "eye.fill", "ear.fill"]), + ("Objects", ["book.fill", "book.closed.fill", "pencil.and.list.clipboard", "cup.and.saucer.fill", "bed.double.fill", "tshirt.fill", "fork.knife"]), + ("Nature", ["tree.fill", "wind", "cloud.fill", "snowflake", "sun.horizon.fill", "moon.fill", "mountain.2.fill"]), + ("Actions", ["checkmark.circle.fill", "target", "scope", "hand.thumbsup.fill", "hand.raised.fill", "bell.fill", "megaphone.fill"]), + ("Arrows", ["arrow.up.circle.fill", "arrow.down.circle.fill", "arrow.counterclockwise.circle.fill", "arrow.2.circlepath"]) ] + 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 { NavigationStack { ScrollView { - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.medium) { - ForEach(icons, id: \.self) { icon in - Button { - selectedIcon = icon - dismiss() - } label: { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(selectedIcon == icon ? AppAccent.primary : AppTextColors.secondary) - .frame(width: 50, height: 50) - .background(selectedIcon == icon ? AppAccent.primary.opacity(0.2) : AppSurface.card) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + 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) + } + } + } } - .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) + .searchable(text: $searchText, prompt: String(localized: "Search icons")) .navigationTitle(String(localized: "Choose Icon")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -360,9 +529,135 @@ struct IconPickerSheet: View { } } } - .presentationDetents([.medium]) + .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) + } +} + +/// 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() + 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 { diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index 7c5c749..61973d2 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -176,6 +176,14 @@ struct SettingsView: View { ritualStore.preloadDemoData() } + SettingsRow( + systemImage: "checkmark.circle.badge.xmark", + title: String(localized: "Complete First Active Arc (Test Renewal)"), + iconColor: AppStatus.success + ) { + ritualStore.simulateArcCompletion() + } + SettingsRow( systemImage: "trash", title: String(localized: "Clear All Completions"), diff --git a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift index 596cad9..b6a0a78 100644 --- a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift +++ b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift @@ -3,24 +3,80 @@ import Bedrock struct TodayEmptyStateView: View { @Bindable var store: RitualStore + @State private var showingPresetLibrary = false + @State private var showingCreateRitual = false var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.large) { SectionHeaderView( - title: String(localized: "No ritual yet"), - subtitle: String(localized: "Begin a four-week arc") + title: String(localized: "No Active Rituals"), + subtitle: String(localized: "Start building better habits") ) - EmptyStateCardView( - title: String(localized: "Start your first ritual"), - message: String(localized: "Choose a theme and keep your focus clear for 28 days."), - actionTitle: String(localized: "Create ritual"), - action: { store.createQuickRitual() } - ) + VStack(spacing: Design.Spacing.large) { + // Icon + Image(systemName: "sparkles") + .font(.system(size: Design.BaseFontSize.largeTitle * 2)) + .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 { TodayEmptyStateView(store: RitualStore.preview) + .padding() + .background(AppSurface.primary) } diff --git a/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift new file mode 100644 index 0000000..cd2848d --- /dev/null +++ b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift @@ -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) +} diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index 5a85b9f..0702b7b 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -4,9 +4,19 @@ import Bedrock struct TodayView: View { @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] { - 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 { @@ -15,7 +25,13 @@ struct TodayView: View { TodayHeaderView(dateText: store.todayDisplayString) 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 { ForEach(todayRituals) { ritual in TodayRitualSectionView( @@ -38,6 +54,17 @@ struct TodayView: View { startPoint: .topLeading, 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] { diff --git a/AndromidaTests/RitualStoreTests.swift b/AndromidaTests/RitualStoreTests.swift index aa2f313..a435c0d 100644 --- a/AndromidaTests/RitualStoreTests.swift +++ b/AndromidaTests/RitualStoreTests.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftData import Testing @testable import Andromida @@ -24,10 +25,36 @@ struct RitualStoreTests { store.toggleHabitCompletion(habit) #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 { - let schema = Schema([Ritual.self, Habit.self]) + let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) let container: ModelContainer do { @@ -36,7 +63,7 @@ private func makeStore() -> RitualStore { 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 { diff --git a/README.md b/README.md index 80cfe18..e300b62 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,87 @@ # 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 -- **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. - **Data**: Local persistence with SwiftData; settings sync via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore). - **No paid APIs**: No external services required. ## Feature Set -- **Today dashboard**: Focus ritual, progress ring, and tap-to-complete habits. -- **Ritual library**: View active and recent rituals. -- **Ritual detail**: Full ritual summary + habit check-ins. -- **Insights**: Lightweight metrics generated locally. -- **Settings**: - - Reminders, haptics, sound toggles - - Ritual pacing options (focus style + length) - - iCloud settings sync - - DEBUG tools for icon generation and branding preview -- **Branding**: - - Bedrock AppLaunchView with custom theme - - Native LaunchScreen.storyboard to prevent flash - - Centralized branding config (colors, icons, launch) +### Today Tab +- Focus ritual cards with progress rings +- Tap-to-complete habit check-ins with haptic/sound feedback +- Time-of-day filtering (morning/evening/anytime rituals) +- Smart empty states (distinguishes "no rituals" from "no rituals for this time") +- Fresh install starts clean (no pre-seeded rituals) + +### Rituals Tab +- View all active rituals +- Create new rituals from scratch or browse preset library +- 13 categorized presets (Health, Productivity, Mindfulness, Self-Care) +- Full ritual management: edit, enable/disable, archive, delete +- 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 @@ -42,10 +100,28 @@ Andromida/ ├── Andromida/ # App target │ ├── App/ │ │ ├── Models/ # SwiftData + DTOs +│ │ │ ├── Ritual.swift +│ │ │ ├── Habit.swift +│ │ │ ├── HabitCompletion.swift +│ │ │ ├── InsightCard.swift +│ │ │ ├── Milestone.swift +│ │ │ ├── TrendDirection.swift +│ │ │ └── RitualPresets.swift │ │ ├── Protocols/ # Interfaces for stores/services +│ │ │ ├── RitualStoreProviding.swift +│ │ │ ├── RitualSeedProviding.swift +│ │ │ └── InsightTipsProviding.swift │ │ ├── Services/ # Stateless logic +│ │ │ └── RitualSeedService.swift │ │ ├── State/ # @Observable stores +│ │ │ ├── RitualStore.swift +│ │ │ └── SettingsStore.swift │ │ └── Views/ # SwiftUI features + components +│ │ ├── Today/ +│ │ ├── Rituals/ +│ │ ├── History/ +│ │ ├── Insights/ +│ │ └── Settings/ │ ├── Shared/ # Bedrock theme + branding config │ └── Resources/ # LaunchScreen.storyboard ├── AndromidaTests/ # Unit tests @@ -64,8 +140,9 @@ Andromida/ ## Data Model -- **Ritual**: Title, theme, start date, duration (days), notes, habits -- **Habit**: Title, symbol, goal, completion by day IDs +- **Ritual**: Title, theme, start date, duration (days), notes, habits, isEnabled, isArchived, timeOfDay, iconName, category +- **Habit**: Title, symbol, completedDayIDs +- **Milestone**: Day, title, symbol, isAchieved - **Settings**: Stored via Bedrock CloudSyncManager (NSUbiquitousKeyValueStore) ## Bedrock Integration @@ -74,6 +151,7 @@ Andromida/ - **Branding**: AppLaunchView, AppIconConfig, LaunchScreenConfig - **Settings UI**: SettingsToggle, SettingsSlider, SettingsSegmentedPicker, SettingsCard - **Cloud Sync**: iCloud sync for settings using CloudSyncManager +- **Onboarding**: Sherpa walkthrough system ## Localization @@ -95,10 +173,11 @@ String catalogs are used for English, Spanish (Mexico), and French (Canada): - Unit tests in `AndromidaTests/` - 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 - 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. - App icon generation is available in DEBUG builds from Settings. +- Fresh installs start with no rituals; users create their own from scratch or presets. diff --git a/TODO.md b/TODO.md index d6cddfe..c87b584 100644 --- a/TODO.md +++ b/TODO.md @@ -14,6 +14,8 @@ ## 3) Today tab UX polish - [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] 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 - [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements. @@ -26,6 +28,7 @@ ## 5) Data & defaults - [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] Remove automatic seed rituals on fresh install. ## 6) QA checklist - [x] First-launch walkthrough appears on a clean install. @@ -33,15 +36,19 @@ - [x] No build warnings or Swift compiler crashes. - [x] iPhone 17 Pro Max simulator layout verified on Today, Rituals, Insights, Settings. -## 7) Future 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. See plan: `.cursor/plans/calendar_history_view_88026c7b.plan.md` +## 7) Completed enhancements +- [x] **History view** – View past/completed rituals with completion percentages. - [x] Scrollable month calendar grid - [x] Daily progress rings with color coding - [x] Filter by ritual using horizontal pill picker - [x] Tap day for detail sheet with habit list - [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] RitualStore CRUD methods (create, update, delete, enable, archive) - [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] Destructive action confirmations with history warning - [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] Explanations for each metric - [x] Per-ritual breakdowns - [x] Streak tracking (current & longest) - [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. - [ ] **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.