Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6be09c3067
commit
9ade3b00ea
@ -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 */;
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<key>Andromida.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -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
|
||||
|
||||
28
Andromida/App/Services/PerformanceLogger.swift
Normal file
28
Andromida/App/Services/PerformanceLogger.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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: {}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -48,9 +48,6 @@ struct RitualsView: View {
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.onAppear {
|
||||
store.refresh()
|
||||
}
|
||||
.navigationTitle(String(localized: "Rituals"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -80,6 +80,7 @@ struct TodayEmptyStateView: View {
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -55,9 +55,6 @@ struct TodayView: View {
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.onAppear {
|
||||
store.refresh()
|
||||
}
|
||||
.sheet(isPresented: .init(
|
||||
get: { showRenewalSheet },
|
||||
set: { if !$0 { store.dismissRenewalPrompt() } }
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user