Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
99d1db84e9
commit
6be09c3067
@ -1,7 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Bedrock
|
||||
import Sherpa
|
||||
|
||||
@main
|
||||
struct AndromidaApp: App {
|
||||
@ -9,6 +8,10 @@ struct AndromidaApp: App {
|
||||
@State private var store: RitualStore
|
||||
@State private var settingsStore: SettingsStore
|
||||
@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() {
|
||||
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
||||
@ -29,13 +32,33 @@ struct AndromidaApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
SherpaContainerView {
|
||||
ZStack {
|
||||
Color.Branding.primary
|
||||
.ignoresSafeArea()
|
||||
ZStack {
|
||||
Color.Branding.primary
|
||||
.ignoresSafeArea()
|
||||
|
||||
if hasCompletedSetupWizard {
|
||||
// Main app - start on Rituals tab if just completed wizard
|
||||
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" : {
|
||||
|
||||
},
|
||||
"Back" : {
|
||||
"comment" : "A button label that says \"Back\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Before 11am" : {
|
||||
"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.",
|
||||
"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)" : {
|
||||
"comment" : "Description of a habit within a ritual preset, focusing on a specific breathing technique.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -407,10 +419,18 @@
|
||||
"comment" : "Title of a ritual preset focused on using breath to reduce stress and increase focus.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Browse All Presets" : {
|
||||
"comment" : "A button label that takes the user to the preset library.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Browse Presets" : {
|
||||
"comment" : "A button that, when tapped, presents a sheet displaying a list of available ritual presets.",
|
||||
"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." : {
|
||||
"comment" : "Notes section of a ritual preset focused on morning hydration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -560,6 +580,14 @@
|
||||
"comment" : "Tip to consider focusing on fewer habits for better consistency.",
|
||||
"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" : {
|
||||
"comment" : "A button label that lets users continue a ritual with changes they've made.",
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -954,10 +986,22 @@
|
||||
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
|
||||
"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" : {
|
||||
"comment" : "Title of a habit in a ritual preset focused on practicing gratitude.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Find your calm" : {
|
||||
"comment" : "Subtitle for the \"Mindfulness\" onboarding goal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"First check-in" : {
|
||||
"comment" : "Label for the first check-in date in the \"Days Active\" breakdown.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -965,6 +1009,9 @@
|
||||
"First Day" : {
|
||||
"comment" : "Title of the first milestone in a 28-day ritual arc.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Focus" : {
|
||||
|
||||
},
|
||||
"Focus Reset" : {
|
||||
"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.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Get more done" : {
|
||||
"comment" : "Subtitle for the \"Focus\" onboarding goal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Get reminded when it's time for your rituals" : {
|
||||
"comment" : "Default text to show in the reminder subtitle when the ritual store is unavailable.",
|
||||
"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." : {
|
||||
"comment" : "Notes for a ritual preset focused on giving the mind a break from screens.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -1146,10 +1201,18 @@
|
||||
"comment" : "Habit title for a ritual preset that encourages having a real conversation with someone.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Health" : {
|
||||
"comment" : "Display name for the \"Health\" onboarding goal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Herbal tea" : {
|
||||
"comment" : "Habit title for a ritual preset that includes herbal tea as a habit.",
|
||||
"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" : {
|
||||
"comment" : "Title of the history view.",
|
||||
"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.",
|
||||
"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" : {
|
||||
"comment" : "Habit within a RitualPreset related to creating a buffer between your day and sleep.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -1322,6 +1393,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Mindfulness" : {
|
||||
|
||||
},
|
||||
"Momentum at a glance" : {
|
||||
"localizations" : {
|
||||
@ -1425,6 +1499,10 @@
|
||||
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Nice work!" : {
|
||||
"comment" : "A congratulatory message displayed after a successful habit check-in.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Night" : {
|
||||
"comment" : "Name for the time of day after 9pm.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -1550,6 +1628,10 @@
|
||||
"comment" : "Subtitle text for the reminder toggle when notifications are disabled in settings.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Nurture yourself" : {
|
||||
"comment" : "Subtitle for the Self-Care goal in the onboarding screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"of %lld" : {
|
||||
|
||||
},
|
||||
@ -1561,6 +1643,10 @@
|
||||
"comment" : "Title for a milestone that occurs one week into a ritual arc.",
|
||||
"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." : {
|
||||
"comment" : "A footnote displayed below the \"Create\" button in the \"No Active Rituals\" view, encouraging users to explore their past rituals.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -1674,6 +1760,10 @@
|
||||
"comment" : "Theme for a ritual preset that encourages reflection and processing of the day's events.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Quick Start" : {
|
||||
"comment" : "A section header for quick start actions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Read 10 pages" : {
|
||||
"localizations" : {
|
||||
"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." : {
|
||||
"comment" : "Explanation of the insight card titled \"Active\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -1893,6 +1995,9 @@
|
||||
"Search icons (e.g., heart, star, book)" : {
|
||||
"comment" : "A prompt for searching through habit icons.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Self-Care" : {
|
||||
|
||||
},
|
||||
"Set 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.",
|
||||
"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" : {
|
||||
"comment" : "Theme of the \"Sleep Preparation\" ritual preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2008,6 +2120,10 @@
|
||||
"comment" : "A button that starts a new arc for a ritual.",
|
||||
"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" : {
|
||||
"comment" : "Subtitle for the \"No Active Rituals\" section in the \"Today\" view when there are no active rituals.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2020,6 +2136,10 @@
|
||||
"comment" : "A confirmation prompt for starting a new arc for a ritual.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Start This Ritual" : {
|
||||
"comment" : "The text for the primary action button in the ritual preview step.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Start with stillness" : {
|
||||
"comment" : "Theme of the \"Morning Meditation\" ritual preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2028,6 +2148,10 @@
|
||||
"comment" : "Theme for the \"Morning Hydration\" ritual preset.",
|
||||
"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" : {
|
||||
"comment" : "Title of a section in the Insight Cards view, related to streaks of consecutive perfect days.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2063,6 +2187,7 @@
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Switch tabs to explore rituals and insights" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -2250,6 +2375,14 @@
|
||||
"comment" : "Label for a breakdown item showing the total number of check-ins made by the user.",
|
||||
"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" : {
|
||||
"comment" : "Theme of the \"Evening Wind-Down\" ritual preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2285,6 +2418,14 @@
|
||||
"comment" : "A label describing the segmented control in the rituals view.",
|
||||
"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" : {
|
||||
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
||||
"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.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Welcome to Rituals" : {
|
||||
"comment" : "The title of the welcome screen in the setup wizard.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Wellness" : {
|
||||
"comment" : "The category of the morning ritual.",
|
||||
"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.",
|
||||
"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." : {
|
||||
"comment" : "Title of a ritual preset focused on helping users regain clarity when they feel scattered.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2339,6 +2492,10 @@
|
||||
"comment" : "Notes section of a ritual preset focused on sleep preparation.",
|
||||
"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." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -2373,10 +2530,18 @@
|
||||
"comment" : "A hint displayed below the \"Browse Presets\" button, encouraging users to explore preset rituals.",
|
||||
"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" : {
|
||||
"comment" : "Text included in a notification for a reminder that multiple rituals are due. The argument is the number of rituals due.",
|
||||
"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." : {
|
||||
"comment" : "Tip provided when the user is at their longest streak and it is greater than zero.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2389,7 +2554,12 @@
|
||||
"comment" : "Explanation for the \"Streak\" insight card.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your first ritual" : {
|
||||
"comment" : "The title of the preview step in the ritual creation flow.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your focus ritual lives here" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"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
|
||||
func updateRitual(
|
||||
_ 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 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 {
|
||||
case focusRitual
|
||||
case firstHabit
|
||||
case tabBar
|
||||
|
||||
case insightsTab
|
||||
case historyTab
|
||||
|
||||
func makeCallout() -> Callout {
|
||||
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:
|
||||
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 Bedrock
|
||||
import Sherpa
|
||||
|
||||
struct RootView: View {
|
||||
@Bindable var store: RitualStore
|
||||
@Bindable var settingsStore: SettingsStore
|
||||
@Bindable var categoryStore: CategoryStore
|
||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||
@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 {
|
||||
case today
|
||||
case rituals
|
||||
@ -17,6 +16,24 @@ struct RootView: View {
|
||||
case history
|
||||
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 {
|
||||
TabView(selection: $selectedTab) {
|
||||
@ -52,13 +69,6 @@ struct RootView: View {
|
||||
}
|
||||
.tint(AppAccent.primary)
|
||||
.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
|
||||
if newPhase == .active {
|
||||
refreshCurrentTab()
|
||||
@ -80,13 +90,3 @@ struct RootView: View {
|
||||
#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"),
|
||||
iconColor: AppStatus.warning
|
||||
) {
|
||||
// Reset both the old and new onboarding flags
|
||||
UserDefaults.standard.removeObject(forKey: "hasCompletedOnboarding")
|
||||
UserDefaults.standard.removeObject(forKey: "hasCompletedSetupWizard")
|
||||
}
|
||||
|
||||
if let ritualStore {
|
||||
|
||||
@ -27,6 +27,23 @@ struct TodayEmptyStateView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.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
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
Button {
|
||||
@ -38,15 +55,14 @@ struct TodayEmptyStateView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(AppAccent.primary)
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
showingPresetLibrary = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "sparkles.rectangle.stack")
|
||||
Text(String(localized: "Browse Presets"))
|
||||
Text(String(localized: "Browse All Presets"))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@ -74,6 +90,68 @@ struct TodayEmptyStateView: View {
|
||||
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 {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Sherpa
|
||||
|
||||
struct HabitRowModel: Identifiable {
|
||||
let id: UUID
|
||||
@ -61,27 +60,17 @@ struct TodayRitualSectionView: View {
|
||||
progress: progress,
|
||||
iconName: iconName
|
||||
)
|
||||
.sherpaTag(RitualsOnboardingTag.focusRitual)
|
||||
}
|
||||
|
||||
private var habitsList: some View {
|
||||
VStack(spacing: Bedrock.Design.Spacing.medium) {
|
||||
if let firstHabit = habitRows.first {
|
||||
ForEach(habitRows) { habit in
|
||||
TodayHabitRowView(
|
||||
title: firstHabit.title,
|
||||
symbolName: firstHabit.symbolName,
|
||||
isCompleted: firstHabit.isCompleted,
|
||||
action: firstHabit.action
|
||||
title: habit.title,
|
||||
symbolName: habit.symbolName,
|
||||
isCompleted: habit.isCompleted,
|
||||
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" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user