Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-26 15:12:11 -06:00
parent 99d1db84e9
commit 6be09c3067
18 changed files with 1817 additions and 54 deletions

View File

@ -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
}
}
)
}
}
}

View File

@ -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" : {

View 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)
}
}

View File

@ -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,

View 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

View 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: {}
)
}
}

View 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: {}
)
}
}

View File

@ -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)
}
}
}

View 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: {}
)
}

View 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: {}
)
}
}

View 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: {})
}
}

View 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: {})
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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
)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"