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

This commit is contained in:
Matt Bruce 2026-01-26 16:51:26 -06:00
parent 6be09c3067
commit 9ade3b00ea
18 changed files with 433 additions and 314 deletions

View File

@ -8,7 +8,6 @@
/* Begin PBXBuildFile section */
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04AED2F26BD5B007F87EA /* Bedrock */; };
EAC04B7F2F26C478007F87EA /* Sherpa in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04B7E2F26C478007F87EA /* Sherpa */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -58,7 +57,6 @@
buildActionMask = 2147483647;
files = (
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */,
EAC04B7F2F26C478007F87EA /* Sherpa in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -120,7 +118,6 @@
name = Andromida;
packageProductDependencies = (
EAC04AED2F26BD5B007F87EA /* Bedrock */,
EAC04B7E2F26C478007F87EA /* Sherpa */,
);
productName = Andromida;
productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */;
@ -206,7 +203,6 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
EAC04AEC2F26BD5B007F87EA /* XCLocalSwiftPackageReference "../Bedrock" */,
EAC04B7D2F26C478007F87EA /* XCLocalSwiftPackageReference "../Sherpa" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = EAC04A992F26BAE8007F87EA /* Products */;
@ -601,10 +597,6 @@
isa = XCLocalSwiftPackageReference;
relativePath = ../Bedrock;
};
EAC04B7D2F26C478007F87EA /* XCLocalSwiftPackageReference "../Sherpa" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../Sherpa;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -612,10 +604,6 @@
isa = XCSwiftPackageProductDependency;
productName = Bedrock;
};
EAC04B7E2F26C478007F87EA /* Sherpa */ = {
isa = XCSwiftPackageProductDependency;
productName = Sherpa;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = EAC04A902F26BAE8007F87EA /* Project object */;

View File

@ -7,7 +7,7 @@
<key>Andromida.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>2</integer>
</dict>
</dict>
</dict>

View File

@ -986,10 +986,6 @@
"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
@ -1499,6 +1495,29 @@
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
"isCommentAutoGenerated" : true
},
"Next ritual: Tomorrow %@ (%@)" : {
"comment" : "A one-line hint in the Today empty state indicating the next ritual scheduled for tomorrow. The first argument is the time of day, the second is its time range.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Next ritual: Tomorrow %1$@ (%2$@)"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Próximo ritual: Mañana %1$@ (%2$@)"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Prochain rituel : Demain %1$@ (%2$@)"
}
}
}
},
"Nice work!" : {
"comment" : "A congratulatory message displayed after a successful habit check-in.",
"isCommentAutoGenerated" : true
@ -1832,9 +1851,28 @@
"comment" : "Title of a toggle in the settings view that controls whether reminders are enabled.",
"isCommentAutoGenerated" : true
},
"Reset Onboarding" : {
"comment" : "Title of a navigation row in the Settings view that resets the user's onboarding state.",
"isCommentAutoGenerated" : true
"Reset Setup Wizard" : {
"comment" : "Title of a navigation row in the Settings view that resets the setup wizard state.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Reset Setup Wizard"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Restablecer asistente de configuración"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Réinitialiser lassistant de configuration"
}
}
}
},
"Rest better tonight" : {
"comment" : "Notes for a ritual preset focused on sleep preparation.",
@ -2375,10 +2413,6 @@
"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
@ -2422,10 +2456,6 @@
"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

View File

@ -0,0 +1,28 @@
import Foundation
import os
enum PerformanceLogger {
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "Andromida",
category: "Performance"
)
static func measure<T>(_ name: String, _ block: () -> T) -> T {
#if DEBUG
let start = CFAbsoluteTimeGetCurrent()
let result = block()
let duration = CFAbsoluteTimeGetCurrent() - start
logger.info("\(name, privacy: .public) took \(duration, format: .fixed(precision: 3))s")
return result
#else
return block()
#endif
}
static func logDuration(_ name: String, from start: CFAbsoluteTime) {
#if DEBUG
let duration = CFAbsoluteTimeGetCurrent() - start
logger.info("\(name, privacy: .public) took \(duration, format: .fixed(precision: 3))s")
#endif
}
}

View File

@ -14,7 +14,13 @@ final class RitualStore: RitualStoreProviding {
@ObservationIgnored private let displayFormatter: DateFormatter
private(set) var rituals: [Ritual] = []
private(set) var currentRituals: [Ritual] = []
private(set) var pastRituals: [Ritual] = []
private(set) var lastErrorMessage: String?
private var analyticsNeedsRefresh = true
private var cachedDatesWithActivity: Set<Date> = []
private var cachedPerfectDayIDs: Set<String> = []
private var pendingReminderTask: Task<Void, Never>?
/// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler()
@ -64,8 +70,10 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time.
func refresh() {
reloadRituals()
checkForCompletedArcs()
PerformanceLogger.measure("RitualStore.refresh") {
reloadRituals()
checkForCompletedArcs()
}
}
func ritualProgress(for ritual: Ritual) -> Double {
@ -129,20 +137,6 @@ final class RitualStore: RitualStoreProviding {
// MARK: - Ritual Management
/// Rituals with active arcs, sorted by time of day
var currentRituals: [Ritual] {
rituals
.filter { $0.hasActiveArc }
.sorted { $0.timeOfDay < $1.timeOfDay }
}
/// Rituals without active arcs (completed or not renewed), sorted by most recently ended
var pastRituals: [Ritual] {
rituals
.filter { !$0.hasActiveArc }
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
}
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
@ -263,26 +257,8 @@ final class RitualStore: RitualStoreProviding {
/// Returns the set of all day IDs that had 100% completion across all active arcs for that day
private func perfectDays() -> Set<String> {
// Get all dates that have any activity
let activeDates = datesWithActivity()
guard !activeDates.isEmpty else { return [] }
// For each date, check if all habits in all active arcs were completed
var perfectDayIDs: Set<String> = []
for date in activeDates {
let dayID = dayIdentifier(for: date)
let activeHabits = habitsActive(on: date)
guard !activeHabits.isEmpty else { continue }
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
if allCompleted {
perfectDayIDs.insert(dayID)
}
}
return perfectDayIDs
refreshAnalyticsCacheIfNeeded()
return cachedPerfectDayIDs
}
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
@ -425,84 +401,86 @@ final class RitualStore: RitualStoreProviding {
}
func insightCards() -> [InsightCard] {
// Only count habits from active arcs for today's stats
let activeHabitsToday = habitsActive(on: Date())
let totalHabits = activeHabitsToday.count
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
return PerformanceLogger.measure("RitualStore.insightCards") {
// Only count habits from active arcs for today's stats
let activeHabitsToday = habitsActive(on: Date())
let totalHabits = activeHabitsToday.count
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
// Days active = unique calendar days with at least one check-in
let daysActiveCount = datesWithActivity().count
// Days active = unique calendar days with at least one check-in
let daysActiveCount = datesWithActivity().count
// Count rituals with active arcs
let activeRitualCount = currentRituals.count
// Count rituals with active arcs
let activeRitualCount = currentRituals.count
// Build per-ritual progress breakdown
let habitsBreakdown = currentRituals.map { ritual in
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
return BreakdownItem(
label: ritual.title,
value: "\(completed) of \(ritual.habits.count)"
)
// Build per-ritual progress breakdown
let habitsBreakdown = currentRituals.map { ritual in
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
return BreakdownItem(
label: ritual.title,
value: "\(completed) of \(ritual.habits.count)"
)
}
// Streak tracking
let current = currentStreak()
let longest = longestStreak()
let streakBreakdown = [
BreakdownItem(label: String(localized: "Current streak"), value: "\(current) days"),
BreakdownItem(label: String(localized: "Longest streak"), value: "\(longest) days")
]
// Weekly trend
let trendData = weeklyTrendData()
let trendBreakdown = trendData.map { point in
BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%")
}
return [
InsightCard(
title: String(localized: "Active"),
value: "\(activeRitualCount)",
caption: String(localized: "In progress now"),
explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
symbolName: "sparkles",
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
),
InsightCard(
title: String(localized: "Streak"),
value: "\(current)",
caption: String(localized: "Consecutive perfect days"),
explanation: String(localized: "Your current streak of consecutive days with 100% habit completion. Complete all your habits today to keep the streak going!"),
symbolName: "flame.fill",
breakdown: streakBreakdown
),
InsightCard(
title: String(localized: "Habits today"),
value: "\(completedToday)",
caption: String(localized: "Completed today"),
explanation: String(localized: "The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals."),
symbolName: "checkmark.circle.fill",
breakdown: habitsBreakdown
),
InsightCard(
title: String(localized: "Completion"),
value: "\(completionRateValue)%",
caption: String(localized: "Today's progress"),
explanation: String(localized: "Your completion percentage for today across all rituals. The chart shows your last 7 days—this helps you spot patterns and stay consistent."),
symbolName: "chart.bar.fill",
breakdown: trendBreakdown,
trendData: trendData
),
InsightCard(
title: String(localized: "Days Active"),
value: "\(daysActiveCount)",
caption: String(localized: "Days you checked in"),
explanation: String(localized: "This counts every unique calendar day where you completed at least one habit. It's calculated by scanning all your habit check-ins across all rituals and counting the distinct days. For example, if you checked in on Monday, skipped Tuesday, then checked in Wednesday and Thursday, your Days Active would be 3."),
symbolName: "calendar",
breakdown: daysActiveBreakdown()
)
]
}
// Streak tracking
let current = currentStreak()
let longest = longestStreak()
let streakBreakdown = [
BreakdownItem(label: String(localized: "Current streak"), value: "\(current) days"),
BreakdownItem(label: String(localized: "Longest streak"), value: "\(longest) days")
]
// Weekly trend
let trendData = weeklyTrendData()
let trendBreakdown = trendData.map { point in
BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%")
}
return [
InsightCard(
title: String(localized: "Active"),
value: "\(activeRitualCount)",
caption: String(localized: "In progress now"),
explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
symbolName: "sparkles",
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
),
InsightCard(
title: String(localized: "Streak"),
value: "\(current)",
caption: String(localized: "Consecutive perfect days"),
explanation: String(localized: "Your current streak of consecutive days with 100% habit completion. Complete all your habits today to keep the streak going!"),
symbolName: "flame.fill",
breakdown: streakBreakdown
),
InsightCard(
title: String(localized: "Habits today"),
value: "\(completedToday)",
caption: String(localized: "Completed today"),
explanation: String(localized: "The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals."),
symbolName: "checkmark.circle.fill",
breakdown: habitsBreakdown
),
InsightCard(
title: String(localized: "Completion"),
value: "\(completionRateValue)%",
caption: String(localized: "Today's progress"),
explanation: String(localized: "Your completion percentage for today across all rituals. The chart shows your last 7 days—this helps you spot patterns and stay consistent."),
symbolName: "chart.bar.fill",
breakdown: trendBreakdown,
trendData: trendData
),
InsightCard(
title: String(localized: "Days Active"),
value: "\(daysActiveCount)",
caption: String(localized: "Days you checked in"),
explanation: String(localized: "This counts every unique calendar day where you completed at least one habit. It's calculated by scanning all your habit check-ins across all rituals and counting the distinct days. For example, if you checked in on Monday, skipped Tuesday, then checked in Wednesday and Thursday, your Days Active would be 3."),
symbolName: "calendar",
breakdown: daysActiveBreakdown()
)
]
}
func createQuickRitual() {
@ -658,14 +636,15 @@ final class RitualStore: RitualStoreProviding {
}
private func reloadRituals() {
do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
// Update reminder scheduling when rituals change
Task {
await reminderScheduler.updateReminders(for: rituals)
PerformanceLogger.measure("RitualStore.reloadRituals") {
do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
updateDerivedData()
invalidateAnalyticsCache()
scheduleReminderUpdate()
} catch {
lastErrorMessage = error.localizedDescription
}
} catch {
lastErrorMessage = error.localizedDescription
}
}
@ -678,6 +657,76 @@ final class RitualStore: RitualStoreProviding {
}
}
private func updateDerivedData() {
currentRituals = rituals
.filter { $0.hasActiveArc }
.sorted { $0.timeOfDay < $1.timeOfDay }
pastRituals = rituals
.filter { !$0.hasActiveArc }
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
}
private func refreshAnalyticsCacheIfNeeded() {
guard analyticsNeedsRefresh else { return }
cachedDatesWithActivity = computeDatesWithActivity()
cachedPerfectDayIDs = computePerfectDays(from: cachedDatesWithActivity)
analyticsNeedsRefresh = false
}
private func invalidateAnalyticsCache() {
analyticsNeedsRefresh = true
}
private func computeDatesWithActivity() -> Set<Date> {
PerformanceLogger.measure("RitualStore.computeDatesWithActivity") {
var dates: Set<Date> = []
for ritual in rituals {
for arc in ritual.arcs {
for habit in arc.habits {
for dayID in habit.completedDayIDs {
if let date = dayFormatter.date(from: dayID) {
dates.insert(calendar.startOfDay(for: date))
}
}
}
}
}
return dates
}
}
private func computePerfectDays(from activeDates: Set<Date>) -> Set<String> {
guard !activeDates.isEmpty else { return [] }
var perfectDayIDs: Set<String> = []
for date in activeDates {
let dayID = dayIdentifier(for: date)
let activeHabits = habitsActive(on: date)
guard !activeHabits.isEmpty else { continue }
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
if allCompleted {
perfectDayIDs.insert(dayID)
}
}
return perfectDayIDs
}
private func scheduleReminderUpdate() {
pendingReminderTask?.cancel()
let ritualsSnapshot = rituals
pendingReminderTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await reminderScheduler.updateReminders(for: ritualsSnapshot)
}
}
private func dayIdentifier(for date: Date) -> String {
dayFormatter.string(from: date)
}
@ -710,21 +759,8 @@ final class RitualStore: RitualStoreProviding {
/// Returns all dates that have any habit activity.
func datesWithActivity() -> Set<Date> {
var dates: Set<Date> = []
for ritual in rituals {
for arc in ritual.arcs {
for habit in arc.habits {
for dayID in habit.completedDayIDs {
if let date = dayFormatter.date(from: dayID) {
dates.insert(calendar.startOfDay(for: date))
}
}
}
}
}
return dates
refreshAnalyticsCacheIfNeeded()
return cachedDatesWithActivity
}
/// Returns the earliest date with any ritual activity.
@ -922,6 +958,11 @@ final class RitualStore: RitualStoreProviding {
)
}
/// Precomputes analytics data if it is invalidated.
func refreshAnalyticsIfNeeded() {
refreshAnalyticsCacheIfNeeded()
}
// MARK: - Debug / Demo Data
#if DEBUG

View File

@ -20,6 +20,7 @@ struct HistoryView: View {
@State private var selectedRitual: Ritual?
@State private var selectedDateItem: IdentifiableDate?
@State private var showingExpandedHistory = false
@State private var refreshToken = UUID()
private let calendar = Calendar.current
@ -79,6 +80,7 @@ struct HistoryView: View {
}
)
}
.id(refreshToken)
}
.padding(Design.Spacing.large)
}
@ -87,8 +89,11 @@ struct HistoryView: View {
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.onAppear {
store.refresh()
.onChange(of: store.rituals) { _, newRituals in
if let selectedRitual {
self.selectedRitual = newRituals.first { $0.id == selectedRitual.id }
}
refreshToken = UUID()
}
.sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet(

View File

@ -3,6 +3,7 @@ import Bedrock
struct InsightsView: View {
@Bindable var store: RitualStore
@State private var refreshToken = UUID()
private let columns = [
GridItem(.adaptive(minimum: AppMetrics.Size.insightCardMinWidth), spacing: Design.Spacing.medium)
@ -21,6 +22,7 @@ struct InsightsView: View {
InsightCardView(card: card, store: store)
}
}
.id(refreshToken)
}
.padding(Design.Spacing.large)
}
@ -30,7 +32,11 @@ struct InsightsView: View {
endPoint: .bottomTrailing
))
.onAppear {
store.refresh()
store.refreshAnalyticsIfNeeded()
}
.onChange(of: store.rituals) { _, _ in
store.refreshAnalyticsIfNeeded()
refreshToken = UUID()
}
}
}

View File

@ -109,6 +109,28 @@ struct FirstCheckInStepView: View {
.opacity(animateContent ? 1 : 0)
Spacer()
VStack(spacing: Design.Spacing.medium) {
Button(action: onComplete) {
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))
}
Button(action: onComplete) {
Text(String(localized: "Skip for now"))
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
}
}
.padding(.horizontal, Design.Spacing.xxLarge)
Spacer()
.frame(height: Design.Spacing.xxLarge)
}
.onAppear {
withAnimation(.easeOut(duration: 0.5)) {

View File

@ -3,7 +3,7 @@ import Bedrock
/// The goal selection screen where users choose what they want to focus on.
struct GoalSelectionStepView: View {
@Binding var selectedGoal: OnboardingGoal?
@Binding var selectedGoals: [OnboardingGoal]
let onContinue: () -> Void
@State private var animateCards = false
@ -33,10 +33,10 @@ struct GoalSelectionStepView: View {
ForEach(Array(OnboardingGoal.allCases.enumerated()), id: \.element.id) { index, goal in
GoalCardView(
goal: goal,
isSelected: selectedGoal == goal,
isSelected: selectedGoals.contains(goal),
onTap: {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
selectedGoal = goal
toggleGoalSelection(goal)
}
}
)
@ -53,7 +53,7 @@ struct GoalSelectionStepView: View {
Spacer()
// Continue button (only shown when a goal is selected)
if selectedGoal != nil {
if !selectedGoals.isEmpty {
Button(action: onContinue) {
Text(String(localized: "Continue"))
.font(.headline)
@ -68,13 +68,21 @@ struct GoalSelectionStepView: View {
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: Design.Animation.quick), value: selectedGoal != nil)
.animation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty)
.onAppear {
withAnimation {
animateCards = true
}
}
}
private func toggleGoalSelection(_ goal: OnboardingGoal) {
if let index = selectedGoals.firstIndex(of: goal) {
selectedGoals.remove(at: index)
} else {
selectedGoals.append(goal)
}
}
}
/// A single goal card in the selection grid.
@ -136,7 +144,7 @@ private struct GoalCardView: View {
.ignoresSafeArea()
GoalSelectionStepView(
selectedGoal: .constant(nil),
selectedGoals: .constant([]),
onContinue: {}
)
}

View File

@ -1,22 +0,0 @@
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 tabBar
case insightsTab
case historyTab
func makeCallout() -> Callout {
switch self {
case .tabBar:
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

@ -9,31 +9,24 @@ struct SetupWizardView: View {
let onComplete: () -> Void
@State private var currentStep: WizardStep = .welcome
@State private var selectedGoal: OnboardingGoal?
@State private var selectedGoals: [OnboardingGoal] = []
@State private var selectedTime: OnboardingTimePreference?
// Track created rituals for "Both" flow
@State private var morningRitual: Ritual?
@State private var eveningRitual: Ritual?
// Track created rituals during onboarding
@State private var createdRituals: [Ritual] = []
@State private var hasCompletedFirstCheckIn = false
// Presets for "Both" flow
@State private var morningPreset: RitualPreset?
@State private var eveningPreset: RitualPreset?
// Presets for preview flow
@State private var pendingPresets: [RitualPreset] = []
@State private var currentPresetIndex: Int = 0
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)
}
case ritualPreview = 3
case firstCheckIn = 4
case whatsNext = 5
}
/// Whether the user selected "Both" for time preference
@ -43,12 +36,21 @@ struct SetupWizardView: View {
/// The first ritual that was created (for first check-in)
private var firstCreatedRitual: Ritual? {
morningRitual ?? eveningRitual
createdRituals.first
}
/// Whether any ritual was created
private var hasCreatedRitual: Bool {
morningRitual != nil || eveningRitual != nil
!createdRituals.isEmpty
}
private var currentPreset: RitualPreset? {
guard currentPresetIndex < pendingPresets.count else { return nil }
return pendingPresets[currentPresetIndex]
}
private var totalPresets: Int {
pendingPresets.count
}
/// Whether to show the back button
@ -60,9 +62,7 @@ struct SetupWizardView: View {
return true
case .timeSelection:
return true
case .morningRitualPreview:
return true
case .eveningRitualPreview:
case .ritualPreview:
return true
}
}
@ -93,7 +93,7 @@ struct SetupWizardView: View {
case .goalSelection:
GoalSelectionStepView(
selectedGoal: $selectedGoal,
selectedGoals: $selectedGoals,
onContinue: advanceToNextStep
)
@ -103,25 +103,14 @@ struct SetupWizardView: View {
onContinue: handleTimeSelectionContinue
)
case .morningRitualPreview:
if let preset = morningPreset {
case .ritualPreview:
if let preset = currentPreset {
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() }
ritualIndex: totalPresets > 1 ? currentPresetIndex + 1 : nil,
totalRituals: totalPresets > 1 ? totalPresets : nil,
onStartRitual: { createCurrentRitualAndAdvance() },
onSkip: { skipCurrentAndAdvance() }
)
}
@ -196,21 +185,20 @@ struct SetupWizardView: View {
/// 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
case .welcome:
return 0.0
case .goalSelection:
return 0.25
case .timeSelection:
return 0.5
case .ritualPreview:
return 0.7
case .firstCheckIn:
return 0.9
case .whatsNext:
return 1.0
}
return currentStepValue / (totalSteps - 1)
}
// MARK: - Navigation Actions
@ -223,14 +211,14 @@ struct SetupWizardView: View {
}
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
if currentStep == .ritualPreview, currentPresetIndex > 0 {
withAnimation {
currentPresetIndex -= 1
}
return
}
let targetStep = currentStep.rawValue - 1
guard targetStep >= 0,
let previousStep = WizardStep(rawValue: targetStep) else { return }
withAnimation {
@ -247,70 +235,61 @@ struct SetupWizardView: View {
// MARK: - Time Selection Handler
private func handleTimeSelectionContinue() {
guard let goal = selectedGoal, let time = selectedTime else { return }
guard let time = selectedTime, !selectedGoals.isEmpty 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
var presets: [RitualPreset] = []
for goal in selectedGoals {
switch time {
case .morning:
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
presets.append(preset)
}
case .evening:
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
presets.append(preset)
}
case .both:
if let morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
presets.append(morningPreset)
}
if let eveningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
presets.append(eveningPreset)
}
}
}
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
pendingPresets = presets
currentPresetIndex = 0
createdRituals = []
hasCompletedFirstCheckIn = false
if presets.isEmpty {
currentStep = .whatsNext
} else {
currentStep = .ritualPreview
}
}
}
// MARK: - Evening Ritual Actions
// MARK: - Ritual Preview Actions
private func createEveningRitualAndAdvance() {
guard let preset = eveningPreset else { return }
eveningRitual = store.createRitual(from: preset)
advanceFromEveningPreview()
private func createCurrentRitualAndAdvance() {
guard let preset = currentPreset else { return }
let ritual = store.createRitual(from: preset)
createdRituals.append(ritual)
advanceFromPreview()
}
private func skipEveningAndAdvance() {
advanceFromEveningPreview()
private func skipCurrentAndAdvance() {
advanceFromPreview()
}
private func advanceFromEveningPreview() {
private func advanceFromPreview() {
withAnimation {
if hasCreatedRitual {
if currentPresetIndex + 1 < pendingPresets.count {
currentPresetIndex += 1
} else if hasCreatedRitual {
// Go to first check-in
currentStep = .firstCheckIn
} else {

View File

@ -48,9 +48,6 @@ struct RitualsView: View {
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.onAppear {
store.refresh()
}
.navigationTitle(String(localized: "Rituals"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

@ -1,5 +1,6 @@
import SwiftUI
import Bedrock
import Foundation
struct RootView: View {
@Bindable var store: RitualStore
@ -74,14 +75,25 @@ struct RootView: View {
refreshCurrentTab()
}
}
.onChange(of: selectedTab) { _, _ in
refreshCurrentTab()
}
}
private func refreshCurrentTab() {
store.refresh()
if selectedTab == .settings {
settingsStore.refresh()
Task {
Task {
// Let tab selection UI update before refreshing data.
await Task.yield()
let refreshStart = CFAbsoluteTimeGetCurrent()
store.refresh()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.store.refresh", from: refreshStart)
if selectedTab == .settings {
let settingsStart = CFAbsoluteTimeGetCurrent()
settingsStore.refresh()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.settings.refresh", from: settingsStart)
let reminderStart = CFAbsoluteTimeGetCurrent()
await store.reminderScheduler.refreshStatus()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.reminderStatus", from: reminderStart)
}
}
}

View File

@ -117,7 +117,7 @@ struct SettingsView: View {
SettingsRow(
systemImage: "arrow.counterclockwise",
title: String(localized: "Reset Onboarding"),
title: String(localized: "Reset Setup Wizard"),
iconColor: AppStatus.warning
) {
// Reset both the old and new onboarding flags

View File

@ -80,6 +80,7 @@ struct TodayEmptyStateView: View {
}
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}

View File

@ -24,6 +24,21 @@ struct TodayNoRitualsForTimeView: View {
}
}
private var nextRitualTomorrow: Ritual? {
let calendar = Calendar.current
let tomorrowDate = calendar.startOfDay(
for: calendar.date(byAdding: .day, value: 1, to: Date()) ?? Date()
)
return store.currentRituals
.filter { ritual in
guard let arc = ritual.currentArc, arc.contains(date: tomorrowDate) else { return false }
return ritual.timeOfDay != .anytime
}
.sorted { $0.timeOfDay < $1.timeOfDay }
.first
}
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
SectionHeaderView(
@ -78,6 +93,17 @@ struct TodayNoRitualsForTimeView: View {
}
}
.padding(.top, Design.Spacing.small)
} else if let tomorrowRitual = nextRitualTomorrow {
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
Text(String.localizedStringWithFormat(
format,
tomorrowRitual.timeOfDay.displayName,
tomorrowRitual.timeOfDay.timeRange
))
.font(.caption)
.foregroundStyle(AppTextColors.tertiary)
.multilineTextAlignment(.center)
.padding(.top, Design.Spacing.small)
}
// Motivational message
@ -88,6 +114,7 @@ struct TodayNoRitualsForTimeView: View {
.padding(.top, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}

View File

@ -55,9 +55,6 @@ struct TodayView: View {
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.onAppear {
store.refresh()
}
.sheet(isPresented: .init(
get: { showRenewalSheet },
set: { if !$0 { store.dismissRenewalPrompt() } }

View File

@ -71,8 +71,8 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
- Debug tools: reset onboarding, app icon generation, branding preview
### Onboarding
- Sherpa-powered walkthrough on first launch
- Highlights focus ritual card and habit check-in flow
- Setup wizard on first launch (goal, time, ritual preview, first check-in)
- Ends with a quick orientation to Today, Rituals, and Insights
- Debug reset available in Settings
### Branding & Launch