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

View File

@ -7,7 +7,7 @@
<key>Andromida.xcscheme_^#shared#^_</key> <key>Andromida.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>2</integer>
</dict> </dict>
</dict> </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.", "comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
"isCommentAutoGenerated" : true "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" : { "Feel better each day" : {
"comment" : "Subtitle for the \"Health\" onboarding goal.", "comment" : "Subtitle for the \"Health\" onboarding goal.",
"isCommentAutoGenerated" : true "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.", "comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
"isCommentAutoGenerated" : true "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!" : { "Nice work!" : {
"comment" : "A congratulatory message displayed after a successful habit check-in.", "comment" : "A congratulatory message displayed after a successful habit check-in.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -1832,9 +1851,28 @@
"comment" : "Title of a toggle in the settings view that controls whether reminders are enabled.", "comment" : "Title of a toggle in the settings view that controls whether reminders are enabled.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Reset Onboarding" : { "Reset Setup Wizard" : {
"comment" : "Title of a navigation row in the Settings view that resets the user's onboarding state.", "comment" : "Title of a navigation row in the Settings view that resets the setup wizard state.",
"isCommentAutoGenerated" : true "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" : { "Rest better tonight" : {
"comment" : "Notes for a ritual preset focused on sleep preparation.", "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.", "comment" : "Label for a breakdown item showing the total number of check-ins made by the user.",
"isCommentAutoGenerated" : true "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." : { "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.", "comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to use the app's insights feature.",
"isCommentAutoGenerated" : true "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.", "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 "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" : { "Weekly completion chart" : {
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.", "comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
"isCommentAutoGenerated" : true "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 @ObservationIgnored private let displayFormatter: DateFormatter
private(set) var rituals: [Ritual] = [] private(set) var rituals: [Ritual] = []
private(set) var currentRituals: [Ritual] = []
private(set) var pastRituals: [Ritual] = []
private(set) var lastErrorMessage: String? 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 /// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler() let reminderScheduler = ReminderScheduler()
@ -64,9 +70,11 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time. /// Refreshes rituals and derived state for current date/time.
func refresh() { func refresh() {
PerformanceLogger.measure("RitualStore.refresh") {
reloadRituals() reloadRituals()
checkForCompletedArcs() checkForCompletedArcs()
} }
}
func ritualProgress(for ritual: Ritual) -> Double { func ritualProgress(for ritual: Ritual) -> Double {
let habits = ritual.habits let habits = ritual.habits
@ -129,20 +137,6 @@ final class RitualStore: RitualStoreProviding {
// MARK: - Ritual Management // 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. /// 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), /// 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. /// 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 /// Returns the set of all day IDs that had 100% completion across all active arcs for that day
private func perfectDays() -> Set<String> { private func perfectDays() -> Set<String> {
// Get all dates that have any activity refreshAnalyticsCacheIfNeeded()
let activeDates = datesWithActivity() return cachedPerfectDayIDs
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
} }
/// Calculates the current streak (consecutive perfect days ending today or yesterday) /// Calculates the current streak (consecutive perfect days ending today or yesterday)
@ -425,6 +401,7 @@ final class RitualStore: RitualStoreProviding {
} }
func insightCards() -> [InsightCard] { func insightCards() -> [InsightCard] {
return PerformanceLogger.measure("RitualStore.insightCards") {
// Only count habits from active arcs for today's stats // Only count habits from active arcs for today's stats
let activeHabitsToday = habitsActive(on: Date()) let activeHabitsToday = habitsActive(on: Date())
let totalHabits = activeHabitsToday.count let totalHabits = activeHabitsToday.count
@ -504,6 +481,7 @@ final class RitualStore: RitualStoreProviding {
) )
] ]
} }
}
func createQuickRitual() { func createQuickRitual() {
let defaultDuration = 28 let defaultDuration = 28
@ -658,16 +636,17 @@ final class RitualStore: RitualStoreProviding {
} }
private func reloadRituals() { private func reloadRituals() {
PerformanceLogger.measure("RitualStore.reloadRituals") {
do { do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>()) rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
// Update reminder scheduling when rituals change updateDerivedData()
Task { invalidateAnalyticsCache()
await reminderScheduler.updateReminders(for: rituals) scheduleReminderUpdate()
}
} catch { } catch {
lastErrorMessage = error.localizedDescription lastErrorMessage = error.localizedDescription
} }
} }
}
private func saveContext() { private func saveContext() {
do { 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 { private func dayIdentifier(for date: Date) -> String {
dayFormatter.string(from: date) dayFormatter.string(from: date)
} }
@ -710,21 +759,8 @@ final class RitualStore: RitualStoreProviding {
/// Returns all dates that have any habit activity. /// Returns all dates that have any habit activity.
func datesWithActivity() -> Set<Date> { func datesWithActivity() -> Set<Date> {
var dates: Set<Date> = [] refreshAnalyticsCacheIfNeeded()
return cachedDatesWithActivity
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
} }
/// Returns the earliest date with any ritual activity. /// 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 // MARK: - Debug / Demo Data
#if DEBUG #if DEBUG

View File

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

View File

@ -3,6 +3,7 @@ import Bedrock
struct InsightsView: View { struct InsightsView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@State private var refreshToken = UUID()
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: AppMetrics.Size.insightCardMinWidth), spacing: Design.Spacing.medium) GridItem(.adaptive(minimum: AppMetrics.Size.insightCardMinWidth), spacing: Design.Spacing.medium)
@ -21,6 +22,7 @@ struct InsightsView: View {
InsightCardView(card: card, store: store) InsightCardView(card: card, store: store)
} }
} }
.id(refreshToken)
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
} }
@ -30,7 +32,11 @@ struct InsightsView: View {
endPoint: .bottomTrailing endPoint: .bottomTrailing
)) ))
.onAppear { .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) .opacity(animateContent ? 1 : 0)
Spacer() 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 { .onAppear {
withAnimation(.easeOut(duration: 0.5)) { 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. /// The goal selection screen where users choose what they want to focus on.
struct GoalSelectionStepView: View { struct GoalSelectionStepView: View {
@Binding var selectedGoal: OnboardingGoal? @Binding var selectedGoals: [OnboardingGoal]
let onContinue: () -> Void let onContinue: () -> Void
@State private var animateCards = false @State private var animateCards = false
@ -33,10 +33,10 @@ struct GoalSelectionStepView: View {
ForEach(Array(OnboardingGoal.allCases.enumerated()), id: \.element.id) { index, goal in ForEach(Array(OnboardingGoal.allCases.enumerated()), id: \.element.id) { index, goal in
GoalCardView( GoalCardView(
goal: goal, goal: goal,
isSelected: selectedGoal == goal, isSelected: selectedGoals.contains(goal),
onTap: { onTap: {
withAnimation(.easeInOut(duration: Design.Animation.quick)) { withAnimation(.easeInOut(duration: Design.Animation.quick)) {
selectedGoal = goal toggleGoalSelection(goal)
} }
} }
) )
@ -53,7 +53,7 @@ struct GoalSelectionStepView: View {
Spacer() Spacer()
// Continue button (only shown when a goal is selected) // Continue button (only shown when a goal is selected)
if selectedGoal != nil { if !selectedGoals.isEmpty {
Button(action: onContinue) { Button(action: onContinue) {
Text(String(localized: "Continue")) Text(String(localized: "Continue"))
.font(.headline) .font(.headline)
@ -68,13 +68,21 @@ struct GoalSelectionStepView: View {
.transition(.move(edge: .bottom).combined(with: .opacity)) .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 { .onAppear {
withAnimation { withAnimation {
animateCards = true 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. /// A single goal card in the selection grid.
@ -136,7 +144,7 @@ private struct GoalCardView: View {
.ignoresSafeArea() .ignoresSafeArea()
GoalSelectionStepView( GoalSelectionStepView(
selectedGoal: .constant(nil), selectedGoals: .constant([]),
onContinue: {} 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 let onComplete: () -> Void
@State private var currentStep: WizardStep = .welcome @State private var currentStep: WizardStep = .welcome
@State private var selectedGoal: OnboardingGoal? @State private var selectedGoals: [OnboardingGoal] = []
@State private var selectedTime: OnboardingTimePreference? @State private var selectedTime: OnboardingTimePreference?
// Track created rituals for "Both" flow // Track created rituals during onboarding
@State private var morningRitual: Ritual? @State private var createdRituals: [Ritual] = []
@State private var eveningRitual: Ritual?
@State private var hasCompletedFirstCheckIn = false @State private var hasCompletedFirstCheckIn = false
// Presets for "Both" flow // Presets for preview flow
@State private var morningPreset: RitualPreset? @State private var pendingPresets: [RitualPreset] = []
@State private var eveningPreset: RitualPreset? @State private var currentPresetIndex: Int = 0
enum WizardStep: Int, CaseIterable { enum WizardStep: Int, CaseIterable {
case welcome = 0 case welcome = 0
case goalSelection = 1 case goalSelection = 1
case timeSelection = 2 case timeSelection = 2
case morningRitualPreview = 3 // Morning preview (or single for Morning/Evening) case ritualPreview = 3
case eveningRitualPreview = 4 // Evening preview (only for "Both") case firstCheckIn = 4
case firstCheckIn = 5 case whatsNext = 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 /// 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) /// The first ritual that was created (for first check-in)
private var firstCreatedRitual: Ritual? { private var firstCreatedRitual: Ritual? {
morningRitual ?? eveningRitual createdRituals.first
} }
/// Whether any ritual was created /// Whether any ritual was created
private var hasCreatedRitual: Bool { 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 /// Whether to show the back button
@ -60,9 +62,7 @@ struct SetupWizardView: View {
return true return true
case .timeSelection: case .timeSelection:
return true return true
case .morningRitualPreview: case .ritualPreview:
return true
case .eveningRitualPreview:
return true return true
} }
} }
@ -93,7 +93,7 @@ struct SetupWizardView: View {
case .goalSelection: case .goalSelection:
GoalSelectionStepView( GoalSelectionStepView(
selectedGoal: $selectedGoal, selectedGoals: $selectedGoals,
onContinue: advanceToNextStep onContinue: advanceToNextStep
) )
@ -103,25 +103,14 @@ struct SetupWizardView: View {
onContinue: handleTimeSelectionContinue onContinue: handleTimeSelectionContinue
) )
case .morningRitualPreview: case .ritualPreview:
if let preset = morningPreset { if let preset = currentPreset {
RitualPreviewStepView( RitualPreviewStepView(
preset: preset, preset: preset,
ritualIndex: isBothMode ? 1 : nil, ritualIndex: totalPresets > 1 ? currentPresetIndex + 1 : nil,
totalRituals: isBothMode ? 2 : nil, totalRituals: totalPresets > 1 ? totalPresets : nil,
onStartRitual: { createMorningRitualAndAdvance() }, onStartRitual: { createCurrentRitualAndAdvance() },
onSkip: { skipMorningAndAdvance() } onSkip: { skipCurrentAndAdvance() }
)
}
case .eveningRitualPreview:
if let preset = eveningPreset {
RitualPreviewStepView(
preset: preset,
ritualIndex: isBothMode ? 2 : nil,
totalRituals: isBothMode ? 2 : nil,
onStartRitual: { createEveningRitualAndAdvance() },
onSkip: { skipEveningAndAdvance() }
) )
} }
@ -196,21 +185,20 @@ struct SetupWizardView: View {
/// Adjusted progress value that accounts for skipped steps /// Adjusted progress value that accounts for skipped steps
private var progressValue: Double { private var progressValue: Double {
// For non-Both flows, we skip eveningRitualPreview
let totalSteps: Double = isBothMode ? 7 : 6
let currentStepValue: Double
switch currentStep { switch currentStep {
case .welcome: currentStepValue = 0 case .welcome:
case .goalSelection: currentStepValue = 1 return 0.0
case .timeSelection: currentStepValue = 2 case .goalSelection:
case .morningRitualPreview: currentStepValue = 3 return 0.25
case .eveningRitualPreview: currentStepValue = 4 case .timeSelection:
case .firstCheckIn: currentStepValue = isBothMode ? 5 : 4 return 0.5
case .whatsNext: currentStepValue = isBothMode ? 6 : 5 case .ritualPreview:
return 0.7
case .firstCheckIn:
return 0.9
case .whatsNext:
return 1.0
} }
return currentStepValue / (totalSteps - 1)
} }
// MARK: - Navigation Actions // MARK: - Navigation Actions
@ -223,14 +211,14 @@ struct SetupWizardView: View {
} }
private func goBack() { private func goBack() {
// Handle back navigation with step skipping if currentStep == .ritualPreview, currentPresetIndex > 0 {
var targetStep = currentStep.rawValue - 1 withAnimation {
currentPresetIndex -= 1
// If going back from firstCheckIn in non-Both mode, skip eveningRitualPreview }
if currentStep == .firstCheckIn && !isBothMode { return
targetStep = WizardStep.morningRitualPreview.rawValue
} }
let targetStep = currentStep.rawValue - 1
guard targetStep >= 0, guard targetStep >= 0,
let previousStep = WizardStep(rawValue: targetStep) else { return } let previousStep = WizardStep(rawValue: targetStep) else { return }
withAnimation { withAnimation {
@ -247,70 +235,61 @@ struct SetupWizardView: View {
// MARK: - Time Selection Handler // MARK: - Time Selection Handler
private func handleTimeSelectionContinue() { 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 { switch time {
case .morning: case .morning:
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
eveningPreset = nil presets.append(preset)
}
case .evening: case .evening:
// For evening only, we still use morningRitualPreview step but show evening preset if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) presets.append(preset)
eveningPreset = nil }
case .both: case .both:
let presets = OnboardingPresetRecommender.recommendedPresets(for: goal) if let morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
morningPreset = presets.morning presets.append(morningPreset)
eveningPreset = presets.evening }
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 { withAnimation {
if isBothMode && eveningPreset != nil { pendingPresets = presets
// Go to evening preview currentPresetIndex = 0
currentStep = .eveningRitualPreview createdRituals = []
} else if hasCreatedRitual { hasCompletedFirstCheckIn = false
// Go to first check-in
currentStep = .firstCheckIn if presets.isEmpty {
} else {
// No rituals created, go to what's next
currentStep = .whatsNext currentStep = .whatsNext
} else {
currentStep = .ritualPreview
} }
} }
} }
// MARK: - Evening Ritual Actions // MARK: - Ritual Preview Actions
private func createEveningRitualAndAdvance() { private func createCurrentRitualAndAdvance() {
guard let preset = eveningPreset else { return } guard let preset = currentPreset else { return }
eveningRitual = store.createRitual(from: preset) let ritual = store.createRitual(from: preset)
advanceFromEveningPreview() createdRituals.append(ritual)
advanceFromPreview()
} }
private func skipEveningAndAdvance() { private func skipCurrentAndAdvance() {
advanceFromEveningPreview() advanceFromPreview()
} }
private func advanceFromEveningPreview() { private func advanceFromPreview() {
withAnimation { withAnimation {
if hasCreatedRitual { if currentPresetIndex + 1 < pendingPresets.count {
currentPresetIndex += 1
} else if hasCreatedRitual {
// Go to first check-in // Go to first check-in
currentStep = .firstCheckIn currentStep = .firstCheckIn
} else { } else {

View File

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

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import Foundation
struct RootView: View { struct RootView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@ -74,14 +75,25 @@ struct RootView: View {
refreshCurrentTab() refreshCurrentTab()
} }
} }
.onChange(of: selectedTab) { _, _ in
refreshCurrentTab()
}
} }
private func 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() await store.reminderScheduler.refreshStatus()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.reminderStatus", from: reminderStart)
} }
} }
} }

View File

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

View File

@ -80,6 +80,7 @@ struct TodayEmptyStateView: View {
} }
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
.frame(maxWidth: .infinity)
.background(AppSurface.card) .background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .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 { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.large) { VStack(alignment: .leading, spacing: Design.Spacing.large) {
SectionHeaderView( SectionHeaderView(
@ -78,6 +93,17 @@ struct TodayNoRitualsForTimeView: View {
} }
} }
.padding(.top, Design.Spacing.small) .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 // Motivational message
@ -88,6 +114,7 @@ struct TodayNoRitualsForTimeView: View {
.padding(.top, Design.Spacing.small) .padding(.top, Design.Spacing.small)
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
.frame(maxWidth: .infinity)
.background(AppSurface.card) .background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
} }

View File

@ -55,9 +55,6 @@ struct TodayView: View {
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
)) ))
.onAppear {
store.refresh()
}
.sheet(isPresented: .init( .sheet(isPresented: .init(
get: { showRenewalSheet }, get: { showRenewalSheet },
set: { if !$0 { store.dismissRenewalPrompt() } } 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 - Debug tools: reset onboarding, app icon generation, branding preview
### Onboarding ### Onboarding
- Sherpa-powered walkthrough on first launch - Setup wizard on first launch (goal, time, ritual preview, first check-in)
- Highlights focus ritual card and habit check-in flow - Ends with a quick orientation to Today, Rituals, and Insights
- Debug reset available in Settings - Debug reset available in Settings
### Branding & Launch ### Branding & Launch