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,8 +70,10 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Refreshes rituals and derived state for current date/time.
|
||||
func refresh() {
|
||||
reloadRituals()
|
||||
checkForCompletedArcs()
|
||||
PerformanceLogger.measure("RitualStore.refresh") {
|
||||
reloadRituals()
|
||||
checkForCompletedArcs()
|
||||
}
|
||||
}
|
||||
|
||||
func ritualProgress(for ritual: Ritual) -> Double {
|
||||
@ -129,20 +137,6 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
// MARK: - Ritual Management
|
||||
|
||||
/// Rituals with active arcs, sorted by time of day
|
||||
var currentRituals: [Ritual] {
|
||||
rituals
|
||||
.filter { $0.hasActiveArc }
|
||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||
}
|
||||
|
||||
/// Rituals without active arcs (completed or not renewed), sorted by most recently ended
|
||||
var pastRituals: [Ritual] {
|
||||
rituals
|
||||
.filter { !$0.hasActiveArc }
|
||||
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
||||
}
|
||||
|
||||
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
|
||||
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
||||
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||
@ -263,26 +257,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns the set of all day IDs that had 100% completion across all active arcs for that day
|
||||
private func perfectDays() -> Set<String> {
|
||||
// Get all dates that have any activity
|
||||
let activeDates = datesWithActivity()
|
||||
guard !activeDates.isEmpty else { return [] }
|
||||
|
||||
// For each date, check if all habits in all active arcs were completed
|
||||
var perfectDayIDs: Set<String> = []
|
||||
|
||||
for date in activeDates {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let activeHabits = habitsActive(on: date)
|
||||
|
||||
guard !activeHabits.isEmpty else { continue }
|
||||
|
||||
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||
if allCompleted {
|
||||
perfectDayIDs.insert(dayID)
|
||||
}
|
||||
}
|
||||
|
||||
return perfectDayIDs
|
||||
refreshAnalyticsCacheIfNeeded()
|
||||
return cachedPerfectDayIDs
|
||||
}
|
||||
|
||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||
@ -425,84 +401,86 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
func insightCards() -> [InsightCard] {
|
||||
// Only count habits from active arcs for today's stats
|
||||
let activeHabitsToday = habitsActive(on: Date())
|
||||
let totalHabits = activeHabitsToday.count
|
||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
||||
|
||||
// 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<Ritual>())
|
||||
// Update reminder scheduling when rituals change
|
||||
Task {
|
||||
await reminderScheduler.updateReminders(for: rituals)
|
||||
PerformanceLogger.measure("RitualStore.reloadRituals") {
|
||||
do {
|
||||
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||
updateDerivedData()
|
||||
invalidateAnalyticsCache()
|
||||
scheduleReminderUpdate()
|
||||
} catch {
|
||||
lastErrorMessage = error.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
lastErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@ -678,6 +657,76 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDerivedData() {
|
||||
currentRituals = rituals
|
||||
.filter { $0.hasActiveArc }
|
||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||
pastRituals = rituals
|
||||
.filter { !$0.hasActiveArc }
|
||||
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
||||
}
|
||||
|
||||
private func refreshAnalyticsCacheIfNeeded() {
|
||||
guard analyticsNeedsRefresh else { return }
|
||||
cachedDatesWithActivity = computeDatesWithActivity()
|
||||
cachedPerfectDayIDs = computePerfectDays(from: cachedDatesWithActivity)
|
||||
analyticsNeedsRefresh = false
|
||||
}
|
||||
|
||||
private func invalidateAnalyticsCache() {
|
||||
analyticsNeedsRefresh = true
|
||||
}
|
||||
|
||||
private func computeDatesWithActivity() -> Set<Date> {
|
||||
PerformanceLogger.measure("RitualStore.computeDatesWithActivity") {
|
||||
var dates: Set<Date> = []
|
||||
|
||||
for ritual in rituals {
|
||||
for arc in ritual.arcs {
|
||||
for habit in arc.habits {
|
||||
for dayID in habit.completedDayIDs {
|
||||
if let date = dayFormatter.date(from: dayID) {
|
||||
dates.insert(calendar.startOfDay(for: date))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
}
|
||||
|
||||
private func computePerfectDays(from activeDates: Set<Date>) -> Set<String> {
|
||||
guard !activeDates.isEmpty else { return [] }
|
||||
|
||||
var perfectDayIDs: Set<String> = []
|
||||
|
||||
for date in activeDates {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let activeHabits = habitsActive(on: date)
|
||||
|
||||
guard !activeHabits.isEmpty else { continue }
|
||||
|
||||
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||
if allCompleted {
|
||||
perfectDayIDs.insert(dayID)
|
||||
}
|
||||
}
|
||||
|
||||
return perfectDayIDs
|
||||
}
|
||||
|
||||
private func scheduleReminderUpdate() {
|
||||
pendingReminderTask?.cancel()
|
||||
let ritualsSnapshot = rituals
|
||||
pendingReminderTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
await reminderScheduler.updateReminders(for: ritualsSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayIdentifier(for date: Date) -> String {
|
||||
dayFormatter.string(from: date)
|
||||
}
|
||||
@ -710,21 +759,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns all dates that have any habit activity.
|
||||
func datesWithActivity() -> Set<Date> {
|
||||
var dates: Set<Date> = []
|
||||
|
||||
for ritual in rituals {
|
||||
for arc in ritual.arcs {
|
||||
for habit in arc.habits {
|
||||
for dayID in habit.completedDayIDs {
|
||||
if let date = dayFormatter.date(from: dayID) {
|
||||
dates.insert(calendar.startOfDay(for: date))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates
|
||||
refreshAnalyticsCacheIfNeeded()
|
||||
return cachedDatesWithActivity
|
||||
}
|
||||
|
||||
/// Returns the earliest date with any ritual activity.
|
||||
@ -921,6 +957,11 @@ final class RitualStore: RitualStoreProviding {
|
||||
completedToday: completedToday
|
||||
)
|
||||
}
|
||||
|
||||
/// Precomputes analytics data if it is invalidated.
|
||||
func refreshAnalyticsIfNeeded() {
|
||||
refreshAnalyticsCacheIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Debug / Demo Data
|
||||
|
||||
|
||||
@ -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
|
||||
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 {
|
||||
|
||||
@ -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 {
|
||||
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