diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index f33575b..8378a3b 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -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 + } + } + ) } } } diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index 6ba92fb..d1e0a71 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -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" : { diff --git a/Andromida/App/Models/OnboardingGoal.swift b/Andromida/App/Models/OnboardingGoal.swift new file mode 100644 index 0000000..8775c3c --- /dev/null +++ b/Andromida/App/Models/OnboardingGoal.swift @@ -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) + } +} diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 4fe3388..77af853 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -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, diff --git a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift new file mode 100644 index 0000000..c57297e --- /dev/null +++ b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift @@ -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 diff --git a/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift new file mode 100644 index 0000000..f614314 --- /dev/null +++ b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift @@ -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: {} + ) + } +} diff --git a/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift b/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift new file mode 100644 index 0000000..0d8a0aa --- /dev/null +++ b/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift @@ -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: {} + ) + } +} diff --git a/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift b/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift index defb266..21622c0 100644 --- a/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift +++ b/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift @@ -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) } } } diff --git a/Andromida/App/Views/Onboarding/SetupWizardView.swift b/Andromida/App/Views/Onboarding/SetupWizardView.swift new file mode 100644 index 0000000..eb2f904 --- /dev/null +++ b/Andromida/App/Views/Onboarding/SetupWizardView.swift @@ -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: {} + ) +} diff --git a/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift b/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift new file mode 100644 index 0000000..25556fa --- /dev/null +++ b/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift @@ -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: {} + ) + } +} diff --git a/Andromida/App/Views/Onboarding/WelcomeStepView.swift b/Andromida/App/Views/Onboarding/WelcomeStepView.swift new file mode 100644 index 0000000..4588411 --- /dev/null +++ b/Andromida/App/Views/Onboarding/WelcomeStepView.swift @@ -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: {}) + } +} diff --git a/Andromida/App/Views/Onboarding/WhatsNextStepView.swift b/Andromida/App/Views/Onboarding/WhatsNextStepView.swift new file mode 100644 index 0000000..971f1ee --- /dev/null +++ b/Andromida/App/Views/Onboarding/WhatsNextStepView.swift @@ -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: {}) + } +} diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index 52a87c3..7a7ae97 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -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 - } -} diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index ec2c8a0..fcec039 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -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 { diff --git a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift index 8b268e8..20c868c 100644 --- a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift +++ b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift @@ -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 { diff --git a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift index 47e1fb3..403300b 100644 --- a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift +++ b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift @@ -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 - ) } } } diff --git a/Andromida/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Andromida/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..5b5035c Binary files /dev/null and b/Andromida/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json b/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..ce8e776 100644 --- a/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Andromida/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024"