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,9 +70,11 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time.
func refresh() {
PerformanceLogger.measure("RitualStore.refresh") {
reloadRituals()
checkForCompletedArcs()
}
}
func ritualProgress(for ritual: Ritual) -> Double {
let habits = ritual.habits
@ -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,6 +401,7 @@ final class RitualStore: RitualStoreProviding {
}
func insightCards() -> [InsightCard] {
return PerformanceLogger.measure("RitualStore.insightCards") {
// Only count habits from active arcs for today's stats
let activeHabitsToday = habitsActive(on: Date())
let totalHabits = activeHabitsToday.count
@ -504,6 +481,7 @@ final class RitualStore: RitualStoreProviding {
)
]
}
}
func createQuickRitual() {
let defaultDuration = 28
@ -658,16 +636,17 @@ final class RitualStore: RitualStoreProviding {
}
private func reloadRituals() {
PerformanceLogger.measure("RitualStore.reloadRituals") {
do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
// Update reminder scheduling when rituals change
Task {
await reminderScheduler.updateReminders(for: rituals)
}
updateDerivedData()
invalidateAnalyticsCache()
scheduleReminderUpdate()
} catch {
lastErrorMessage = error.localizedDescription
}
}
}
private func saveContext() {
do {
@ -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
var presets: [RitualPreset] = []
for goal in selectedGoals {
switch time {
case .morning:
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning)
eveningPreset = nil
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
presets.append(preset)
}
case .evening:
// For evening only, we still use morningRitualPreview step but show evening preset
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening)
eveningPreset = nil
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
presets.append(preset)
}
case .both:
let presets = OnboardingPresetRecommender.recommendedPresets(for: goal)
morningPreset = presets.morning
eveningPreset = presets.evening
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 {
// 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