Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6be09c3067
commit
9ade3b00ea
@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04AED2F26BD5B007F87EA /* Bedrock */; };
|
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04AED2F26BD5B007F87EA /* Bedrock */; };
|
||||||
EAC04B7F2F26C478007F87EA /* Sherpa in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04B7E2F26C478007F87EA /* Sherpa */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -58,7 +57,6 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */,
|
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */,
|
||||||
EAC04B7F2F26C478007F87EA /* Sherpa in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -120,7 +118,6 @@
|
|||||||
name = Andromida;
|
name = Andromida;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
EAC04AED2F26BD5B007F87EA /* Bedrock */,
|
EAC04AED2F26BD5B007F87EA /* Bedrock */,
|
||||||
EAC04B7E2F26C478007F87EA /* Sherpa */,
|
|
||||||
);
|
);
|
||||||
productName = Andromida;
|
productName = Andromida;
|
||||||
productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */;
|
productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */;
|
||||||
@ -206,7 +203,6 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
EAC04AEC2F26BD5B007F87EA /* XCLocalSwiftPackageReference "../Bedrock" */,
|
EAC04AEC2F26BD5B007F87EA /* XCLocalSwiftPackageReference "../Bedrock" */,
|
||||||
EAC04B7D2F26C478007F87EA /* XCLocalSwiftPackageReference "../Sherpa" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = EAC04A992F26BAE8007F87EA /* Products */;
|
productRefGroup = EAC04A992F26BAE8007F87EA /* Products */;
|
||||||
@ -601,10 +597,6 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = ../Bedrock;
|
relativePath = ../Bedrock;
|
||||||
};
|
};
|
||||||
EAC04B7D2F26C478007F87EA /* XCLocalSwiftPackageReference "../Sherpa" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = ../Sherpa;
|
|
||||||
};
|
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@ -612,10 +604,6 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Bedrock;
|
productName = Bedrock;
|
||||||
};
|
};
|
||||||
EAC04B7E2F26C478007F87EA /* Sherpa */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = Sherpa;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = EAC04A902F26BAE8007F87EA /* Project object */;
|
rootObject = EAC04A902F26BAE8007F87EA /* Project object */;
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
<key>Andromida.xcscheme_^#shared#^_</key>
|
<key>Andromida.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -986,10 +986,6 @@
|
|||||||
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
|
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Explore your rituals and insights" : {
|
|
||||||
"comment" : "Sherpa walkthrough tag text for the \"tab bar\" section of the app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Feel better each day" : {
|
"Feel better each day" : {
|
||||||
"comment" : "Subtitle for the \"Health\" onboarding goal.",
|
"comment" : "Subtitle for the \"Health\" onboarding goal.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1499,6 +1495,29 @@
|
|||||||
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Next ritual: Tomorrow %@ (%@)" : {
|
||||||
|
"comment" : "A one-line hint in the Today empty state indicating the next ritual scheduled for tomorrow. The first argument is the time of day, the second is its time range.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Next ritual: Tomorrow %1$@ (%2$@)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Próximo ritual: Mañana %1$@ (%2$@)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Prochain rituel : Demain %1$@ (%2$@)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Nice work!" : {
|
"Nice work!" : {
|
||||||
"comment" : "A congratulatory message displayed after a successful habit check-in.",
|
"comment" : "A congratulatory message displayed after a successful habit check-in.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1832,9 +1851,28 @@
|
|||||||
"comment" : "Title of a toggle in the settings view that controls whether reminders are enabled.",
|
"comment" : "Title of a toggle in the settings view that controls whether reminders are enabled.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Reset Onboarding" : {
|
"Reset Setup Wizard" : {
|
||||||
"comment" : "Title of a navigation row in the Settings view that resets the user's onboarding state.",
|
"comment" : "Title of a navigation row in the Settings view that resets the setup wizard state.",
|
||||||
"isCommentAutoGenerated" : true
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reset Setup Wizard"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Restablecer asistente de configuración"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Réinitialiser l’assistant de configuration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Rest better tonight" : {
|
"Rest better tonight" : {
|
||||||
"comment" : "Notes for a ritual preset focused on sleep preparation.",
|
"comment" : "Notes for a ritual preset focused on sleep preparation.",
|
||||||
@ -2375,10 +2413,6 @@
|
|||||||
"comment" : "Label for a breakdown item showing the total number of check-ins made by the user.",
|
"comment" : "Label for a breakdown item showing the total number of check-ins made by the user.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Track your progress and streaks here" : {
|
|
||||||
"comment" : "Text for a Sherpa callout on the Insights tab of the app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Track your streaks, progress, and trends over time." : {
|
"Track your streaks, progress, and trends over time." : {
|
||||||
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to use the app's insights feature.",
|
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to use the app's insights feature.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2422,10 +2456,6 @@
|
|||||||
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to manage all your rituals, regardless of the time they were created.",
|
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to manage all your rituals, regardless of the time they were created.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"View your check-in history" : {
|
|
||||||
"comment" : "Text for a Sherpa callout on the History tab of the Rituals app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Weekly completion chart" : {
|
"Weekly completion chart" : {
|
||||||
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
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
|
@ObservationIgnored private let displayFormatter: DateFormatter
|
||||||
|
|
||||||
private(set) var rituals: [Ritual] = []
|
private(set) var rituals: [Ritual] = []
|
||||||
|
private(set) var currentRituals: [Ritual] = []
|
||||||
|
private(set) var pastRituals: [Ritual] = []
|
||||||
private(set) var lastErrorMessage: String?
|
private(set) var lastErrorMessage: String?
|
||||||
|
private var analyticsNeedsRefresh = true
|
||||||
|
private var cachedDatesWithActivity: Set<Date> = []
|
||||||
|
private var cachedPerfectDayIDs: Set<String> = []
|
||||||
|
private var pendingReminderTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// Reminder scheduler for time-slot based notifications
|
/// Reminder scheduler for time-slot based notifications
|
||||||
let reminderScheduler = ReminderScheduler()
|
let reminderScheduler = ReminderScheduler()
|
||||||
@ -64,9 +70,11 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Refreshes rituals and derived state for current date/time.
|
/// Refreshes rituals and derived state for current date/time.
|
||||||
func refresh() {
|
func refresh() {
|
||||||
|
PerformanceLogger.measure("RitualStore.refresh") {
|
||||||
reloadRituals()
|
reloadRituals()
|
||||||
checkForCompletedArcs()
|
checkForCompletedArcs()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ritualProgress(for ritual: Ritual) -> Double {
|
func ritualProgress(for ritual: Ritual) -> Double {
|
||||||
let habits = ritual.habits
|
let habits = ritual.habits
|
||||||
@ -129,20 +137,6 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
// MARK: - Ritual Management
|
// MARK: - Ritual Management
|
||||||
|
|
||||||
/// Rituals with active arcs, sorted by time of day
|
|
||||||
var currentRituals: [Ritual] {
|
|
||||||
rituals
|
|
||||||
.filter { $0.hasActiveArc }
|
|
||||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rituals without active arcs (completed or not renewed), sorted by most recently ended
|
|
||||||
var pastRituals: [Ritual] {
|
|
||||||
rituals
|
|
||||||
.filter { !$0.hasActiveArc }
|
|
||||||
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
|
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
|
||||||
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
||||||
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||||
@ -263,26 +257,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the set of all day IDs that had 100% completion across all active arcs for that day
|
/// Returns the set of all day IDs that had 100% completion across all active arcs for that day
|
||||||
private func perfectDays() -> Set<String> {
|
private func perfectDays() -> Set<String> {
|
||||||
// Get all dates that have any activity
|
refreshAnalyticsCacheIfNeeded()
|
||||||
let activeDates = datesWithActivity()
|
return cachedPerfectDayIDs
|
||||||
guard !activeDates.isEmpty else { return [] }
|
|
||||||
|
|
||||||
// For each date, check if all habits in all active arcs were completed
|
|
||||||
var perfectDayIDs: Set<String> = []
|
|
||||||
|
|
||||||
for date in activeDates {
|
|
||||||
let dayID = dayIdentifier(for: date)
|
|
||||||
let activeHabits = habitsActive(on: date)
|
|
||||||
|
|
||||||
guard !activeHabits.isEmpty else { continue }
|
|
||||||
|
|
||||||
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
|
||||||
if allCompleted {
|
|
||||||
perfectDayIDs.insert(dayID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return perfectDayIDs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||||
@ -425,6 +401,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func insightCards() -> [InsightCard] {
|
func insightCards() -> [InsightCard] {
|
||||||
|
return PerformanceLogger.measure("RitualStore.insightCards") {
|
||||||
// Only count habits from active arcs for today's stats
|
// Only count habits from active arcs for today's stats
|
||||||
let activeHabitsToday = habitsActive(on: Date())
|
let activeHabitsToday = habitsActive(on: Date())
|
||||||
let totalHabits = activeHabitsToday.count
|
let totalHabits = activeHabitsToday.count
|
||||||
@ -504,6 +481,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createQuickRitual() {
|
func createQuickRitual() {
|
||||||
let defaultDuration = 28
|
let defaultDuration = 28
|
||||||
@ -658,16 +636,17 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reloadRituals() {
|
private func reloadRituals() {
|
||||||
|
PerformanceLogger.measure("RitualStore.reloadRituals") {
|
||||||
do {
|
do {
|
||||||
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||||
// Update reminder scheduling when rituals change
|
updateDerivedData()
|
||||||
Task {
|
invalidateAnalyticsCache()
|
||||||
await reminderScheduler.updateReminders(for: rituals)
|
scheduleReminderUpdate()
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
lastErrorMessage = error.localizedDescription
|
lastErrorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func saveContext() {
|
private func saveContext() {
|
||||||
do {
|
do {
|
||||||
@ -678,6 +657,76 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateDerivedData() {
|
||||||
|
currentRituals = rituals
|
||||||
|
.filter { $0.hasActiveArc }
|
||||||
|
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||||
|
pastRituals = rituals
|
||||||
|
.filter { !$0.hasActiveArc }
|
||||||
|
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAnalyticsCacheIfNeeded() {
|
||||||
|
guard analyticsNeedsRefresh else { return }
|
||||||
|
cachedDatesWithActivity = computeDatesWithActivity()
|
||||||
|
cachedPerfectDayIDs = computePerfectDays(from: cachedDatesWithActivity)
|
||||||
|
analyticsNeedsRefresh = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invalidateAnalyticsCache() {
|
||||||
|
analyticsNeedsRefresh = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeDatesWithActivity() -> Set<Date> {
|
||||||
|
PerformanceLogger.measure("RitualStore.computeDatesWithActivity") {
|
||||||
|
var dates: Set<Date> = []
|
||||||
|
|
||||||
|
for ritual in rituals {
|
||||||
|
for arc in ritual.arcs {
|
||||||
|
for habit in arc.habits {
|
||||||
|
for dayID in habit.completedDayIDs {
|
||||||
|
if let date = dayFormatter.date(from: dayID) {
|
||||||
|
dates.insert(calendar.startOfDay(for: date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computePerfectDays(from activeDates: Set<Date>) -> Set<String> {
|
||||||
|
guard !activeDates.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var perfectDayIDs: Set<String> = []
|
||||||
|
|
||||||
|
for date in activeDates {
|
||||||
|
let dayID = dayIdentifier(for: date)
|
||||||
|
let activeHabits = habitsActive(on: date)
|
||||||
|
|
||||||
|
guard !activeHabits.isEmpty else { continue }
|
||||||
|
|
||||||
|
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||||
|
if allCompleted {
|
||||||
|
perfectDayIDs.insert(dayID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return perfectDayIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleReminderUpdate() {
|
||||||
|
pendingReminderTask?.cancel()
|
||||||
|
let ritualsSnapshot = rituals
|
||||||
|
pendingReminderTask = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await reminderScheduler.updateReminders(for: ritualsSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func dayIdentifier(for date: Date) -> String {
|
private func dayIdentifier(for date: Date) -> String {
|
||||||
dayFormatter.string(from: date)
|
dayFormatter.string(from: date)
|
||||||
}
|
}
|
||||||
@ -710,21 +759,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns all dates that have any habit activity.
|
/// Returns all dates that have any habit activity.
|
||||||
func datesWithActivity() -> Set<Date> {
|
func datesWithActivity() -> Set<Date> {
|
||||||
var dates: Set<Date> = []
|
refreshAnalyticsCacheIfNeeded()
|
||||||
|
return cachedDatesWithActivity
|
||||||
for ritual in rituals {
|
|
||||||
for arc in ritual.arcs {
|
|
||||||
for habit in arc.habits {
|
|
||||||
for dayID in habit.completedDayIDs {
|
|
||||||
if let date = dayFormatter.date(from: dayID) {
|
|
||||||
dates.insert(calendar.startOfDay(for: date))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the earliest date with any ritual activity.
|
/// Returns the earliest date with any ritual activity.
|
||||||
@ -922,6 +958,11 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precomputes analytics data if it is invalidated.
|
||||||
|
func refreshAnalyticsIfNeeded() {
|
||||||
|
refreshAnalyticsCacheIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Debug / Demo Data
|
// MARK: - Debug / Demo Data
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct HistoryView: View {
|
|||||||
@State private var selectedRitual: Ritual?
|
@State private var selectedRitual: Ritual?
|
||||||
@State private var selectedDateItem: IdentifiableDate?
|
@State private var selectedDateItem: IdentifiableDate?
|
||||||
@State private var showingExpandedHistory = false
|
@State private var showingExpandedHistory = false
|
||||||
|
@State private var refreshToken = UUID()
|
||||||
|
|
||||||
private let calendar = Calendar.current
|
private let calendar = Calendar.current
|
||||||
|
|
||||||
@ -79,6 +80,7 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.id(refreshToken)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
}
|
}
|
||||||
@ -87,8 +89,11 @@ struct HistoryView: View {
|
|||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
))
|
))
|
||||||
.onAppear {
|
.onChange(of: store.rituals) { _, newRituals in
|
||||||
store.refresh()
|
if let selectedRitual {
|
||||||
|
self.selectedRitual = newRituals.first { $0.id == selectedRitual.id }
|
||||||
|
}
|
||||||
|
refreshToken = UUID()
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedDateItem) { item in
|
.sheet(item: $selectedDateItem) { item in
|
||||||
HistoryDayDetailSheet(
|
HistoryDayDetailSheet(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Bedrock
|
|||||||
|
|
||||||
struct InsightsView: View {
|
struct InsightsView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
|
@State private var refreshToken = UUID()
|
||||||
|
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: AppMetrics.Size.insightCardMinWidth), spacing: Design.Spacing.medium)
|
GridItem(.adaptive(minimum: AppMetrics.Size.insightCardMinWidth), spacing: Design.Spacing.medium)
|
||||||
@ -21,6 +22,7 @@ struct InsightsView: View {
|
|||||||
InsightCardView(card: card, store: store)
|
InsightCardView(card: card, store: store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.id(refreshToken)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
}
|
}
|
||||||
@ -30,7 +32,11 @@ struct InsightsView: View {
|
|||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
))
|
))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
store.refresh()
|
store.refreshAnalyticsIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: store.rituals) { _, _ in
|
||||||
|
store.refreshAnalyticsIfNeeded()
|
||||||
|
refreshToken = UUID()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,6 +109,28 @@ struct FirstCheckInStepView: View {
|
|||||||
.opacity(animateContent ? 1 : 0)
|
.opacity(animateContent ? 1 : 0)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Button(action: onComplete) {
|
||||||
|
Text(String(localized: "Continue"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(AppTextColors.inverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.Size.buttonHeight)
|
||||||
|
.background(AppAccent.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onComplete) {
|
||||||
|
Text(String(localized: "Skip for now"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.easeOut(duration: 0.5)) {
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Bedrock
|
|||||||
|
|
||||||
/// The goal selection screen where users choose what they want to focus on.
|
/// The goal selection screen where users choose what they want to focus on.
|
||||||
struct GoalSelectionStepView: View {
|
struct GoalSelectionStepView: View {
|
||||||
@Binding var selectedGoal: OnboardingGoal?
|
@Binding var selectedGoals: [OnboardingGoal]
|
||||||
let onContinue: () -> Void
|
let onContinue: () -> Void
|
||||||
|
|
||||||
@State private var animateCards = false
|
@State private var animateCards = false
|
||||||
@ -33,10 +33,10 @@ struct GoalSelectionStepView: View {
|
|||||||
ForEach(Array(OnboardingGoal.allCases.enumerated()), id: \.element.id) { index, goal in
|
ForEach(Array(OnboardingGoal.allCases.enumerated()), id: \.element.id) { index, goal in
|
||||||
GoalCardView(
|
GoalCardView(
|
||||||
goal: goal,
|
goal: goal,
|
||||||
isSelected: selectedGoal == goal,
|
isSelected: selectedGoals.contains(goal),
|
||||||
onTap: {
|
onTap: {
|
||||||
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
||||||
selectedGoal = goal
|
toggleGoalSelection(goal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -53,7 +53,7 @@ struct GoalSelectionStepView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Continue button (only shown when a goal is selected)
|
// Continue button (only shown when a goal is selected)
|
||||||
if selectedGoal != nil {
|
if !selectedGoals.isEmpty {
|
||||||
Button(action: onContinue) {
|
Button(action: onContinue) {
|
||||||
Text(String(localized: "Continue"))
|
Text(String(localized: "Continue"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@ -68,13 +68,21 @@ struct GoalSelectionStepView: View {
|
|||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: Design.Animation.quick), value: selectedGoal != nil)
|
.animation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
animateCards = true
|
animateCards = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func toggleGoalSelection(_ goal: OnboardingGoal) {
|
||||||
|
if let index = selectedGoals.firstIndex(of: goal) {
|
||||||
|
selectedGoals.remove(at: index)
|
||||||
|
} else {
|
||||||
|
selectedGoals.append(goal)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single goal card in the selection grid.
|
/// A single goal card in the selection grid.
|
||||||
@ -136,7 +144,7 @@ private struct GoalCardView: View {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
GoalSelectionStepView(
|
GoalSelectionStepView(
|
||||||
selectedGoal: .constant(nil),
|
selectedGoals: .constant([]),
|
||||||
onContinue: {}
|
onContinue: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
let onComplete: () -> Void
|
||||||
|
|
||||||
@State private var currentStep: WizardStep = .welcome
|
@State private var currentStep: WizardStep = .welcome
|
||||||
@State private var selectedGoal: OnboardingGoal?
|
@State private var selectedGoals: [OnboardingGoal] = []
|
||||||
@State private var selectedTime: OnboardingTimePreference?
|
@State private var selectedTime: OnboardingTimePreference?
|
||||||
|
|
||||||
// Track created rituals for "Both" flow
|
// Track created rituals during onboarding
|
||||||
@State private var morningRitual: Ritual?
|
@State private var createdRituals: [Ritual] = []
|
||||||
@State private var eveningRitual: Ritual?
|
|
||||||
@State private var hasCompletedFirstCheckIn = false
|
@State private var hasCompletedFirstCheckIn = false
|
||||||
|
|
||||||
// Presets for "Both" flow
|
// Presets for preview flow
|
||||||
@State private var morningPreset: RitualPreset?
|
@State private var pendingPresets: [RitualPreset] = []
|
||||||
@State private var eveningPreset: RitualPreset?
|
@State private var currentPresetIndex: Int = 0
|
||||||
|
|
||||||
enum WizardStep: Int, CaseIterable {
|
enum WizardStep: Int, CaseIterable {
|
||||||
case welcome = 0
|
case welcome = 0
|
||||||
case goalSelection = 1
|
case goalSelection = 1
|
||||||
case timeSelection = 2
|
case timeSelection = 2
|
||||||
case morningRitualPreview = 3 // Morning preview (or single for Morning/Evening)
|
case ritualPreview = 3
|
||||||
case eveningRitualPreview = 4 // Evening preview (only for "Both")
|
case firstCheckIn = 4
|
||||||
case firstCheckIn = 5
|
case whatsNext = 5
|
||||||
case whatsNext = 6
|
|
||||||
|
|
||||||
var progress: Double {
|
|
||||||
// Normalize progress based on actual steps shown
|
|
||||||
Double(rawValue) / Double(WizardStep.allCases.count - 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the user selected "Both" for time preference
|
/// Whether the user selected "Both" for time preference
|
||||||
@ -43,12 +36,21 @@ struct SetupWizardView: View {
|
|||||||
|
|
||||||
/// The first ritual that was created (for first check-in)
|
/// The first ritual that was created (for first check-in)
|
||||||
private var firstCreatedRitual: Ritual? {
|
private var firstCreatedRitual: Ritual? {
|
||||||
morningRitual ?? eveningRitual
|
createdRituals.first
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether any ritual was created
|
/// Whether any ritual was created
|
||||||
private var hasCreatedRitual: Bool {
|
private var hasCreatedRitual: Bool {
|
||||||
morningRitual != nil || eveningRitual != nil
|
!createdRituals.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentPreset: RitualPreset? {
|
||||||
|
guard currentPresetIndex < pendingPresets.count else { return nil }
|
||||||
|
return pendingPresets[currentPresetIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalPresets: Int {
|
||||||
|
pendingPresets.count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether to show the back button
|
/// Whether to show the back button
|
||||||
@ -60,9 +62,7 @@ struct SetupWizardView: View {
|
|||||||
return true
|
return true
|
||||||
case .timeSelection:
|
case .timeSelection:
|
||||||
return true
|
return true
|
||||||
case .morningRitualPreview:
|
case .ritualPreview:
|
||||||
return true
|
|
||||||
case .eveningRitualPreview:
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,7 +93,7 @@ struct SetupWizardView: View {
|
|||||||
|
|
||||||
case .goalSelection:
|
case .goalSelection:
|
||||||
GoalSelectionStepView(
|
GoalSelectionStepView(
|
||||||
selectedGoal: $selectedGoal,
|
selectedGoals: $selectedGoals,
|
||||||
onContinue: advanceToNextStep
|
onContinue: advanceToNextStep
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -103,25 +103,14 @@ struct SetupWizardView: View {
|
|||||||
onContinue: handleTimeSelectionContinue
|
onContinue: handleTimeSelectionContinue
|
||||||
)
|
)
|
||||||
|
|
||||||
case .morningRitualPreview:
|
case .ritualPreview:
|
||||||
if let preset = morningPreset {
|
if let preset = currentPreset {
|
||||||
RitualPreviewStepView(
|
RitualPreviewStepView(
|
||||||
preset: preset,
|
preset: preset,
|
||||||
ritualIndex: isBothMode ? 1 : nil,
|
ritualIndex: totalPresets > 1 ? currentPresetIndex + 1 : nil,
|
||||||
totalRituals: isBothMode ? 2 : nil,
|
totalRituals: totalPresets > 1 ? totalPresets : nil,
|
||||||
onStartRitual: { createMorningRitualAndAdvance() },
|
onStartRitual: { createCurrentRitualAndAdvance() },
|
||||||
onSkip: { skipMorningAndAdvance() }
|
onSkip: { skipCurrentAndAdvance() }
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .eveningRitualPreview:
|
|
||||||
if let preset = eveningPreset {
|
|
||||||
RitualPreviewStepView(
|
|
||||||
preset: preset,
|
|
||||||
ritualIndex: isBothMode ? 2 : nil,
|
|
||||||
totalRituals: isBothMode ? 2 : nil,
|
|
||||||
onStartRitual: { createEveningRitualAndAdvance() },
|
|
||||||
onSkip: { skipEveningAndAdvance() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,21 +185,20 @@ struct SetupWizardView: View {
|
|||||||
|
|
||||||
/// Adjusted progress value that accounts for skipped steps
|
/// Adjusted progress value that accounts for skipped steps
|
||||||
private var progressValue: Double {
|
private var progressValue: Double {
|
||||||
// For non-Both flows, we skip eveningRitualPreview
|
|
||||||
let totalSteps: Double = isBothMode ? 7 : 6
|
|
||||||
let currentStepValue: Double
|
|
||||||
|
|
||||||
switch currentStep {
|
switch currentStep {
|
||||||
case .welcome: currentStepValue = 0
|
case .welcome:
|
||||||
case .goalSelection: currentStepValue = 1
|
return 0.0
|
||||||
case .timeSelection: currentStepValue = 2
|
case .goalSelection:
|
||||||
case .morningRitualPreview: currentStepValue = 3
|
return 0.25
|
||||||
case .eveningRitualPreview: currentStepValue = 4
|
case .timeSelection:
|
||||||
case .firstCheckIn: currentStepValue = isBothMode ? 5 : 4
|
return 0.5
|
||||||
case .whatsNext: currentStepValue = isBothMode ? 6 : 5
|
case .ritualPreview:
|
||||||
|
return 0.7
|
||||||
|
case .firstCheckIn:
|
||||||
|
return 0.9
|
||||||
|
case .whatsNext:
|
||||||
|
return 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentStepValue / (totalSteps - 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation Actions
|
// MARK: - Navigation Actions
|
||||||
@ -223,14 +211,14 @@ struct SetupWizardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func goBack() {
|
private func goBack() {
|
||||||
// Handle back navigation with step skipping
|
if currentStep == .ritualPreview, currentPresetIndex > 0 {
|
||||||
var targetStep = currentStep.rawValue - 1
|
withAnimation {
|
||||||
|
currentPresetIndex -= 1
|
||||||
// If going back from firstCheckIn in non-Both mode, skip eveningRitualPreview
|
}
|
||||||
if currentStep == .firstCheckIn && !isBothMode {
|
return
|
||||||
targetStep = WizardStep.morningRitualPreview.rawValue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let targetStep = currentStep.rawValue - 1
|
||||||
guard targetStep >= 0,
|
guard targetStep >= 0,
|
||||||
let previousStep = WizardStep(rawValue: targetStep) else { return }
|
let previousStep = WizardStep(rawValue: targetStep) else { return }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@ -247,70 +235,61 @@ struct SetupWizardView: View {
|
|||||||
// MARK: - Time Selection Handler
|
// MARK: - Time Selection Handler
|
||||||
|
|
||||||
private func handleTimeSelectionContinue() {
|
private func handleTimeSelectionContinue() {
|
||||||
guard let goal = selectedGoal, let time = selectedTime else { return }
|
guard let time = selectedTime, !selectedGoals.isEmpty else { return }
|
||||||
|
|
||||||
// Prepare presets based on time selection
|
var presets: [RitualPreset] = []
|
||||||
|
for goal in selectedGoals {
|
||||||
switch time {
|
switch time {
|
||||||
case .morning:
|
case .morning:
|
||||||
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning)
|
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
|
||||||
eveningPreset = nil
|
presets.append(preset)
|
||||||
|
}
|
||||||
case .evening:
|
case .evening:
|
||||||
// For evening only, we still use morningRitualPreview step but show evening preset
|
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
|
||||||
morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening)
|
presets.append(preset)
|
||||||
eveningPreset = nil
|
}
|
||||||
|
|
||||||
case .both:
|
case .both:
|
||||||
let presets = OnboardingPresetRecommender.recommendedPresets(for: goal)
|
if let morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
|
||||||
morningPreset = presets.morning
|
presets.append(morningPreset)
|
||||||
eveningPreset = presets.evening
|
}
|
||||||
|
if let eveningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
|
||||||
|
presets.append(eveningPreset)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
advanceToNextStep()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Morning Ritual Actions
|
|
||||||
|
|
||||||
private func createMorningRitualAndAdvance() {
|
|
||||||
guard let preset = morningPreset else { return }
|
|
||||||
morningRitual = store.createRitual(from: preset)
|
|
||||||
advanceFromMorningPreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func skipMorningAndAdvance() {
|
|
||||||
advanceFromMorningPreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func advanceFromMorningPreview() {
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
if isBothMode && eveningPreset != nil {
|
pendingPresets = presets
|
||||||
// Go to evening preview
|
currentPresetIndex = 0
|
||||||
currentStep = .eveningRitualPreview
|
createdRituals = []
|
||||||
} else if hasCreatedRitual {
|
hasCompletedFirstCheckIn = false
|
||||||
// Go to first check-in
|
|
||||||
currentStep = .firstCheckIn
|
if presets.isEmpty {
|
||||||
} else {
|
|
||||||
// No rituals created, go to what's next
|
|
||||||
currentStep = .whatsNext
|
currentStep = .whatsNext
|
||||||
|
} else {
|
||||||
|
currentStep = .ritualPreview
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Evening Ritual Actions
|
// MARK: - Ritual Preview Actions
|
||||||
|
|
||||||
private func createEveningRitualAndAdvance() {
|
private func createCurrentRitualAndAdvance() {
|
||||||
guard let preset = eveningPreset else { return }
|
guard let preset = currentPreset else { return }
|
||||||
eveningRitual = store.createRitual(from: preset)
|
let ritual = store.createRitual(from: preset)
|
||||||
advanceFromEveningPreview()
|
createdRituals.append(ritual)
|
||||||
|
advanceFromPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func skipEveningAndAdvance() {
|
private func skipCurrentAndAdvance() {
|
||||||
advanceFromEveningPreview()
|
advanceFromPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func advanceFromEveningPreview() {
|
private func advanceFromPreview() {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
if hasCreatedRitual {
|
if currentPresetIndex + 1 < pendingPresets.count {
|
||||||
|
currentPresetIndex += 1
|
||||||
|
} else if hasCreatedRitual {
|
||||||
// Go to first check-in
|
// Go to first check-in
|
||||||
currentStep = .firstCheckIn
|
currentStep = .firstCheckIn
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -48,9 +48,6 @@ struct RitualsView: View {
|
|||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
))
|
))
|
||||||
.onAppear {
|
|
||||||
store.refresh()
|
|
||||||
}
|
|
||||||
.navigationTitle(String(localized: "Rituals"))
|
.navigationTitle(String(localized: "Rituals"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@ -74,14 +75,25 @@ struct RootView: View {
|
|||||||
refreshCurrentTab()
|
refreshCurrentTab()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedTab) { _, _ in
|
||||||
|
refreshCurrentTab()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshCurrentTab() {
|
private func refreshCurrentTab() {
|
||||||
store.refresh()
|
|
||||||
if selectedTab == .settings {
|
|
||||||
settingsStore.refresh()
|
|
||||||
Task {
|
Task {
|
||||||
|
// Let tab selection UI update before refreshing data.
|
||||||
|
await Task.yield()
|
||||||
|
let refreshStart = CFAbsoluteTimeGetCurrent()
|
||||||
|
store.refresh()
|
||||||
|
PerformanceLogger.logDuration("RootView.refreshCurrentTab.store.refresh", from: refreshStart)
|
||||||
|
if selectedTab == .settings {
|
||||||
|
let settingsStart = CFAbsoluteTimeGetCurrent()
|
||||||
|
settingsStore.refresh()
|
||||||
|
PerformanceLogger.logDuration("RootView.refreshCurrentTab.settings.refresh", from: settingsStart)
|
||||||
|
let reminderStart = CFAbsoluteTimeGetCurrent()
|
||||||
await store.reminderScheduler.refreshStatus()
|
await store.reminderScheduler.refreshStatus()
|
||||||
|
PerformanceLogger.logDuration("RootView.refreshCurrentTab.reminderStatus", from: reminderStart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,7 +117,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
systemImage: "arrow.counterclockwise",
|
systemImage: "arrow.counterclockwise",
|
||||||
title: String(localized: "Reset Onboarding"),
|
title: String(localized: "Reset Setup Wizard"),
|
||||||
iconColor: AppStatus.warning
|
iconColor: AppStatus.warning
|
||||||
) {
|
) {
|
||||||
// Reset both the old and new onboarding flags
|
// Reset both the old and new onboarding flags
|
||||||
|
|||||||
@ -80,6 +80,7 @@ struct TodayEmptyStateView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.background(AppSurface.card)
|
.background(AppSurface.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,21 @@ struct TodayNoRitualsForTimeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var nextRitualTomorrow: Ritual? {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let tomorrowDate = calendar.startOfDay(
|
||||||
|
for: calendar.date(byAdding: .day, value: 1, to: Date()) ?? Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
return store.currentRituals
|
||||||
|
.filter { ritual in
|
||||||
|
guard let arc = ritual.currentArc, arc.contains(date: tomorrowDate) else { return false }
|
||||||
|
return ritual.timeOfDay != .anytime
|
||||||
|
}
|
||||||
|
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||||
|
.first
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
SectionHeaderView(
|
SectionHeaderView(
|
||||||
@ -78,6 +93,17 @@ struct TodayNoRitualsForTimeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
|
} else if let tomorrowRitual = nextRitualTomorrow {
|
||||||
|
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||||
|
Text(String.localizedStringWithFormat(
|
||||||
|
format,
|
||||||
|
tomorrowRitual.timeOfDay.displayName,
|
||||||
|
tomorrowRitual.timeOfDay.timeRange
|
||||||
|
))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Motivational message
|
// Motivational message
|
||||||
@ -88,6 +114,7 @@ struct TodayNoRitualsForTimeView: View {
|
|||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.background(AppSurface.card)
|
.background(AppSurface.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,9 +55,6 @@ struct TodayView: View {
|
|||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
))
|
))
|
||||||
.onAppear {
|
|
||||||
store.refresh()
|
|
||||||
}
|
|
||||||
.sheet(isPresented: .init(
|
.sheet(isPresented: .init(
|
||||||
get: { showRenewalSheet },
|
get: { showRenewalSheet },
|
||||||
set: { if !$0 { store.dismissRenewalPrompt() } }
|
set: { if !$0 { store.dismissRenewalPrompt() } }
|
||||||
|
|||||||
@ -71,8 +71,8 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
|||||||
- Debug tools: reset onboarding, app icon generation, branding preview
|
- Debug tools: reset onboarding, app icon generation, branding preview
|
||||||
|
|
||||||
### Onboarding
|
### Onboarding
|
||||||
- Sherpa-powered walkthrough on first launch
|
- Setup wizard on first launch (goal, time, ritual preview, first check-in)
|
||||||
- Highlights focus ritual card and habit check-in flow
|
- Ends with a quick orientation to Today, Rituals, and Insights
|
||||||
- Debug reset available in Settings
|
- Debug reset available in Settings
|
||||||
|
|
||||||
### Branding & Launch
|
### Branding & Launch
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user