Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
99d1db84e9
commit
6be09c3067
@ -1,7 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import Sherpa
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct AndromidaApp: App {
|
struct AndromidaApp: App {
|
||||||
@ -9,6 +8,10 @@ struct AndromidaApp: App {
|
|||||||
@State private var store: RitualStore
|
@State private var store: RitualStore
|
||||||
@State private var settingsStore: SettingsStore
|
@State private var settingsStore: SettingsStore
|
||||||
@State private var categoryStore: CategoryStore
|
@State private var categoryStore: CategoryStore
|
||||||
|
@AppStorage("hasCompletedSetupWizard") private var hasCompletedSetupWizard = false
|
||||||
|
|
||||||
|
/// Track if user just completed the wizard (to start on Rituals tab)
|
||||||
|
@State private var justCompletedWizard = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
||||||
@ -29,13 +32,33 @@ struct AndromidaApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
SherpaContainerView {
|
ZStack {
|
||||||
ZStack {
|
Color.Branding.primary
|
||||||
Color.Branding.primary
|
.ignoresSafeArea()
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
|
if hasCompletedSetupWizard {
|
||||||
|
// Main app - start on Rituals tab if just completed wizard
|
||||||
AppLaunchView(config: .rituals) {
|
AppLaunchView(config: .rituals) {
|
||||||
RootView(store: store, settingsStore: settingsStore, categoryStore: categoryStore)
|
RootView(
|
||||||
|
store: store,
|
||||||
|
settingsStore: settingsStore,
|
||||||
|
categoryStore: categoryStore,
|
||||||
|
initialTab: justCompletedWizard ? .rituals : .today
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First-run setup wizard
|
||||||
|
AppLaunchView(config: .rituals) {
|
||||||
|
SetupWizardView(
|
||||||
|
store: store,
|
||||||
|
categoryStore: categoryStore,
|
||||||
|
onComplete: {
|
||||||
|
justCompletedWizard = true
|
||||||
|
withAnimation {
|
||||||
|
hasCompletedSetupWizard = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -356,6 +356,10 @@
|
|||||||
},
|
},
|
||||||
"Arc History" : {
|
"Arc History" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Back" : {
|
||||||
|
"comment" : "A button label that says \"Back\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Before 11am" : {
|
"Before 11am" : {
|
||||||
"comment" : "Time range description for the \"Morning\" time of day.",
|
"comment" : "Time range description for the \"Morning\" time of day.",
|
||||||
@ -369,6 +373,14 @@
|
|||||||
"comment" : "Habit title for a mindfulness ritual that involves scanning the body for tension.",
|
"comment" : "Habit title for a mindfulness ritual that involves scanning the body for tension.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Bookend your day" : {
|
||||||
|
"comment" : "Subtitle for the \"Both\" option in the \"Time of Day\" section of the onboarding screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Both" : {
|
||||||
|
"comment" : "Text for a user preference to start and end their day with a ritual.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Box breathing (4-4-4-4)" : {
|
"Box breathing (4-4-4-4)" : {
|
||||||
"comment" : "Description of a habit within a ritual preset, focusing on a specific breathing technique.",
|
"comment" : "Description of a habit within a ritual preset, focusing on a specific breathing technique.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -407,10 +419,18 @@
|
|||||||
"comment" : "Title of a ritual preset focused on using breath to reduce stress and increase focus.",
|
"comment" : "Title of a ritual preset focused on using breath to reduce stress and increase focus.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Browse All Presets" : {
|
||||||
|
"comment" : "A button label that takes the user to the preset library.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Browse Presets" : {
|
"Browse Presets" : {
|
||||||
"comment" : "A button that, when tapped, presents a sheet displaying a list of available ritual presets.",
|
"comment" : "A button that, when tapped, presents a sheet displaying a list of available ritual presets.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Build lasting habits through focused, time-bound journeys" : {
|
||||||
|
"comment" : "A description of the functionality of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Build the habit of hydrating first thing in the morning." : {
|
"Build the habit of hydrating first thing in the morning." : {
|
||||||
"comment" : "Notes section of a ritual preset focused on morning hydration.",
|
"comment" : "Notes section of a ritual preset focused on morning hydration.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -560,6 +580,14 @@
|
|||||||
"comment" : "Tip to consider focusing on fewer habits for better consistency.",
|
"comment" : "Tip to consider focusing on fewer habits for better consistency.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Continue" : {
|
||||||
|
"comment" : "A button label that says \"Continue\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Continue to Rituals" : {
|
||||||
|
"comment" : "A button label that links to the next step in the tutorial.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Continue with Changes" : {
|
"Continue with Changes" : {
|
||||||
"comment" : "A button label that lets users continue a ritual with changes they've made.",
|
"comment" : "A button label that lets users continue a ritual with changes they've made.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -686,6 +714,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Day 1 of %lld" : {
|
||||||
|
"comment" : "A subheading followed by the ritual title. The argument is the number of days in the ritual.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"days" : {
|
"days" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -954,10 +986,22 @@
|
|||||||
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
|
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Explore your rituals and insights" : {
|
||||||
|
"comment" : "Sherpa walkthrough tag text for the \"tab bar\" section of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Feel better each day" : {
|
||||||
|
"comment" : "Subtitle for the \"Health\" onboarding goal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Find the good" : {
|
"Find the good" : {
|
||||||
"comment" : "Title of a habit in a ritual preset focused on practicing gratitude.",
|
"comment" : "Title of a habit in a ritual preset focused on practicing gratitude.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Find your calm" : {
|
||||||
|
"comment" : "Subtitle for the \"Mindfulness\" onboarding goal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"First check-in" : {
|
"First check-in" : {
|
||||||
"comment" : "Label for the first check-in date in the \"Days Active\" breakdown.",
|
"comment" : "Label for the first check-in date in the \"Days Active\" breakdown.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -965,6 +1009,9 @@
|
|||||||
"First Day" : {
|
"First Day" : {
|
||||||
"comment" : "Title of the first milestone in a 28-day ritual arc.",
|
"comment" : "Title of the first milestone in a 28-day ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Focus" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Focus Reset" : {
|
"Focus Reset" : {
|
||||||
"comment" : "Title of a ritual preset that encourages users to refocus when they feel scattered.",
|
"comment" : "Title of a ritual preset that encourages users to refocus when they feel scattered.",
|
||||||
@ -1040,10 +1087,18 @@
|
|||||||
"comment" : "Habit title for a ritual preset focused on self-care, emphasizing gentle stretching as a habit.",
|
"comment" : "Habit title for a ritual preset focused on self-care, emphasizing gentle stretching as a habit.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Get more done" : {
|
||||||
|
"comment" : "Subtitle for the \"Focus\" onboarding goal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Get reminded when it's time for your rituals" : {
|
"Get reminded when it's time for your rituals" : {
|
||||||
"comment" : "Default text to show in the reminder subtitle when the ritual store is unavailable.",
|
"comment" : "Default text to show in the reminder subtitle when the ritual store is unavailable.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Get Started" : {
|
||||||
|
"comment" : "The text for the \"Get Started\" button in the welcome screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Give your mind a break from screens." : {
|
"Give your mind a break from screens." : {
|
||||||
"comment" : "Notes for a ritual preset focused on giving the mind a break from screens.",
|
"comment" : "Notes for a ritual preset focused on giving the mind a break from screens.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1146,10 +1201,18 @@
|
|||||||
"comment" : "Habit title for a ritual preset that encourages having a real conversation with someone.",
|
"comment" : "Habit title for a ritual preset that encourages having a real conversation with someone.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Health" : {
|
||||||
|
"comment" : "Display name for the \"Health\" onboarding goal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Herbal tea" : {
|
"Herbal tea" : {
|
||||||
"comment" : "Habit title for a ritual preset that includes herbal tea as a habit.",
|
"comment" : "Habit title for a ritual preset that includes herbal tea as a habit.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Here's how to get the most from Rituals" : {
|
||||||
|
"comment" : "A description of what users can expect to gain from using the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"History" : {
|
"History" : {
|
||||||
"comment" : "Title of the history view.",
|
"comment" : "Title of the history view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1274,6 +1337,14 @@
|
|||||||
"comment" : "Habit title for a mindfulness ritual where the user lets go of their worries or stresses of the day.",
|
"comment" : "Habit title for a mindfulness ritual where the user lets go of their worries or stresses of the day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Let's Go" : {
|
||||||
|
"comment" : "A call-to-action button text that translates to \"Let's Go\" in English.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Let's try it out!" : {
|
||||||
|
"comment" : "A title displayed in the first step of the onboarding tutorial.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Light a candle or dim lights" : {
|
"Light a candle or dim lights" : {
|
||||||
"comment" : "Habit within a RitualPreset related to creating a buffer between your day and sleep.",
|
"comment" : "Habit within a RitualPreset related to creating a buffer between your day and sleep.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1322,6 +1393,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Mindfulness" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Momentum at a glance" : {
|
"Momentum at a glance" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1425,6 +1499,10 @@
|
|||||||
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Nice work!" : {
|
||||||
|
"comment" : "A congratulatory message displayed after a successful habit check-in.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Night" : {
|
"Night" : {
|
||||||
"comment" : "Name for the time of day after 9pm.",
|
"comment" : "Name for the time of day after 9pm.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1550,6 +1628,10 @@
|
|||||||
"comment" : "Subtitle text for the reminder toggle when notifications are disabled in settings.",
|
"comment" : "Subtitle text for the reminder toggle when notifications are disabled in settings.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Nurture yourself" : {
|
||||||
|
"comment" : "Subtitle for the Self-Care goal in the onboarding screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"of %lld" : {
|
"of %lld" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -1561,6 +1643,10 @@
|
|||||||
"comment" : "Title for a milestone that occurs one week into a ritual arc.",
|
"comment" : "Title for a milestone that occurs one week into a ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"or" : {
|
||||||
|
"comment" : "A conjunction used to connect two independent clauses. In this context, it connects the divider and the action buttons.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Or restart a past ritual from the Past tab." : {
|
"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.",
|
"comment" : "A footnote displayed below the \"Create\" button in the \"No Active Rituals\" view, encouraging users to explore their past rituals.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1674,6 +1760,10 @@
|
|||||||
"comment" : "Theme for a ritual preset that encourages reflection and processing of the day's events.",
|
"comment" : "Theme for a ritual preset that encourages reflection and processing of the day's events.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Quick Start" : {
|
||||||
|
"comment" : "A section header for quick start actions.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Read 10 pages" : {
|
"Read 10 pages" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1788,6 +1878,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Ritual %lld of %lld" : {
|
||||||
|
"comment" : "Text for the header of a view that previews a ritual preset, indicating which ritual it is in a multi-ritual flow. The first argument is the index of the current ritual (starting at 1). The second argument is the total number of rituals.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Ritual %1$lld of %2$lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency." : {
|
"Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency." : {
|
||||||
"comment" : "Explanation of the insight card titled \"Active\".",
|
"comment" : "Explanation of the insight card titled \"Active\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1893,6 +1995,9 @@
|
|||||||
"Search icons (e.g., heart, star, book)" : {
|
"Search icons (e.g., heart, star, book)" : {
|
||||||
"comment" : "A prompt for searching through habit icons.",
|
"comment" : "A prompt for searching through habit icons.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Self-Care" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Set an intention for the day" : {
|
"Set an intention for the day" : {
|
||||||
"comment" : "Habit title for a ritual preset focused on setting an intention for the day.",
|
"comment" : "Habit title for a ritual preset focused on setting an intention for the day.",
|
||||||
@ -1944,6 +2049,13 @@
|
|||||||
"comment" : "A button label that indicates more content is available.",
|
"comment" : "A button label that indicates more content is available.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Shows rituals for the current time of day. Check in here daily." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Skip for now" : {
|
||||||
|
"comment" : "A button label that allows users to skip creating a new ritual for now.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Sleep Preparation" : {
|
"Sleep Preparation" : {
|
||||||
"comment" : "Theme of the \"Sleep Preparation\" ritual preset.",
|
"comment" : "Theme of the \"Sleep Preparation\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2008,6 +2120,10 @@
|
|||||||
"comment" : "A button that starts a new arc for a ritual.",
|
"comment" : "A button that starts a new arc for a ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Start %@ ritual" : {
|
||||||
|
"comment" : "A button that starts a ritual from a goal category. The argument is the name of the goal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Start building better habits" : {
|
"Start building better habits" : {
|
||||||
"comment" : "Subtitle for the \"No Active Rituals\" section in the \"Today\" view when there are no active rituals.",
|
"comment" : "Subtitle for the \"No Active Rituals\" section in the \"Today\" view when there are no active rituals.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2020,6 +2136,10 @@
|
|||||||
"comment" : "A confirmation prompt for starting a new arc for a ritual.",
|
"comment" : "A confirmation prompt for starting a new arc for a ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Start This Ritual" : {
|
||||||
|
"comment" : "The text for the primary action button in the ritual preview step.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Start with stillness" : {
|
"Start with stillness" : {
|
||||||
"comment" : "Theme of the \"Morning Meditation\" ritual preset.",
|
"comment" : "Theme of the \"Morning Meditation\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2028,6 +2148,10 @@
|
|||||||
"comment" : "Theme for the \"Morning Hydration\" ritual preset.",
|
"comment" : "Theme for the \"Morning Hydration\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Start your day right" : {
|
||||||
|
"comment" : "Subtitle for the \"Morning\" option in the \"Time of Day\" section of the onboarding screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Streak" : {
|
"Streak" : {
|
||||||
"comment" : "Title of a section in the Insight Cards view, related to streaks of consecutive perfect days.",
|
"comment" : "Title of a section in the Insight Cards view, related to streaks of consecutive perfect days.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2063,6 +2187,7 @@
|
|||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Switch tabs to explore rituals and insights" : {
|
"Switch tabs to explore rituals and insights" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2250,6 +2375,14 @@
|
|||||||
"comment" : "Label for a breakdown item showing the total number of check-ins made by the user.",
|
"comment" : "Label for a breakdown item showing the total number of check-ins made by the user.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Track your progress and streaks here" : {
|
||||||
|
"comment" : "Text for a Sherpa callout on the Insights tab of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Track your streaks, progress, and trends over time." : {
|
||||||
|
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to use the app's insights feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Transition to rest" : {
|
"Transition to rest" : {
|
||||||
"comment" : "Theme of the \"Evening Wind-Down\" ritual preset.",
|
"comment" : "Theme of the \"Evening Wind-Down\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2285,6 +2418,14 @@
|
|||||||
"comment" : "A label describing the segmented control in the rituals view.",
|
"comment" : "A label describing the segmented control in the rituals view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"View and manage all your rituals, regardless of time." : {
|
||||||
|
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to manage all your rituals, regardless of the time they were created.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"View your check-in history" : {
|
||||||
|
"comment" : "Text for a Sherpa callout on the History tab of the Rituals app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Weekly completion chart" : {
|
"Weekly completion chart" : {
|
||||||
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2293,6 +2434,10 @@
|
|||||||
"comment" : "Name of a ritual preset that users can add to their collection, focusing on preparing for a fresh week.",
|
"comment" : "Name of a ritual preset that users can add to their collection, focusing on preparing for a fresh week.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Welcome to Rituals" : {
|
||||||
|
"comment" : "The title of the welcome screen in the setup wizard.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Wellness" : {
|
"Wellness" : {
|
||||||
"comment" : "The category of the morning ritual.",
|
"comment" : "The category of the morning ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2309,6 +2454,14 @@
|
|||||||
"comment" : "Habit title for a mindfulness ritual where the user writes down one positive thing that happened during the day.",
|
"comment" : "Habit title for a mindfulness ritual where the user writes down one positive thing that happened during the day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"What would you like to focus on?" : {
|
||||||
|
"comment" : "A prompt displayed at the top of the goal selection screen, asking the user what they want to focus on.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"When do you want to build habits?" : {
|
||||||
|
"comment" : "A question displayed at the top of the time selection screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"When you feel scattered, use this to refocus." : {
|
"When you feel scattered, use this to refocus." : {
|
||||||
"comment" : "Title of a ritual preset focused on helping users regain clarity when they feel scattered.",
|
"comment" : "Title of a ritual preset focused on helping users regain clarity when they feel scattered.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2339,6 +2492,10 @@
|
|||||||
"comment" : "Notes section of a ritual preset focused on sleep preparation.",
|
"comment" : "Notes section of a ritual preset focused on sleep preparation.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Wind down with intention" : {
|
||||||
|
"comment" : "Subtitle for the \"Evening\" section of the \"Both\" time preference option in the onboarding flow.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Wind down with quiet, consistent cues." : {
|
"Wind down with quiet, consistent cues." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -2373,10 +2530,18 @@
|
|||||||
"comment" : "A hint displayed below the \"Browse Presets\" button, encouraging users to explore preset rituals.",
|
"comment" : "A hint displayed below the \"Browse Presets\" button, encouraging users to explore preset rituals.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"You completed your first check-in" : {
|
||||||
|
"comment" : "A description of the positive outcome of completing a first check-in.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"You have %lld rituals to complete" : {
|
"You have %lld rituals to complete" : {
|
||||||
"comment" : "Text included in a notification for a reminder that multiple rituals are due. The argument is the number of rituals due.",
|
"comment" : "Text included in a notification for a reminder that multiple rituals are due. The argument is the number of rituals due.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"You're all set!" : {
|
||||||
|
"comment" : "A welcoming message displayed at the end of the wizard.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"You're at your best streak! Keep it going." : {
|
"You're at your best streak! Keep it going." : {
|
||||||
"comment" : "Tip provided when the user is at their longest streak and it is greater than zero.",
|
"comment" : "Tip provided when the user is at their longest streak and it is greater than zero.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2389,7 +2554,12 @@
|
|||||||
"comment" : "Explanation for the \"Streak\" insight card.",
|
"comment" : "Explanation for the \"Streak\" insight card.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Your first ritual" : {
|
||||||
|
"comment" : "The title of the preview step in the ritual creation flow.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Your focus ritual lives here" : {
|
"Your focus ritual lives here" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
125
Andromida/App/Models/OnboardingGoal.swift
Normal file
125
Andromida/App/Models/OnboardingGoal.swift
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// User goals for onboarding that map to preset rituals.
|
||||||
|
enum OnboardingGoal: String, CaseIterable, Identifiable {
|
||||||
|
case health
|
||||||
|
case productivity
|
||||||
|
case mindfulness
|
||||||
|
case selfCare
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .health: return String(localized: "Health")
|
||||||
|
case .productivity: return String(localized: "Focus")
|
||||||
|
case .mindfulness: return String(localized: "Mindfulness")
|
||||||
|
case .selfCare: return String(localized: "Self-Care")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitle: String {
|
||||||
|
switch self {
|
||||||
|
case .health: return String(localized: "Feel better each day")
|
||||||
|
case .productivity: return String(localized: "Get more done")
|
||||||
|
case .mindfulness: return String(localized: "Find your calm")
|
||||||
|
case .selfCare: return String(localized: "Nurture yourself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .health: return "heart.fill"
|
||||||
|
case .productivity: return "bolt.fill"
|
||||||
|
case .mindfulness: return "brain.head.profile"
|
||||||
|
case .selfCare: return "sparkles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps to PresetCategory for filtering presets.
|
||||||
|
var presetCategory: PresetCategory {
|
||||||
|
switch self {
|
||||||
|
case .health: return .health
|
||||||
|
case .productivity: return .productivity
|
||||||
|
case .mindfulness: return .mindfulness
|
||||||
|
case .selfCare: return .selfCare
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User's preferred time for building habits.
|
||||||
|
enum OnboardingTimePreference: String, CaseIterable, Identifiable {
|
||||||
|
case morning
|
||||||
|
case evening
|
||||||
|
case both
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .morning: return String(localized: "Morning")
|
||||||
|
case .evening: return String(localized: "Evening")
|
||||||
|
case .both: return String(localized: "Both")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitle: String {
|
||||||
|
switch self {
|
||||||
|
case .morning: return String(localized: "Start your day right")
|
||||||
|
case .evening: return String(localized: "Wind down with intention")
|
||||||
|
case .both: return String(localized: "Bookend your day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .morning: return "sunrise.fill"
|
||||||
|
case .evening: return "moon.fill"
|
||||||
|
case .both: return "circle.righthalf.filled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the TimeOfDay values to filter presets by.
|
||||||
|
var timeOfDayFilters: [TimeOfDay] {
|
||||||
|
switch self {
|
||||||
|
case .morning: return [.morning]
|
||||||
|
case .evening: return [.evening]
|
||||||
|
case .both: return [.morning, .evening]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for recommending ritual presets based on onboarding selections.
|
||||||
|
enum OnboardingPresetRecommender {
|
||||||
|
|
||||||
|
/// Returns the recommended preset for a given goal and time preference.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - goal: The user's selected goal
|
||||||
|
/// - time: The user's preferred time
|
||||||
|
/// - Returns: A ritual preset, or nil if none matches
|
||||||
|
static func recommendedPreset(for goal: OnboardingGoal, time: OnboardingTimePreference) -> RitualPreset? {
|
||||||
|
let categoryPresets = RitualPresetLibrary.presets(for: goal.presetCategory)
|
||||||
|
|
||||||
|
// Filter by time of day preference
|
||||||
|
let timeFilters = time.timeOfDayFilters
|
||||||
|
let matchingPresets = categoryPresets.filter { preset in
|
||||||
|
timeFilters.contains(preset.timeOfDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first matching preset, or first category preset as fallback
|
||||||
|
return matchingPresets.first ?? categoryPresets.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all recommended presets when user selects "Both" for time.
|
||||||
|
/// - Parameter goal: The user's selected goal
|
||||||
|
/// - Returns: Morning and evening presets for the goal
|
||||||
|
static func recommendedPresets(for goal: OnboardingGoal) -> (morning: RitualPreset?, evening: RitualPreset?) {
|
||||||
|
let categoryPresets = RitualPresetLibrary.presets(for: goal.presetCategory)
|
||||||
|
|
||||||
|
let morningPreset = categoryPresets.first { $0.timeOfDay == .morning }
|
||||||
|
let eveningPreset = categoryPresets.first { $0.timeOfDay == .evening }
|
||||||
|
|
||||||
|
return (morningPreset, eveningPreset)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -579,6 +579,35 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a ritual from a preset template and returns it.
|
||||||
|
/// Used during onboarding to immediately show the created ritual.
|
||||||
|
@discardableResult
|
||||||
|
func createRitual(from preset: RitualPreset) -> Ritual {
|
||||||
|
let habits = preset.habits.map { habitPreset in
|
||||||
|
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName)
|
||||||
|
}
|
||||||
|
let arc = RitualArc(
|
||||||
|
startDate: Date(),
|
||||||
|
durationDays: preset.durationDays,
|
||||||
|
arcNumber: 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: habits
|
||||||
|
)
|
||||||
|
let ritual = Ritual(
|
||||||
|
title: preset.title,
|
||||||
|
theme: preset.theme,
|
||||||
|
defaultDurationDays: preset.durationDays,
|
||||||
|
notes: preset.notes,
|
||||||
|
timeOfDay: preset.timeOfDay,
|
||||||
|
iconName: preset.iconName,
|
||||||
|
category: preset.category,
|
||||||
|
arcs: [arc]
|
||||||
|
)
|
||||||
|
modelContext.insert(ritual)
|
||||||
|
saveContext()
|
||||||
|
return ritual
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates an existing ritual's properties
|
/// Updates an existing ritual's properties
|
||||||
func updateRitual(
|
func updateRitual(
|
||||||
_ ritual: Ritual,
|
_ ritual: Ritual,
|
||||||
|
|||||||
263
Andromida/App/Views/Onboarding/FirstCheckInStepView.swift
Normal file
263
Andromida/App/Views/Onboarding/FirstCheckInStepView.swift
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// Interactive tutorial step where users complete their first habit check-in.
|
||||||
|
struct FirstCheckInStepView: View {
|
||||||
|
@Bindable var store: RitualStore
|
||||||
|
let ritual: Ritual
|
||||||
|
@Binding var hasCompletedCheckIn: Bool
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
@State private var animateContent = false
|
||||||
|
@State private var showCelebration = false
|
||||||
|
@State private var showContinueButton = false
|
||||||
|
|
||||||
|
private var habits: [ArcHabit] {
|
||||||
|
store.habits(for: ritual)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasAnyCheckIn: Bool {
|
||||||
|
habits.contains { store.isHabitCompletedToday($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.large)
|
||||||
|
|
||||||
|
if showCelebration {
|
||||||
|
// Celebration state
|
||||||
|
celebrationView
|
||||||
|
} else {
|
||||||
|
// Check-in tutorial
|
||||||
|
tutorialView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confetti overlay
|
||||||
|
if showCelebration {
|
||||||
|
ConfettiView(
|
||||||
|
colors: [AppAccent.primary, AppAccent.light, AppStatus.success, .yellow, .orange],
|
||||||
|
count: 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: hasAnyCheckIn) { _, completed in
|
||||||
|
if completed && !hasCompletedCheckIn {
|
||||||
|
triggerCelebration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tutorial View
|
||||||
|
|
||||||
|
private var tutorialView: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
// Header
|
||||||
|
Text(String(localized: "Let's try it out!"))
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
|
||||||
|
// Ritual card with habits
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
// Header
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: ritual.iconName)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(String(localized: "Day 1 of \(ritual.durationDays)"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Text(ritual.title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.bottom, Design.Spacing.small)
|
||||||
|
|
||||||
|
// Habits list
|
||||||
|
ForEach(Array(habits.enumerated()), id: \.element.id) { index, habit in
|
||||||
|
OnboardingHabitRowView(
|
||||||
|
title: habit.title,
|
||||||
|
symbolName: habit.symbolName,
|
||||||
|
isCompleted: store.isHabitCompletedToday(habit),
|
||||||
|
isHighlighted: index == 0 && !hasAnyCheckIn,
|
||||||
|
action: { store.toggleHabitCompletion(habit) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
.offset(y: animateContent ? 0 : 20)
|
||||||
|
|
||||||
|
// Instruction
|
||||||
|
Text(String(localized: "Tap a habit to check in"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
|
animateContent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Celebration View
|
||||||
|
|
||||||
|
private var celebrationView: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Success icon
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 80))
|
||||||
|
.foregroundStyle(AppStatus.success)
|
||||||
|
.scaleEffect(showCelebration ? 1 : 0.5)
|
||||||
|
.opacity(showCelebration ? 1 : 0)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Text(String(localized: "Nice work!"))
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(String(localized: "You completed your first check-in"))
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Continue button
|
||||||
|
if showContinueButton {
|
||||||
|
Button(action: onComplete) {
|
||||||
|
Text(String(localized: "Continue to Rituals"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.inverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.Size.buttonHeight)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func triggerCelebration() {
|
||||||
|
hasCompletedCheckIn = true
|
||||||
|
|
||||||
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
|
||||||
|
showCelebration = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show continue button after celebration settles
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
|
showContinueButton = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A habit row styled for the onboarding flow with optional highlight.
|
||||||
|
private struct OnboardingHabitRowView: View {
|
||||||
|
let title: String
|
||||||
|
let symbolName: String
|
||||||
|
let isCompleted: Bool
|
||||||
|
let isHighlighted: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
@State private var pulseAnimation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: symbolName)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(isCompleted ? AppStatus.success : AppAccent.primary)
|
||||||
|
.frame(width: AppMetrics.Size.iconLarge)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Spacer(minLength: Design.Spacing.medium)
|
||||||
|
|
||||||
|
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(isCompleted ? AppStatus.success : AppBorder.subtle)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(isHighlighted ? AppAccent.primary.opacity(0.1) : AppSurface.tertiary)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.stroke(
|
||||||
|
isHighlighted ? AppAccent.primary : Color.clear,
|
||||||
|
lineWidth: 2
|
||||||
|
)
|
||||||
|
.scaleEffect(pulseAnimation && isHighlighted ? 1.02 : 1.0)
|
||||||
|
.opacity(pulseAnimation && isHighlighted ? 0.5 : 1.0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onAppear {
|
||||||
|
if isHighlighted {
|
||||||
|
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
||||||
|
pulseAnimation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
struct FirstCheckInStepView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let store = RitualStore.preview
|
||||||
|
if let ritual = store.currentRituals.first {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
FirstCheckInStepView(
|
||||||
|
store: store,
|
||||||
|
ritual: ritual,
|
||||||
|
hasCompletedCheckIn: .constant(false),
|
||||||
|
onComplete: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
143
Andromida/App/Views/Onboarding/GoalSelectionStepView.swift
Normal file
143
Andromida/App/Views/Onboarding/GoalSelectionStepView.swift
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// The goal selection screen where users choose what they want to focus on.
|
||||||
|
struct GoalSelectionStepView: View {
|
||||||
|
@Binding var selectedGoal: OnboardingGoal?
|
||||||
|
let onContinue: () -> Void
|
||||||
|
|
||||||
|
@State private var animateCards = false
|
||||||
|
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.flexible(), spacing: Design.Spacing.medium),
|
||||||
|
GridItem(.flexible(), spacing: Design.Spacing.medium)
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.large)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Text(String(localized: "What would you like to focus on?"))
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
// Goal cards grid
|
||||||
|
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
||||||
|
ForEach(Array(OnboardingGoal.allCases.enumerated()), id: \.element.id) { index, goal in
|
||||||
|
GoalCardView(
|
||||||
|
goal: goal,
|
||||||
|
isSelected: selectedGoal == goal,
|
||||||
|
onTap: {
|
||||||
|
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
||||||
|
selectedGoal = goal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.opacity(animateCards ? 1 : 0)
|
||||||
|
.offset(y: animateCards ? 0 : 20)
|
||||||
|
.animation(
|
||||||
|
.easeOut(duration: 0.4).delay(Double(index) * 0.1),
|
||||||
|
value: animateCards
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Continue button (only shown when a goal is selected)
|
||||||
|
if selectedGoal != nil {
|
||||||
|
Button(action: onContinue) {
|
||||||
|
Text(String(localized: "Continue"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.inverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.Size.buttonHeight)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xxLarge)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: selectedGoal != nil)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation {
|
||||||
|
animateCards = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single goal card in the selection grid.
|
||||||
|
private struct GoalCardView: View {
|
||||||
|
let goal: OnboardingGoal
|
||||||
|
let isSelected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: goal.symbolName)
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.secondary)
|
||||||
|
|
||||||
|
// Text
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(goal.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(goal.subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.large)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(isSelected ? AppAccent.primary.opacity(0.15) : AppSurface.card)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.stroke(
|
||||||
|
isSelected ? AppAccent.primary : Color.clear,
|
||||||
|
lineWidth: 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(goal.displayName)
|
||||||
|
.accessibilityHint(goal.subtitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
GoalSelectionStepView(
|
||||||
|
selectedGoal: .constant(nil),
|
||||||
|
onContinue: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
178
Andromida/App/Views/Onboarding/RitualPreviewStepView.swift
Normal file
178
Andromida/App/Views/Onboarding/RitualPreviewStepView.swift
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// Shows a preview of a ritual preset before creation.
|
||||||
|
struct RitualPreviewStepView: View {
|
||||||
|
let preset: RitualPreset
|
||||||
|
let ritualIndex: Int? // e.g., 1 for "Ritual 1 of 2"
|
||||||
|
let totalRituals: Int? // e.g., 2
|
||||||
|
let onStartRitual: () -> Void
|
||||||
|
let onSkip: () -> Void
|
||||||
|
|
||||||
|
@State private var animateContent = false
|
||||||
|
|
||||||
|
/// Header text based on whether this is part of a multi-ritual flow
|
||||||
|
private var headerText: String {
|
||||||
|
if let index = ritualIndex, let total = totalRituals {
|
||||||
|
return String(localized: "Ritual \(index) of \(total)")
|
||||||
|
} else {
|
||||||
|
return String(localized: "Your first ritual")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.large)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
Text(headerText)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
|
||||||
|
// Ritual preview card
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
|
// Title and theme
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: preset.iconName)
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(preset.title)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(preset.theme)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(AppBorder.subtle)
|
||||||
|
|
||||||
|
// Habits list
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
ForEach(preset.habits) { habit in
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: habit.symbolName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
Text(habit.title)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(AppBorder.subtle)
|
||||||
|
|
||||||
|
// Duration and time
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: "\(preset.durationDays) days"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
Text(preset.timeOfDay.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: preset.timeOfDay.symbolName)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
.offset(y: animateContent ? 0 : 20)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Primary CTA
|
||||||
|
Button(action: onStartRitual) {
|
||||||
|
Text(String(localized: "Start This Ritual"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.inverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.Size.buttonHeight)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip option
|
||||||
|
Button(action: onSkip) {
|
||||||
|
Text(String(localized: "Skip for now"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
|
animateContent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Single Ritual") {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
RitualPreviewStepView(
|
||||||
|
preset: RitualPresetLibrary.healthPresets[0],
|
||||||
|
ritualIndex: nil,
|
||||||
|
totalRituals: nil,
|
||||||
|
onStartRitual: {},
|
||||||
|
onSkip: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Multi Ritual 1 of 2") {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
RitualPreviewStepView(
|
||||||
|
preset: RitualPresetLibrary.healthPresets[0],
|
||||||
|
ritualIndex: 1,
|
||||||
|
totalRituals: 2,
|
||||||
|
onStartRitual: {},
|
||||||
|
onSkip: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +1,22 @@
|
|||||||
import Sherpa
|
import Sherpa
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sherpa walkthrough tags for post-wizard app exploration.
|
||||||
|
/// The main onboarding (goal selection, ritual creation, first check-in) is handled
|
||||||
|
/// by the SetupWizard. These tags provide optional guidance for exploring the app.
|
||||||
enum RitualsOnboardingTag: SherpaTags {
|
enum RitualsOnboardingTag: SherpaTags {
|
||||||
case focusRitual
|
|
||||||
case firstHabit
|
|
||||||
case tabBar
|
case tabBar
|
||||||
|
case insightsTab
|
||||||
|
case historyTab
|
||||||
|
|
||||||
func makeCallout() -> Callout {
|
func makeCallout() -> Callout {
|
||||||
switch self {
|
switch self {
|
||||||
case .focusRitual:
|
|
||||||
return .text(String(localized: "Your focus ritual lives here"), edge: .bottom)
|
|
||||||
case .firstHabit:
|
|
||||||
return .text(String(localized: "Tap a habit to check in"), edge: .top)
|
|
||||||
case .tabBar:
|
case .tabBar:
|
||||||
return .text(String(localized: "Switch tabs to explore rituals and insights"), edge: .top)
|
return .text(String(localized: "Explore your rituals and insights"), edge: .top)
|
||||||
|
case .insightsTab:
|
||||||
|
return .text(String(localized: "Track your progress and streaks here"), edge: .bottom)
|
||||||
|
case .historyTab:
|
||||||
|
return .text(String(localized: "View your check-in history"), edge: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
330
Andromida/App/Views/Onboarding/SetupWizardView.swift
Normal file
330
Andromida/App/Views/Onboarding/SetupWizardView.swift
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// The setup wizard shown to new users on first launch.
|
||||||
|
/// Guides them through goal selection, time preference, and creates their first ritual(s).
|
||||||
|
struct SetupWizardView: View {
|
||||||
|
@Bindable var store: RitualStore
|
||||||
|
@Bindable var categoryStore: CategoryStore
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
@State private var currentStep: WizardStep = .welcome
|
||||||
|
@State private var selectedGoal: OnboardingGoal?
|
||||||
|
@State private var selectedTime: OnboardingTimePreference?
|
||||||
|
|
||||||
|
// Track created rituals for "Both" flow
|
||||||
|
@State private var morningRitual: Ritual?
|
||||||
|
@State private var eveningRitual: Ritual?
|
||||||
|
@State private var hasCompletedFirstCheckIn = false
|
||||||
|
|
||||||
|
// Presets for "Both" flow
|
||||||
|
@State private var morningPreset: RitualPreset?
|
||||||
|
@State private var eveningPreset: RitualPreset?
|
||||||
|
|
||||||
|
enum WizardStep: Int, CaseIterable {
|
||||||
|
case welcome = 0
|
||||||
|
case goalSelection = 1
|
||||||
|
case timeSelection = 2
|
||||||
|
case morningRitualPreview = 3 // Morning preview (or single for Morning/Evening)
|
||||||
|
case eveningRitualPreview = 4 // Evening preview (only for "Both")
|
||||||
|
case firstCheckIn = 5
|
||||||
|
case whatsNext = 6
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
// Normalize progress based on actual steps shown
|
||||||
|
Double(rawValue) / Double(WizardStep.allCases.count - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the user selected "Both" for time preference
|
||||||
|
private var isBothMode: Bool {
|
||||||
|
selectedTime == .both
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first ritual that was created (for first check-in)
|
||||||
|
private var firstCreatedRitual: Ritual? {
|
||||||
|
morningRitual ?? eveningRitual
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any ritual was created
|
||||||
|
private var hasCreatedRitual: Bool {
|
||||||
|
morningRitual != nil || eveningRitual != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to show the back button
|
||||||
|
private var canGoBack: Bool {
|
||||||
|
switch currentStep {
|
||||||
|
case .welcome, .firstCheckIn, .whatsNext:
|
||||||
|
return false
|
||||||
|
case .goalSelection:
|
||||||
|
return true
|
||||||
|
case .timeSelection:
|
||||||
|
return true
|
||||||
|
case .morningRitualPreview:
|
||||||
|
return true
|
||||||
|
case .eveningRitualPreview:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Background gradient
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header with back button and progress (hidden on welcome and whatsNext)
|
||||||
|
if currentStep != .welcome && currentStep != .whatsNext {
|
||||||
|
headerView
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Group {
|
||||||
|
switch currentStep {
|
||||||
|
case .welcome:
|
||||||
|
WelcomeStepView(onContinue: advanceToNextStep)
|
||||||
|
|
||||||
|
case .goalSelection:
|
||||||
|
GoalSelectionStepView(
|
||||||
|
selectedGoal: $selectedGoal,
|
||||||
|
onContinue: advanceToNextStep
|
||||||
|
)
|
||||||
|
|
||||||
|
case .timeSelection:
|
||||||
|
TimeSelectionStepView(
|
||||||
|
selectedTime: $selectedTime,
|
||||||
|
onContinue: handleTimeSelectionContinue
|
||||||
|
)
|
||||||
|
|
||||||
|
case .morningRitualPreview:
|
||||||
|
if let preset = morningPreset {
|
||||||
|
RitualPreviewStepView(
|
||||||
|
preset: preset,
|
||||||
|
ritualIndex: isBothMode ? 1 : nil,
|
||||||
|
totalRituals: isBothMode ? 2 : nil,
|
||||||
|
onStartRitual: { createMorningRitualAndAdvance() },
|
||||||
|
onSkip: { skipMorningAndAdvance() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .eveningRitualPreview:
|
||||||
|
if let preset = eveningPreset {
|
||||||
|
RitualPreviewStepView(
|
||||||
|
preset: preset,
|
||||||
|
ritualIndex: isBothMode ? 2 : nil,
|
||||||
|
totalRituals: isBothMode ? 2 : nil,
|
||||||
|
onStartRitual: { createEveningRitualAndAdvance() },
|
||||||
|
onSkip: { skipEveningAndAdvance() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .firstCheckIn:
|
||||||
|
if let ritual = firstCreatedRitual {
|
||||||
|
FirstCheckInStepView(
|
||||||
|
store: store,
|
||||||
|
ritual: ritual,
|
||||||
|
hasCompletedCheckIn: $hasCompletedFirstCheckIn,
|
||||||
|
onComplete: { advanceToWhatsNext() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .whatsNext:
|
||||||
|
WhatsNextStepView(onComplete: onComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||||
|
removal: .move(edge: .leading).combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header View
|
||||||
|
|
||||||
|
private var headerView: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Back button row
|
||||||
|
HStack {
|
||||||
|
if canGoBack {
|
||||||
|
Button(action: goBack) {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
Text(String(localized: "Back"))
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
progressIndicator
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Indicator
|
||||||
|
|
||||||
|
private var progressIndicator: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
// Background track
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||||
|
.fill(AppSurface.card)
|
||||||
|
.frame(height: 4)
|
||||||
|
|
||||||
|
// Progress fill
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||||
|
.fill(AppAccent.primary)
|
||||||
|
.frame(width: geometry.size.width * progressValue, height: 4)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjusted progress value that accounts for skipped steps
|
||||||
|
private var progressValue: Double {
|
||||||
|
// For non-Both flows, we skip eveningRitualPreview
|
||||||
|
let totalSteps: Double = isBothMode ? 7 : 6
|
||||||
|
let currentStepValue: Double
|
||||||
|
|
||||||
|
switch currentStep {
|
||||||
|
case .welcome: currentStepValue = 0
|
||||||
|
case .goalSelection: currentStepValue = 1
|
||||||
|
case .timeSelection: currentStepValue = 2
|
||||||
|
case .morningRitualPreview: currentStepValue = 3
|
||||||
|
case .eveningRitualPreview: currentStepValue = 4
|
||||||
|
case .firstCheckIn: currentStepValue = isBothMode ? 5 : 4
|
||||||
|
case .whatsNext: currentStepValue = isBothMode ? 6 : 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentStepValue / (totalSteps - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation Actions
|
||||||
|
|
||||||
|
private func advanceToNextStep() {
|
||||||
|
guard let nextStep = WizardStep(rawValue: currentStep.rawValue + 1) else { return }
|
||||||
|
withAnimation {
|
||||||
|
currentStep = nextStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goBack() {
|
||||||
|
// Handle back navigation with step skipping
|
||||||
|
var targetStep = currentStep.rawValue - 1
|
||||||
|
|
||||||
|
// If going back from firstCheckIn in non-Both mode, skip eveningRitualPreview
|
||||||
|
if currentStep == .firstCheckIn && !isBothMode {
|
||||||
|
targetStep = WizardStep.morningRitualPreview.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard targetStep >= 0,
|
||||||
|
let previousStep = WizardStep(rawValue: targetStep) else { return }
|
||||||
|
withAnimation {
|
||||||
|
currentStep = previousStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func advanceToWhatsNext() {
|
||||||
|
withAnimation {
|
||||||
|
currentStep = .whatsNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Selection Handler
|
||||||
|
|
||||||
|
private func handleTimeSelectionContinue() {
|
||||||
|
guard let goal = selectedGoal, let time = selectedTime else { return }
|
||||||
|
|
||||||
|
// Prepare presets based on time selection
|
||||||
|
switch time {
|
||||||
|
case .morning:
|
||||||
|
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning)
|
||||||
|
eveningPreset = nil
|
||||||
|
|
||||||
|
case .evening:
|
||||||
|
// For evening only, we still use morningRitualPreview step but show evening preset
|
||||||
|
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening)
|
||||||
|
eveningPreset = nil
|
||||||
|
|
||||||
|
case .both:
|
||||||
|
let presets = OnboardingPresetRecommender.recommendedPresets(for: goal)
|
||||||
|
morningPreset = presets.morning
|
||||||
|
eveningPreset = presets.evening
|
||||||
|
}
|
||||||
|
|
||||||
|
advanceToNextStep()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Morning Ritual Actions
|
||||||
|
|
||||||
|
private func createMorningRitualAndAdvance() {
|
||||||
|
guard let preset = morningPreset else { return }
|
||||||
|
morningRitual = store.createRitual(from: preset)
|
||||||
|
advanceFromMorningPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skipMorningAndAdvance() {
|
||||||
|
advanceFromMorningPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func advanceFromMorningPreview() {
|
||||||
|
withAnimation {
|
||||||
|
if isBothMode && eveningPreset != nil {
|
||||||
|
// Go to evening preview
|
||||||
|
currentStep = .eveningRitualPreview
|
||||||
|
} else if hasCreatedRitual {
|
||||||
|
// Go to first check-in
|
||||||
|
currentStep = .firstCheckIn
|
||||||
|
} else {
|
||||||
|
// No rituals created, go to what's next
|
||||||
|
currentStep = .whatsNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Evening Ritual Actions
|
||||||
|
|
||||||
|
private func createEveningRitualAndAdvance() {
|
||||||
|
guard let preset = eveningPreset else { return }
|
||||||
|
eveningRitual = store.createRitual(from: preset)
|
||||||
|
advanceFromEveningPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skipEveningAndAdvance() {
|
||||||
|
advanceFromEveningPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func advanceFromEveningPreview() {
|
||||||
|
withAnimation {
|
||||||
|
if hasCreatedRitual {
|
||||||
|
// Go to first check-in
|
||||||
|
currentStep = .firstCheckIn
|
||||||
|
} else {
|
||||||
|
// No rituals created, go to what's next
|
||||||
|
currentStep = .whatsNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SetupWizardView(
|
||||||
|
store: RitualStore.preview,
|
||||||
|
categoryStore: CategoryStore.preview,
|
||||||
|
onComplete: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
143
Andromida/App/Views/Onboarding/TimeSelectionStepView.swift
Normal file
143
Andromida/App/Views/Onboarding/TimeSelectionStepView.swift
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// The time selection screen where users choose when they want to build habits.
|
||||||
|
struct TimeSelectionStepView: View {
|
||||||
|
@Binding var selectedTime: OnboardingTimePreference?
|
||||||
|
let onContinue: () -> Void
|
||||||
|
|
||||||
|
@State private var animateCards = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.large)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Text(String(localized: "When do you want to build habits?"))
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
// Time preference cards
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
ForEach(Array(OnboardingTimePreference.allCases.enumerated()), id: \.element.id) { index, time in
|
||||||
|
TimeCardView(
|
||||||
|
time: time,
|
||||||
|
isSelected: selectedTime == time,
|
||||||
|
onTap: {
|
||||||
|
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
||||||
|
selectedTime = time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.opacity(animateCards ? 1 : 0)
|
||||||
|
.offset(y: animateCards ? 0 : 20)
|
||||||
|
.animation(
|
||||||
|
.easeOut(duration: 0.4).delay(Double(index) * 0.1),
|
||||||
|
value: animateCards
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Continue button (only shown when a time is selected)
|
||||||
|
if selectedTime != nil {
|
||||||
|
Button(action: onContinue) {
|
||||||
|
Text(String(localized: "Continue"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.inverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.Size.buttonHeight)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xxLarge)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: selectedTime != nil)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation {
|
||||||
|
animateCards = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single time preference card.
|
||||||
|
private struct TimeCardView: View {
|
||||||
|
let time: OnboardingTimePreference
|
||||||
|
let isSelected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: time.symbolName)
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.secondary)
|
||||||
|
.frame(width: 44)
|
||||||
|
|
||||||
|
// Text
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(time.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(time.subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Selection indicator
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(isSelected ? AppAccent.primary.opacity(0.15) : AppSurface.card)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.stroke(
|
||||||
|
isSelected ? AppAccent.primary : Color.clear,
|
||||||
|
lineWidth: 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(time.displayName)
|
||||||
|
.accessibilityHint(time.subtitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
TimeSelectionStepView(
|
||||||
|
selectedTime: .constant(nil),
|
||||||
|
onContinue: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
Andromida/App/Views/Onboarding/WelcomeStepView.swift
Normal file
148
Andromida/App/Views/Onboarding/WelcomeStepView.swift
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// The welcome screen shown as the first step of the setup wizard.
|
||||||
|
struct WelcomeStepView: View {
|
||||||
|
let onContinue: () -> Void
|
||||||
|
|
||||||
|
@State private var animateRings = false
|
||||||
|
@State private var animateText = false
|
||||||
|
@State private var animateButton = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Animated rings visual
|
||||||
|
animatedRingsView
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.opacity(animateRings ? 1 : 0)
|
||||||
|
.scaleEffect(animateRings ? 1 : 0.8)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Text(String(localized: "Welcome to Rituals"))
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(String(localized: "Build lasting habits through focused, time-bound journeys"))
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
.opacity(animateText ? 1 : 0)
|
||||||
|
.offset(y: animateText ? 0 : 20)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Get Started button
|
||||||
|
Button(action: onContinue) {
|
||||||
|
Text(String(localized: "Get Started"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.inverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.Size.buttonHeight)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
.opacity(animateButton ? 1 : 0)
|
||||||
|
.offset(y: animateButton ? 0 : 20)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
startAnimations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animated Rings
|
||||||
|
|
||||||
|
private var animatedRingsView: some View {
|
||||||
|
ZStack {
|
||||||
|
// Outer ring
|
||||||
|
Circle()
|
||||||
|
.stroke(
|
||||||
|
AppAccent.primary.opacity(0.3),
|
||||||
|
style: StrokeStyle(lineWidth: 8, lineCap: .round)
|
||||||
|
)
|
||||||
|
.frame(width: 180, height: 180)
|
||||||
|
.rotationEffect(.degrees(animateRings ? 360 : 0))
|
||||||
|
.animation(
|
||||||
|
.linear(duration: 20).repeatForever(autoreverses: false),
|
||||||
|
value: animateRings
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middle ring with arc
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: 0.7)
|
||||||
|
.stroke(
|
||||||
|
AppAccent.primary.opacity(0.6),
|
||||||
|
style: StrokeStyle(lineWidth: 10, lineCap: .round)
|
||||||
|
)
|
||||||
|
.frame(width: 140, height: 140)
|
||||||
|
.rotationEffect(.degrees(animateRings ? -360 : 0))
|
||||||
|
.animation(
|
||||||
|
.linear(duration: 15).repeatForever(autoreverses: false),
|
||||||
|
value: animateRings
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inner ring with arc
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: 0.5)
|
||||||
|
.stroke(
|
||||||
|
AppAccent.primary,
|
||||||
|
style: StrokeStyle(lineWidth: 12, lineCap: .round)
|
||||||
|
)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.rotationEffect(.degrees(animateRings ? 360 : 0))
|
||||||
|
.animation(
|
||||||
|
.linear(duration: 10).repeatForever(autoreverses: false),
|
||||||
|
value: animateRings
|
||||||
|
)
|
||||||
|
|
||||||
|
// Center icon
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.scaleEffect(animateRings ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 1.5).repeatForever(autoreverses: true),
|
||||||
|
value: animateRings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animations
|
||||||
|
|
||||||
|
private func startAnimations() {
|
||||||
|
// Stagger the animations
|
||||||
|
withAnimation(.easeOut(duration: 0.6)) {
|
||||||
|
animateRings = true
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.easeOut(duration: 0.6).delay(0.3)) {
|
||||||
|
animateText = true
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.easeOut(duration: 0.6).delay(0.6)) {
|
||||||
|
animateButton = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
WelcomeStepView(onContinue: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
138
Andromida/App/Views/Onboarding/WhatsNextStepView.swift
Normal file
138
Andromida/App/Views/Onboarding/WhatsNextStepView.swift
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// Educational screen shown at the end of the wizard explaining the app's main features.
|
||||||
|
struct WhatsNextStepView: View {
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
@State private var animateContent = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(AppStatus.success)
|
||||||
|
|
||||||
|
Text(String(localized: "You're all set!"))
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(String(localized: "Here's how to get the most from Rituals"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
.offset(y: animateContent ? 0 : 20)
|
||||||
|
|
||||||
|
// Feature cards
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
FeatureHelpCard(
|
||||||
|
icon: "sun.max.fill",
|
||||||
|
title: String(localized: "Today"),
|
||||||
|
description: String(localized: "Shows rituals for the current time of day. Check in here daily.")
|
||||||
|
)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
.offset(y: animateContent ? 0 : 20)
|
||||||
|
.animation(.easeOut(duration: 0.4).delay(0.1), value: animateContent)
|
||||||
|
|
||||||
|
FeatureHelpCard(
|
||||||
|
icon: "sparkles",
|
||||||
|
title: String(localized: "Rituals"),
|
||||||
|
description: String(localized: "View and manage all your rituals, regardless of time.")
|
||||||
|
)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
.offset(y: animateContent ? 0 : 20)
|
||||||
|
.animation(.easeOut(duration: 0.4).delay(0.2), value: animateContent)
|
||||||
|
|
||||||
|
FeatureHelpCard(
|
||||||
|
icon: "chart.bar.fill",
|
||||||
|
title: String(localized: "Insights"),
|
||||||
|
description: String(localized: "Track your streaks, progress, and trends over time.")
|
||||||
|
)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
.offset(y: animateContent ? 0 : 20)
|
||||||
|
.animation(.easeOut(duration: 0.4).delay(0.3), value: animateContent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// CTA button
|
||||||
|
Button(action: onComplete) {
|
||||||
|
Text(String(localized: "Let's Go"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.inverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.Size.buttonHeight)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
|
animateContent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card explaining a feature of the app.
|
||||||
|
private struct FeatureHelpCard: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(AppAccent.primary.opacity(0.15))
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
|
||||||
|
// Text
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppSurface.primary, AppSurface.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
WhatsNextStepView(onComplete: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import Sherpa
|
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@Bindable var settingsStore: SettingsStore
|
@Bindable var settingsStore: SettingsStore
|
||||||
@Bindable var categoryStore: CategoryStore
|
@Bindable var categoryStore: CategoryStore
|
||||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var selectedTab: RootTab = .today
|
@State private var selectedTab: RootTab
|
||||||
|
|
||||||
|
/// The available tabs in the app.
|
||||||
enum RootTab: Hashable {
|
enum RootTab: Hashable {
|
||||||
case today
|
case today
|
||||||
case rituals
|
case rituals
|
||||||
@ -17,6 +16,24 @@ struct RootView: View {
|
|||||||
case history
|
case history
|
||||||
case settings
|
case settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a RootView with an optional initial tab.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - store: The ritual store
|
||||||
|
/// - settingsStore: The settings store
|
||||||
|
/// - categoryStore: The category store
|
||||||
|
/// - initialTab: The tab to show on first appearance (defaults to .today)
|
||||||
|
init(
|
||||||
|
store: RitualStore,
|
||||||
|
settingsStore: SettingsStore,
|
||||||
|
categoryStore: CategoryStore,
|
||||||
|
initialTab: RootTab = .today
|
||||||
|
) {
|
||||||
|
self.store = store
|
||||||
|
self.settingsStore = settingsStore
|
||||||
|
self.categoryStore = categoryStore
|
||||||
|
self._selectedTab = State(initialValue: initialTab)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@ -52,13 +69,6 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.tint(AppAccent.primary)
|
.tint(AppAccent.primary)
|
||||||
.background(AppSurface.primary.ignoresSafeArea())
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
.sherpaTabBarTag(RitualsOnboardingTag.tabBar)
|
|
||||||
.sherpa(
|
|
||||||
isActive: !hasCompletedOnboarding,
|
|
||||||
tags: RitualsOnboardingTag.self,
|
|
||||||
delegate: self,
|
|
||||||
startDelay: Bedrock.Design.Animation.standard
|
|
||||||
)
|
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
refreshCurrentTab()
|
refreshCurrentTab()
|
||||||
@ -80,13 +90,3 @@ struct RootView: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
RootView(store: RitualStore.preview, settingsStore: SettingsStore.preview, categoryStore: CategoryStore.preview)
|
RootView(store: RitualStore.preview, settingsStore: SettingsStore.preview, categoryStore: CategoryStore.preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension RootView: SherpaDelegate {
|
|
||||||
func onWalkthroughComplete(sherpa: Sherpa) {
|
|
||||||
hasCompletedOnboarding = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) {
|
|
||||||
hasCompletedOnboarding = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -120,7 +120,9 @@ struct SettingsView: View {
|
|||||||
title: String(localized: "Reset Onboarding"),
|
title: String(localized: "Reset Onboarding"),
|
||||||
iconColor: AppStatus.warning
|
iconColor: AppStatus.warning
|
||||||
) {
|
) {
|
||||||
|
// Reset both the old and new onboarding flags
|
||||||
UserDefaults.standard.removeObject(forKey: "hasCompletedOnboarding")
|
UserDefaults.standard.removeObject(forKey: "hasCompletedOnboarding")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "hasCompletedSetupWizard")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ritualStore {
|
if let ritualStore {
|
||||||
|
|||||||
@ -27,6 +27,23 @@ struct TodayEmptyStateView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
// Quick start goal cards
|
||||||
|
quickStartSection
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
HStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
Text(String(localized: "or"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
Button {
|
Button {
|
||||||
@ -38,15 +55,14 @@ struct TodayEmptyStateView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.bordered)
|
||||||
.tint(AppAccent.primary)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showingPresetLibrary = true
|
showingPresetLibrary = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "sparkles.rectangle.stack")
|
Image(systemName: "sparkles.rectangle.stack")
|
||||||
Text(String(localized: "Browse Presets"))
|
Text(String(localized: "Browse All Presets"))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
@ -74,6 +90,68 @@ struct TodayEmptyStateView: View {
|
|||||||
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: nil)
|
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Start Section
|
||||||
|
|
||||||
|
private var quickStartSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text(String(localized: "Quick Start"))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [
|
||||||
|
GridItem(.flexible(), spacing: Design.Spacing.small),
|
||||||
|
GridItem(.flexible(), spacing: Design.Spacing.small)
|
||||||
|
],
|
||||||
|
spacing: Design.Spacing.small
|
||||||
|
) {
|
||||||
|
ForEach(OnboardingGoal.allCases) { goal in
|
||||||
|
QuickStartButton(goal: goal) {
|
||||||
|
startQuickRitual(for: goal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startQuickRitual(for goal: OnboardingGoal) {
|
||||||
|
// Get the first morning preset for this goal, or any preset
|
||||||
|
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
|
||||||
|
store.createRitual(from: preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact button for quick-starting a ritual from a goal category.
|
||||||
|
private struct QuickStartButton: View {
|
||||||
|
let goal: OnboardingGoal
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: goal.symbolName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
|
||||||
|
Text(goal.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(AppSurface.tertiary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(String(localized: "Start \(goal.displayName) ritual"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import Sherpa
|
|
||||||
|
|
||||||
struct HabitRowModel: Identifiable {
|
struct HabitRowModel: Identifiable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
@ -61,27 +60,17 @@ struct TodayRitualSectionView: View {
|
|||||||
progress: progress,
|
progress: progress,
|
||||||
iconName: iconName
|
iconName: iconName
|
||||||
)
|
)
|
||||||
.sherpaTag(RitualsOnboardingTag.focusRitual)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var habitsList: some View {
|
private var habitsList: some View {
|
||||||
VStack(spacing: Bedrock.Design.Spacing.medium) {
|
VStack(spacing: Bedrock.Design.Spacing.medium) {
|
||||||
if let firstHabit = habitRows.first {
|
ForEach(habitRows) { habit in
|
||||||
TodayHabitRowView(
|
TodayHabitRowView(
|
||||||
title: firstHabit.title,
|
title: habit.title,
|
||||||
symbolName: firstHabit.symbolName,
|
symbolName: habit.symbolName,
|
||||||
isCompleted: firstHabit.isCompleted,
|
isCompleted: habit.isCompleted,
|
||||||
action: firstHabit.action
|
action: habit.action
|
||||||
)
|
)
|
||||||
.sherpaTag(RitualsOnboardingTag.firstHabit)
|
|
||||||
}
|
|
||||||
ForEach(habitRows.dropFirst()) { habit in
|
|
||||||
TodayHabitRowView(
|
|
||||||
title: habit.title,
|
|
||||||
symbolName: habit.symbolName,
|
|
||||||
isCompleted: habit.isCompleted,
|
|
||||||
action: habit.action
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Andromida/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Andromida/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user