From 9ade3b00ea565f0f59e0da98787ee78a870fc731 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 26 Jan 2026 16:51:26 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida.xcodeproj/project.pbxproj | 12 - .../xcschemes/xcschememanagement.plist | 2 +- .../App/Localization/Localizable.xcstrings | 60 +++- .../App/Services/PerformanceLogger.swift | 28 ++ Andromida/App/State/RitualStore.swift | 311 ++++++++++-------- Andromida/App/Views/History/HistoryView.swift | 9 +- .../App/Views/Insights/InsightsView.swift | 8 +- .../Onboarding/FirstCheckInStepView.swift | 22 ++ .../Onboarding/GoalSelectionStepView.swift | 20 +- .../Onboarding/RitualsOnboardingTags.swift | 22 -- .../Views/Onboarding/SetupWizardView.swift | 193 +++++------ Andromida/App/Views/Rituals/RitualsView.swift | 3 - Andromida/App/Views/RootView.swift | 20 +- .../App/Views/Settings/SettingsView.swift | 2 +- .../Components/TodayEmptyStateView.swift | 1 + .../TodayNoRitualsForTimeView.swift | 27 ++ Andromida/App/Views/Today/TodayView.swift | 3 - README.md | 4 +- 18 files changed, 433 insertions(+), 314 deletions(-) create mode 100644 Andromida/App/Services/PerformanceLogger.swift delete mode 100644 Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift diff --git a/Andromida.xcodeproj/project.pbxproj b/Andromida.xcodeproj/project.pbxproj index f8e0401..fbaa931 100644 --- a/Andromida.xcodeproj/project.pbxproj +++ b/Andromida.xcodeproj/project.pbxproj @@ -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 */; diff --git a/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 9e6a2b8..23eac62 100644 --- a/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Andromida.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ Andromida.xcscheme_^#shared#^_ orderHint - 1 + 2 diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index d1e0a71..d666326 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -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 l’assistant 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 diff --git a/Andromida/App/Services/PerformanceLogger.swift b/Andromida/App/Services/PerformanceLogger.swift new file mode 100644 index 0000000..579414e --- /dev/null +++ b/Andromida/App/Services/PerformanceLogger.swift @@ -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(_ 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 + } +} diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 77af853..6d16a57 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -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 = [] + private var cachedPerfectDayIDs: Set = [] + private var pendingReminderTask: Task? /// 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 { - // 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 = [] - - 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) - - // Days active = unique calendar days with at least one check-in - let daysActiveCount = datesWithActivity().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)" - ) - } - - // 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 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 + + // 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)" + ) + } + + // 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() - ) - ] + 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()) - // Update reminder scheduling when rituals change - Task { - await reminderScheduler.updateReminders(for: rituals) + PerformanceLogger.measure("RitualStore.reloadRituals") { + do { + rituals = try modelContext.fetch(FetchDescriptor()) + 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 { + PerformanceLogger.measure("RitualStore.computeDatesWithActivity") { + var dates: Set = [] + + 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) -> Set { + guard !activeDates.isEmpty else { return [] } + + var perfectDayIDs: Set = [] + + 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 { - var dates: Set = [] - - 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. @@ -921,6 +957,11 @@ final class RitualStore: RitualStoreProviding { completedToday: completedToday ) } + + /// Precomputes analytics data if it is invalidated. + func refreshAnalyticsIfNeeded() { + refreshAnalyticsCacheIfNeeded() + } // MARK: - Debug / Demo Data diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index 1cf3752..bd89703 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -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( diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift index 8415b4b..b286599 100644 --- a/Andromida/App/Views/Insights/InsightsView.swift +++ b/Andromida/App/Views/Insights/InsightsView.swift @@ -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() } } } diff --git a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift index c57297e..adb351a 100644 --- a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift +++ b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift @@ -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)) { diff --git a/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift index f614314..cc269fc 100644 --- a/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift +++ b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift @@ -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: {} ) } diff --git a/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift b/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift deleted file mode 100644 index 21622c0..0000000 --- a/Andromida/App/Views/Onboarding/RitualsOnboardingTags.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Andromida/App/Views/Onboarding/SetupWizardView.swift b/Andromida/App/Views/Onboarding/SetupWizardView.swift index eb2f904..05a83d9 100644 --- a/Andromida/App/Views/Onboarding/SetupWizardView.swift +++ b/Andromida/App/Views/Onboarding/SetupWizardView.swift @@ -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 { diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift index 89857d0..857e052 100644 --- a/Andromida/App/Views/Rituals/RitualsView.swift +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -48,9 +48,6 @@ struct RitualsView: View { startPoint: .topLeading, endPoint: .bottomTrailing )) - .onAppear { - store.refresh() - } .navigationTitle(String(localized: "Rituals")) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index 7a7ae97..b1fe431 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -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) } } } diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index fcec039..4cd0bea 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -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 diff --git a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift index 20c868c..41ff600 100644 --- a/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift +++ b/Andromida/App/Views/Today/Components/TodayEmptyStateView.swift @@ -80,6 +80,7 @@ struct TodayEmptyStateView: View { } } .padding(Design.Spacing.large) + .frame(maxWidth: .infinity) .background(AppSurface.card) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) } diff --git a/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift index cd2848d..ca20f96 100644 --- a/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift +++ b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift @@ -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)) } diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index c485cf8..ca8fc3c 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -55,9 +55,6 @@ struct TodayView: View { startPoint: .topLeading, endPoint: .bottomTrailing )) - .onAppear { - store.refresh() - } .sheet(isPresented: .init( get: { showRenewalSheet }, set: { if !$0 { store.dismissRenewalPrompt() } } diff --git a/README.md b/README.md index 44abcb5..1bafe68 100644 --- a/README.md +++ b/README.md @@ -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