diff --git a/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme b/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme index 1a15726f..21466c5d 100644 --- a/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme +++ b/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme @@ -89,7 +89,7 @@ + isEnabled = "NO"> + isEnabled = "NO"> + isEnabled = "NO"> diff --git a/MyHeartCounts/Account/AccountSheet.swift b/MyHeartCounts/Account/AccountSheet.swift index e5dff396..fe9fd1ae 100644 --- a/MyHeartCounts/Account/AccountSheet.swift +++ b/MyHeartCounts/Account/AccountSheet.swift @@ -144,6 +144,9 @@ struct AccountSheet: View { .contentShape(Rectangle()) .foregroundStyle(colorScheme.textLabelForegroundStyle) } + NavigationLink("View Participation Stats") { + ParticipationStatsView(enrollment: enrollment) + } PostTrialNudgesToggle() NavigationLink("Review Consent Forms") { SignedConsentForms() diff --git a/MyHeartCounts/Account/ParticipationStatsProvider.swift b/MyHeartCounts/Account/ParticipationStatsProvider.swift new file mode 100644 index 00000000..a96ed25d --- /dev/null +++ b/MyHeartCounts/Account/ParticipationStatsProvider.swift @@ -0,0 +1,406 @@ +// +// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import Spezi +import SpeziHealthKit +import SpeziScheduler +import SpeziStudy + + +@Observable +final class ParticipationStatsProvider: Module, EnvironmentAccessible, @unchecked Sendable { + // swiftlint:disable attributes + @ObservationIgnored @Dependency(HealthKit.self) private var healthKit + @ObservationIgnored @Dependency(Scheduler.self) private var scheduler + // swiftlint:enable attributes + + nonisolated init() {} +} + + +extension ParticipationStatsProvider { + struct Stats: Sendable { + let enrollment: EnrollmentStats + let appEngagement: AppEngagementStats? + let taskEngagement: TaskEngagementStats + let health: HealthStats + } + + + struct EnrollmentStats: Sendable { + let enrollmentDate: Date + let numDaysEnrolled: Int + let numWeeksEnrolled: Int + let numMonthsEnrolled: Int + let numYearsEnrolled: Int + } + + struct AppEngagementStats: Sendable { + /// The user's current streak of opening the app at least once per week. + let currentLaunchAppStreak: Int + /// The user's longest recorded streak of opening the app at least once per week. + let longestLaunchAppStreak: Int + } + + struct TaskEngagementStats: Sendable { + let totalCompleted: Int + let questionnairesCompleted: Int + let articlesRead: Int + let ecgsRecorded: Int + let walkRunTestsCompleted: Int + } + + + struct HealthStats: Sendable { + struct WorkoutInfo: Sendable { + let numWorkouts: Int + let totalDuration: Measurement + } + + struct LongestWorkoutInfo: Sendable { + let date: Date + let activityType: HKWorkoutActivityType + let duration: Measurement + } + + struct PersonalBests: Sendable { + struct Entry: Sendable { // swiftlint:disable:this nesting + let date: Date + let value: Value + + fileprivate func map(_ transform: (Value) -> NewValue) -> Entry { + .init(date: date, value: transform(value)) + } + } + + let bestDailySteps: Entry? + let longestWorkout: LongestWorkoutInfo? + let maxHeartRateBPM: Int? + let avgRestingHeartRateBPM: Int? + } + + let totalSteps: Int? + let totalActiveEnergyKcal: Double? + let totalDistanceWalkingRunning: Measurement? + let totalExerciseTime: Measurement? + let totalFlightsClimbed: Int? + let totalHeartbeats: Int? + let totalSleepTime: Measurement? + let workoutInfo: WorkoutInfo? + let personalBests: PersonalBests + } +} + + +extension ParticipationStatsProvider { + func computeStats(for enrollment: StudyEnrollment) async -> Stats { + let now = Date() + let cal = Calendar.current + let studyId = enrollment.studyId + let enrollmentDate = enrollment.enrollmentDate + let enrollmentTimeRange = if enrollmentDate < now { + cal.startOfDay(for: enrollmentDate)..) -> AppEngagementStats { + // TODO (we don't have app opening tracking yet...) + return AppEngagementStats( + currentLaunchAppStreak: 0, + longestLaunchAppStreak: 0 + ) + } + + @MainActor // required bc of the Scheduler... + private func computeTaskEngagementStats( + studyId: UUID, + enrollmentTimeRange: Range + ) async -> TaskEngagementStats { + // ECGs come from HealthKit rather than from Scheduler outcomes so the count survives a + // reinstall (HealthKit data persists; the Scheduler's local task-completion store does not). + async let ecgCount = countECGs(in: enrollmentTimeRange) + let events: [Event] = (try? scheduler.queryEvents(for: enrollmentTimeRange)) ?? [] + let studyEvents = events.filter { event in + event.isCompleted && event.task.studyContext?.studyId == studyId + } + var perCategory: [Task.Category: Int] = [:] + for event in studyEvents { + if let cat = event.task.category { + perCategory[cat, default: 0] += 1 + } + } + let walkRun = (perCategory[.timedWalkingTest] ?? 0) + (perCategory[.timedRunningTest] ?? 0) + return TaskEngagementStats( + totalCompleted: studyEvents.count, + questionnairesCompleted: perCategory[.questionnaire] ?? 0, + articlesRead: perCategory[.informational] ?? 0, + ecgsRecorded: (await ecgCount) ?? 0, + walkRunTestsCompleted: walkRun + ) + } +} + + +extension ParticipationStatsProvider { + // MARK: Health Stats + + private func computeHealthStats(for timeRange: Range) async -> HealthStats { + async let steps = cumulativeSum(in: timeRange, of: .stepCount).map(Int.init) + async let energy = cumulativeSum(in: timeRange, of: .activeEnergyBurned) + async let distance = cumulativeSum(in: timeRange, of: .distanceWalkingRunning, using: .meter()) + async let exerciseMin = cumulativeSum(in: timeRange, of: .appleExerciseTime) + async let flights = cumulativeSum(in: timeRange, of: .flightsClimbed).map(Int.init) + async let heartbeats = estimateTotalHeartbeats(in: timeRange) + async let sleepSec = totalSleepSeconds(in: timeRange) + async let workoutStats = loadWorkoutStats(in: timeRange) + async let personalBests = computePersonalBests(in: timeRange) + return HealthStats( + totalSteps: await steps, + totalActiveEnergyKcal: await energy, + totalDistanceWalkingRunning: (await distance).map { .init(value: $0, unit: .meters) }, + totalExerciseTime: (await exerciseMin).map { .init(value: $0 * 60, unit: .seconds) }, + totalFlightsClimbed: await flights, + totalHeartbeats: Int(await heartbeats), + totalSleepTime: (await sleepSec).map { .init(value: $0, unit: .seconds) }, + workoutInfo: await workoutStats, + personalBests: await personalBests + ) + } + + private func computePersonalBests(in timeRange: Range) async -> HealthStats.PersonalBests { + async let bestStepDay = bestDay(in: timeRange, of: .stepCount)?.map { Int($0) } + async let longestWorkout = longestWorkout(in: timeRange) + async let maxHR: Double? = discreteStat( + in: timeRange, + of: .heartRate, + option: .max, + aggregator: { $0.maximumQuantity()?.doubleValue(for: .count() / .minute()) }, + reducer: { $0.max() } + ) + async let avgRestingHR: Double? = discreteStat( + in: timeRange, + of: .restingHeartRate, + option: .average, + aggregator: { $0.averageQuantity()?.doubleValue(for: .count() / .minute()) }, + reducer: { $0.isEmpty ? nil : $0.reduce(0, +) / Double($0.count) } + ) + return HealthStats.PersonalBests( + bestDailySteps: await bestStepDay, + longestWorkout: await longestWorkout, + maxHeartRateBPM: (await maxHR).map { Int($0.rounded()) }, + avgRestingHeartRateBPM: (await avgRestingHR).map { Int($0.rounded()) } + ) + } + + private func cumulativeSum( + in timeRange: Range, + of sampleType: SampleType, + using unit: HKUnit? = nil + ) async -> Double? { + guard let stats = try? await healthKit.statisticsQuery( + sampleType, + aggregatedBy: [.sum], + over: .year, + timeRange: .init(timeRange) + ) else { + return nil + } + let unit = unit ?? sampleType.displayUnit + return stats.reduce(0) { $0 + ($1.sumQuantity()?.doubleValue(for: unit) ?? 0) } + } + + /// Determines the daily best for a cumulative sample type, e.g. the max total quantity value observed over the course of a single day. + /// + /// - parameter timeRange: The time range for which the daily best should be computed + /// - parameter sampleType: The sample type to operate on + /// - parameter unit: The `HKUnit` to use when working with samples. Defaults to `sampleType.displayUnit` if `nil`. + /// - parameter isConsideredBetter: Compares two values (of unit `unit`) to determine if the first one is "better" than the second one. + /// Defaults to a greater-than comparison, which results in the function selecting the highest daily sum. + /// For a metric where the lowest-possible value is be considered the "daily best", you would pass a lower-than comparison function (i.e., `(<)`). + private func bestDay( + in timeRange: Range, + of sampleType: SampleType, + using unit: HKUnit? = nil, + isConsideredBetter: (_ fst: Double, _ snd: Double) -> Bool = (>) + ) async -> HealthStats.PersonalBests.Entry? { + guard sampleType.hkSampleType.aggregationStyle == .cumulative else { + return nil + } + let cal = Calendar.current + assert(timeRange.lowerBound == cal.startOfDay(for: timeRange.lowerBound)) + guard let stats = try? await healthKit.statisticsQuery( + sampleType, + aggregatedBy: [.sum], + over: .day, + timeRange: .init(timeRange) + ) else { + return nil + } + let unit = unit ?? sampleType.displayUnit + return stats + .compactMap { stat -> HealthStats.PersonalBests.Entry? in + guard let value = stat.sumQuantity()?.doubleValue(for: unit), value > 0 else { + return nil + } + return .init(date: stat.startDate, value: value) + } + .max { isConsideredBetter($1.value, $0.value) } + } + + private func discreteStat( + in timeRange: Range, + of sampleType: SampleType, + option: HealthKit.DiscreteAggregationOption, + aggregator: (HKStatistics) -> Double?, + reducer: ([Double]) -> Double? + ) async -> Double? { + guard let stats = try? await healthKit.statisticsQuery( + sampleType, + aggregatedBy: [option], + over: .year, + timeRange: .init(timeRange) + ) else { + return nil + } + return reducer(stats.compactMap(aggregator)) + } + + private func countECGs(in timeRange: Range) async -> Int? { + (try? await healthKit.query(.electrocardiogram, timeRange: .init(timeRange)))?.count + } + + private func estimateTotalHeartbeats(in timeRange: Range) async -> Double { + // Aggregate by day; for each day's avg BPM, multiply by the actual recorded interval + // (clamped to the enrollment range). The result undercounts hours the user wasn't + // wearing the watch, which is fine - we label it as an estimate. + guard let stats = try? await healthKit.statisticsQuery( + .heartRate, + aggregatedBy: [.average], + over: .day, + timeRange: .init(timeRange) + ) else { + return 0 + } + return stats.reduce(0) { acc, stat in + guard let bpm = stat.averageQuantity()?.doubleValue(for: .count() / .minute()) else { + return acc + } + let clamped = stat.timeRange.clamped(to: timeRange) + let minutes = clamped.timeInterval / 60 + return acc + bpm * minutes + } + } + + @concurrent + private func totalSleepSeconds(in timeRange: Range) async -> Double? { + guard let samples = try? await healthKit.query( + .sleepAnalysis, + timeRange: .init(timeRange), + source: .appleHealthSystem + ) else { + return nil + } + if let sessions = try? samples.splitIntoSleepSessions(separateBySource: false) { + // preferred path. will not overcount overlapping samples, in part bc the `separateBySource` param above is false. + return sessions.reduce(0) { total, session in + total + session.totalTimeSpentAsleep + } + } else { + // fallback in case we can't compute the sessions. + // this will be slightly inaccurate, but at least will show _something_ + let asleepValues = HKCategoryValueSleepAnalysis.allAsleepValues.mapIntoSet(\.rawValue) + return samples.lazy + .filter { asleepValues.contains($0.value) } + .reduce(0) { acc, sample in + acc + sample.endDate.timeIntervalSince(sample.startDate) + } + } + } + + private func loadWorkoutStats(in timeRange: Range) async -> HealthStats.WorkoutInfo? { + guard let workouts = try? await healthKit.query(.workout, timeRange: .init(timeRange)) else { + return nil + } + return .init( + numWorkouts: workouts.count, + totalDuration: .init(value: workouts.reduce(0.0) { $0 + $1.duration }, unit: .seconds) + ) + } + + private func longestWorkout(in timeRange: Range) async -> HealthStats.LongestWorkoutInfo? { + guard let workouts = try? await healthKit.query(.workout, timeRange: .init(timeRange)) else { + return nil + } + return workouts + .max { $0.duration < $1.duration } + .map { + .init( + date: $0.startDate, + activityType: $0.workoutActivityType, + duration: .init(value: $0.duration, unit: .seconds) + ) + } + } +} + + +private func computeWeekStreaks( + activeWeeks: Set, + thisWeek: Date, + calendar cal: Calendar +) -> (current: Int, longest: Int) { + guard !activeWeeks.isEmpty else { + return (0, 0) + } + var longest = 0 + var run = 0 + var previous: Date? + for week in activeWeeks.sorted() { + if let prev = previous, cal.dateComponents([.weekOfYear], from: prev, to: week).weekOfYear == 1 { + run += 1 + } else { + run = 1 + } + longest = max(longest, run) + previous = week + } + var cursor = thisWeek + if !activeWeeks.contains(cursor) { + guard let previousWeek = cal.date(byAdding: .weekOfYear, value: -1, to: cursor) else { + return (0, longest) + } + cursor = previousWeek + } + var current = 0 + while activeWeeks.contains(cursor) { + current += 1 + guard let next = cal.date(byAdding: .weekOfYear, value: -1, to: cursor) else { + break + } + cursor = next + } + return (current, longest) +} diff --git a/MyHeartCounts/Account/ParticipationStatsView.swift b/MyHeartCounts/Account/ParticipationStatsView.swift new file mode 100644 index 00000000..46e0fbfa --- /dev/null +++ b/MyHeartCounts/Account/ParticipationStatsView.swift @@ -0,0 +1,703 @@ +// +// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +// swiftlint:disable attributes file_types_order file_length + +import Foundation +import SFSafeSymbols +import SpeziFoundation +import SpeziStudy +import SpeziViews +import SwiftUI + + +/// Displays interesting (though not necessarily scientificaly useful) statisics about the user's participation in the study. +struct ParticipationStatsView: View { + @Environment(\.calendar) private var cal + @Environment(ParticipationStatsProvider.self) private var statsProvider + @Environment(AchievementsManager.self) private var achievementsManager + + private let enrollment: StudyEnrollment + @State private var stats: ParticipationStatsProvider.Stats? + @State private var isShowingExplainerSheet = false + + var body: some View { + Form { + EnrollmentStatsSection(enrollmentDate: enrollment.enrollmentDate) + TiledSection("Engagement"/*, symbol: .checklistChecked*/) { + engagementSection(using: stats) + } + TiledSection("Health Totals"/*, symbol: .heartFill*/) { + healthTotalsSection(using: stats?.health) + } + TiledSection("Personal Bests"/*, symbol: .starFill*/) { + personalBestsSection(using: stats?.health.personalBests) + } + funFactsSection() + } + .navigationTitle("Stats and Achievements") + .navigationBarTitleDisplayMode(.inline) + .task { + await updateStats() + } + .refreshable { + await updateStats() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + isShowingExplainerSheet = true + } label: { + Label("Info", systemSymbol: .infoCircle) + .labelStyle(.iconOnly) + } + } + } + .sheet(isPresented: $isShowingExplainerSheet) { + NavigationStack { + ScrollView { + Text("PARTICIPATION_STATS_EXPLAINER(enrollmentDate: \(enrollment.enrollmentDate, format: .dateTime))") + // Note that leaving the study (e.g., by deleting the app or logging out) and re-enrolling will reset some of the engagement-related statistics. + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + .navigationTitle("Info") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + DismissButton() + } + } + } + } + } + + init(enrollment: StudyEnrollment) { + self.enrollment = enrollment + } + + private func updateStats() async { + stats = await statsProvider.computeStats(for: enrollment) + } +} + + +// MARK: - Sections + +extension ParticipationStatsView { + @ViewBuilder + private func engagementSection( // swiftlint:disable:this function_body_length + using stats: ParticipationStatsProvider.Stats? + ) -> some View { + StatCard( + title: "Tasks Done", + value: stats?.taskEngagement.totalCompleted, + format: .number, + symbol: .checkmarkCircleFill, + accentColor: .accentColor + ) + if let appEngagement = stats?.appEngagement { + // TODO verify that this starts counting at 1, ie even if the user only has had the app installed for like 2-3 days!! + StatCard( + title: "Current Streak", + value: appEngagement.currentLaunchAppStreak, + format: .weekCount, + symbol: .flameFill, + accentColor: .orange + ) + if appEngagement.longestLaunchAppStreak >= 2 { + StatCard( + title: "Longest Streak", + value: appEngagement.longestLaunchAppStreak, + format: .weekCount, + symbol: .trophyFill, + accentColor: .yellow + ) + } + } + StatCard( + title: "Surveys Answered", + value: stats?.taskEngagement.questionnairesCompleted, + format: .number, + symbol: .listClipboardFill, + accentColor: .purple + ) + StatCard( + title: "Articles Read", + value: stats?.taskEngagement.articlesRead, + format: .number, + symbol: .bookFill, + accentColor: .brown + ) + StatCard( + title: "ECGs Recorded", + value: stats?.taskEngagement.ecgsRecorded, + format: .number, + symbol: .waveformPathEcgRectangle, + accentColor: .red + ) + StatCard( + title: "Walk / Run Tests", + value: stats?.taskEngagement.walkRunTestsCompleted, + format: .number, + symbol: .figureWalk, + accentColor: .blue + ) + } + + @ViewBuilder + private func healthTotalsSection( // swiftlint:disable:this function_body_length + using stats: ParticipationStatsProvider.HealthStats? + ) -> some View { + StatCard( + title: "Steps", + value: stats?.totalSteps, + format: .compactNumber, + symbol: .figureWalk, + accentColor: .green + ) + StatCard( + title: "Heartbeats", + value: stats?.totalHeartbeats, + format: .compactNumber, + symbol: .heartFill, + accentColor: .red, + subtitle: "Estimate" + ) + StatCard( + title: "Distance", + value: stats?.totalDistanceWalkingRunning?.value, + format: .distance, + symbol: .mapFill, + accentColor: .blue + ) + StatCard( + title: "Active Energy", + value: stats?.totalActiveEnergyKcal, + format: .energyKcal, + symbol: .flameFill, + accentColor: .orange + ) + StatCard( + title: "Exercise Time", + value: stats?.totalExerciseTime?.value, + format: .duration, + symbol: .figureRun, + accentColor: .green + ) + StatCard( + title: "Sleep", + value: stats?.totalSleepTime?.value, + format: .duration, + symbol: .bedDoubleFill, + accentColor: .indigo + ) + StatCard( + title: "Workouts", + value: stats?.workoutInfo?.numWorkouts, + format: .number, + symbol: .figureCooldown, + accentColor: .teal + ) + StatCard( + title: "Flights Climbed", + value: stats?.totalFlightsClimbed, + format: .number, + symbol: .figureStairs, + accentColor: .cyan + ) + } + + @ViewBuilder + private func personalBestsSection(using stats: ParticipationStatsProvider.HealthStats.PersonalBests?) -> some View { + let dateFormat: Date.FormatStyle = .dateTime.month(.abbreviated).day() + StatCard( + title: "Best Step Day", + value: stats?.bestDailySteps?.value, + format: .compactNumber, + symbol: .figureWalk, + accentColor: .green, + subtitle: (stats?.bestDailySteps?.date).map { "\($0, format: dateFormat)" } + ) + StatCard( + title: "Longest Workout", + value: stats?.longestWorkout?.duration.value, + format: .duration, + symbol: .stopwatchFill, + accentColor: .blue, + subtitle: { () -> LocalizedStringResource? in + let components: [String?] = [ + stats?.longestWorkout?.date.formatted(dateFormat), + stats?.longestWorkout?.activityType.displayTitle.localizedString() + ] + let text = components.lazy.compactMap(\.self).joined(separator: " • ") + return text.isEmpty ? nil : "\(text)" + }() + ) + StatCard( + title: "Max Heart Rate", + value: stats?.maxHeartRateBPM, + format: .heartRate, + symbol: .heartFill, + accentColor: .red + ) + StatCard( + title: "Resting HR", + value: stats?.avgRestingHeartRateBPM, + format: .heartRate, + symbol: .bedDoubleFill, + accentColor: .pink, + subtitle: "Average" + ) + } + + @ViewBuilder + private func funFactsSection() -> some View { + if let funFacts = makeFunFacts(), !funFacts.isEmpty { + Section("Fun Facts") { + ForEach(funFacts) { fact in + FunFactCard(fact: fact) + .listRowInsets(.zero) + .listRowBackground(Color.clear) + } + } + } + } +} + + +// MARK: - Helpers + +extension ParticipationStatsView { + private func makeFunFacts() -> [FunFact]? { // swiftlint:disable:this discouraged_optional_collection + guard let stats else { + return nil + } + let healthStats = stats.health + var facts: [FunFact] = [] + if let steps = healthStats.totalSteps, steps > 0 { + facts.append(.init( + symbol: .figureWalk, + color: .green, + text: stepCountFunFactText(numSteps: steps, distance: healthStats.totalDistanceWalkingRunning) + )) + } + if let beats = healthStats.totalHeartbeats, beats > 0 { + let lifetimePercent = Double(beats) / 3_000_000_000 // QUESTION can we simply estimate 3bn lifetime total heartbeats? + facts.append(.init( + symbol: .heartFill, + color: .red, + text: "Your heart has beaten about \(beats, format: .number.notation(.compactName)) times since you enrolled — roughly \(lifetimePercent, format: .percent.precision(.fractionLength(0...2))) of an average lifetime." + )) + } + if let kcal = healthStats.totalActiveEnergyKcal, kcal > 0 { + let pizzaSlices = Int((kcal / 285).rounded()) + if pizzaSlices > 0 { + facts.append(.init( + symbol: .flameFill, + color: .orange, + text: "You've burned \(Int(kcal), format: .number) active calories — the equivalent of \(pizzaSlices, format: .number) slices of pizza." + )) + } + } + if let sleepSec = healthStats.totalSleepTime?.value(in: .seconds), sleepSec > TimeConstants.day { + let days = sleepSec / TimeConstants.day + facts.append(.init( + symbol: .bedDoubleFill, + color: .indigo, + text: "You've spent about \(days, format: .number.precision(.fractionLength(1))) full days asleep since enrolling. Rest is part of the work." + )) + } + // IDEA re-implement, based on weeks (months?) w/ activity +// if let active = stats.taskEngagement?.activeDays, let total = engagement?.daysSinceEnrollment, total > 0 { +// let percent = Double(active) / Double(total) +// if percent >= 0.7 { +// facts.append(.init( +// symbol: .starFill, +// color: .yellow, +// text: "You've been active on \(active.formatted(.number)) of \(total.formatted(.number)) days — that's a fantastic \(percent.formatted(.percent.precision(.fractionLength(0)))). Keep it up!" +// )) +// } +// } + return facts + } + + + private func stepCountFunFactText( + numSteps: Int, + distance: Measurement? + ) -> LocalizedStringResource { + // extremely unlikely that the user has step counts w/out distance, but we provide a fallback nonetheless. + let distance = distance ?? Measurement(value: Double(numSteps) * 0.762 /* ~0.762m per step*/, unit: .meters) + let distanceKm = distance.value(in: .kilometers) + let distanceFormatted = distance + .converted(to: { () -> UnitLength in + switch Locale.current.measurementSystem { + case .uk, .us: .miles + default: .kilometers // includes .metric + } + }()) + .formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) + let comparison = { () -> LocalizedStringResource? in + if distanceKm >= 20_000 { + let earths = distanceKm / 40_075 + return "about \(earths, format: .number.precision(.fractionLength(1))) trips around the Earth" + } else if distanceKm >= 1_000 { + let coastToCoast = distanceKm / 4_700 // SF to NYC on foot + return "roughly \(coastToCoast, format: .number.precision(.fractionLength(1))) trips from San Francisco to New York" + } else if distanceKm >= 50 { + let marathons = distanceKm / 42.195 + return "the distance of \(marathons, format: .number.precision(.fractionLength(1))) marathons" + } else if distanceKm >= 5 { + let bridges = distanceKm / 2.737 // Golden Gate Bridge + return "\(bridges, format: .number.precision(.fractionLength(0...1))) lengths of the Golden Gate Bridge" + } else if distanceKm >= 0.5 { + let laps = distanceKm / 0.4 // 400m track + return "\(laps, format: .number.precision(.fractionLength(0...1))) laps around a running track" + } else { + return nil + } + }() + return if let comparison { + "Your \(numSteps, format: .number) steps cover about \(distanceFormatted) — that's \(comparison)." + } else { + "Your \(numSteps, format: .number) steps cover roughly \(distanceFormatted)." + } + } +} + + +private struct EnrollmentStatsSection: View { + @Environment(AchievementsManager.self) private var achievements + + let enrollmentDate: Date + + var body: some View { + Section { + NavigationLink { + AchievementsView() + } label: { + // bc we want the whole section to act as a single button that opens the achievements + // (which we achieve by placing all content in a giant NavigationLink), we need to + // build up the Form-like layout by hand. + Group(subviews: sectionContent) { subviews in + VStack(alignment: .leading, spacing: 15) { + ForEach(subviews) { subview in + if subview.id != subviews.first?.id { + Divider() + } + subview + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .navigationLinkIndicatorVisibility(.hidden) + } + } + + private var nextEnrollmentDurationAchievement: AchievementsManager.UpcomingAchievement? { + achievements.nextLockedAchievement(in: .studyParticipation, subcategory: .enrollmentDuration) + } + + @ViewBuilder private var sectionContent: some View { + daysEnrolledRow + achievementsRow + } + + @ViewBuilder + private var daysEnrolledRow: some View { + let numDaysEnrolled = Calendar.current.countDistinctDays(from: enrollmentDate, to: .now) + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(numDaysEnrolled, format: .number) + .font(.system(size: 64, weight: .bold, design: .rounded)) + .monospacedDigit() + Text(numDaysEnrolled > 1 ? "days" : "day") + .font(.title2.weight(.medium)) + .foregroundStyle(.secondary) + Spacer() + } + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Enrolled since") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Text(enrollmentDate, format: .dateTime.year().month(.wide).day()) + .font(.headline) + } + Spacer() + if let achievement = nextEnrollmentDurationAchievement?.achievement { + achievementInfoCapsule(for: achievement) + } + } + } + .overlay(alignment: .topTrailing) { + HStack { + Image(systemSymbol: .medalStar) + .accessibilityLabel("Achievements") + .imageScale(.small) + VStack { + let numUnlocked = achievements.userDisplayableUnlockedAchievementsCount + let numTotal = achievements.userDisplayableTotalAchievementCount + Text("\(numUnlocked, format: .number) / \(numTotal, format: .number)") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + ProgressView(value: Double(numUnlocked) / Double(numTotal)) + .frame(maxWidth: 41) + } + } + } + } + + @ViewBuilder + private var achievementsRow: some View { + let upcoming = achievements + .nextLockedAchievements(excluding: nextEnrollmentDurationAchievement.map { [$0.achievement] } ?? []) + .prefix(3) + if !upcoming.isEmpty { + VStack(alignment: .leading) { + Text("Upcoming Achievements") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + ForEach(upcoming, id: \.achievement) { upcoming in + let achievement = upcoming.achievement + achievementInfoCapsule(for: achievement) + } + } + } + } + + private func achievementInfoCapsule(for achievement: Achievement) -> some View { + HStack { + AchievementIcon(achievement: achievement) + VStack(alignment: .leading) { + Text(achievement.title) + .font(.caption) + Text(achievement.description) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} + + +private struct StatCard: View { + enum Format { + case number + case compactNumber + case weekCount + /// in meters + case distance + /// in kcal + case energyKcal + /// in seconds + case duration + /// in BPM + case heartRate + } + + let title: LocalizedStringResource + let subtitle: LocalizedStringResource? + let value: Double? + let format: Format + let symbol: SFSymbol + let accentColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemSymbol: symbol) + .font(.callout) + .foregroundStyle(accentColor) + .accessibilityHidden(true) + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer() + } + Group { + if let value { + formattedValue(value) + } else { + Text("—") + .foregroundStyle(.tertiary) + } + } + .font(.system(size: 22, weight: .bold, design: .rounded)) + .monospacedDigit() + .contentTransition(.numericText(value: value ?? 0)) + .minimumScaleFactor(0.6) + .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } else { + Text(" ").font(.caption2) // keep card height consistent + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .dashboardTileBackground(cornerRadius: 14) + } + + init( + title: LocalizedStringResource, + value: (some BinaryInteger)?, + format: Format, + symbol: SFSymbol, + accentColor: Color, + subtitle: LocalizedStringResource? = nil + ) { + self.title = title + self.subtitle = subtitle + self.value = value.map { Double($0) } + self.format = format + self.symbol = symbol + self.accentColor = accentColor + } + + init( + title: LocalizedStringResource, + value: (some BinaryFloatingPoint)?, + format: Format, + symbol: SFSymbol, + accentColor: Color, + subtitle: LocalizedStringResource? = nil + ) { + self.title = title + self.value = value.map { Double($0) } + self.format = format + self.symbol = symbol + self.accentColor = accentColor + self.subtitle = subtitle + } + + @ViewBuilder + private func formattedValue(_ value: Double) -> some View { + switch format { + case .number: + Text(Int(value), format: .number) + case .compactNumber: + Text(Int(value), format: .number.notation(.compactName)) + case .weekCount: + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(Int(value), format: .number) + Text("w").font(.title3.weight(.medium)).foregroundStyle(.secondary) + } + case .distance: + let measurement = Measurement(value: value, unit: .meters) + Text(measurement.formatted(.measurement( + width: .abbreviated, + usage: .road, + numberFormatStyle: .number.precision(.fractionLength(0)) + ))) + case .energyKcal: + let measurement = Measurement(value: value, unit: .kilocalories) + Text(measurement.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.notation(.compactName)))) + case .duration: + let duration: Duration = .seconds(value) + let formatStyle: Duration.UnitsFormatStyle = .units( + allowed: duration >= .days(1) ? [.days, .hours] : [.hours, .minutes], + width: .narrow + ) + Text(duration, format: formatStyle) + case .heartRate: + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(Int(value), format: .number) + Text("bpm").font(.title3.weight(.medium)).foregroundStyle(.secondary) + } + } + } +} + + +private struct FunFact: Identifiable { + let id = UUID() + let symbol: SFSymbol + let color: Color + let text: LocalizedStringResource +} + + +private struct FunFactCard: View { + let fact: FunFact + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemSymbol: fact.symbol) + .font(.title3) + .foregroundStyle(fact.color) + .frame(width: 28, height: 28) + .accessibilityHidden(true) + Text(fact.text) + .font(.subheadline) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .dashboardTileBackground(cornerRadius: 0 /*14*/) + } +} + + +extension Measurement { + func value(in unit: UnitType) -> Double where UnitType: Dimension { + self.converted(to: unit).value + } +} + + +// MARK: TMP + +/// A Button that presents a sheet with the participation stats & achievements. +/// +/// - Note: This button is only functional if there exists at least one study enrollment. +/// Otherwise, the button will be disabled and not do anything. +struct ParticipationStatsButton: View { + // Important: we currently assume that there is only ever at most one enrollment, and that that enrollment will always be the MHC one. + // this is correct currently, bc we don't have sub-studies yet, but might change at some point down the road. + @StudyManagerQuery private var enrollments: [StudyEnrollment] + @State private var showStats = false + + private var enrollment: StudyEnrollment? { + enrollments.first + } + + var body: some View { + Button { + showStats = true + } label: { + Label(symbol: .medalStar) { + Text("Stats and Achievements") + } + } + .disabled(enrollment == nil) + .sheet(isPresented: $showStats) { + if let enrollment { + NavigationStack { + ParticipationStatsView(enrollment: enrollment) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + DismissButton() + } + } + } + } + } + } +} diff --git a/MyHeartCounts/Achievements/Achievement.swift b/MyHeartCounts/Achievements/Achievement.swift new file mode 100644 index 00000000..a9ac68ce --- /dev/null +++ b/MyHeartCounts/Achievements/Achievement.swift @@ -0,0 +1,176 @@ +// +// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SFSafeSymbols +import SpeziFoundation + + +/// A tracked goal the user can unlock by satisfying some condition. +/// +/// - Note: ``Achievement`` conforms to both `Equatable` and `Hashable`. +/// Equality and hashing depend solely on the achievement's ``id``. All other properties are ignored. +/// It is the responsibility of the application to ensure that there never exist two or more achievements with equal ``id``s. +struct Achievement: Identifiable, Sendable { + typealias MetricValue = Double + + enum Kind: Sendable { + /// An achievement that unlocks based on the events recorded for a trigger. + /// + /// - parameter predicate: The function that determines whether the achievement's condition is fulfilled, i.e., whether the achievement is unlocked. + /// The trigger events passed to the closure are sorted in increasing order w.r.t. their timestamps. + /// Returns the current ``AchievementsManager/AchievementState``, i.e., locked/unlocked. + case event( + trigger: Trigger, + predicate: @Sendable (_ events: [AchievementsManager.State.TriggerEvent]) -> AchievementsManager.AchievementState + ) + /// An achievement that unlocks when a value associated with some metric reaches a threshold. + case threshold(metric: Metric, target: MetricValue) + + /// An achievement that unlocks when a "thing" happens at least once. + static func eventOnce(trigger: Trigger) -> Self { + .counting(trigger: trigger, target: 1) + } + + /// An achievement that unlocks when the amount of times a specific "thing" happened exceeds a threshold. + /// + /// - parameter target: the trigger threshold. Must be a positive value greater than 0; otherwise the achievement will always be considered locked. + static func counting(trigger: Trigger, target: Int) -> Self { + // Qurstions: + // 1. do we want to support negative trigger-based achievement kinds? + // ie, the achievement would be unlocked as long as you don't have any triggers, but become locked once they exist? + // what would the unlockDate be in that case? + // 2. if there is an "trigger once" achievement that can be unlocked multiple times (bc the trigger is fired multiple times), + // should we use the first, or the latest, date for the completion? (currently it's the first, which probably is what + // we want most if not all of the time... + .event(trigger: trigger) { events in + guard target > 0 else { + return .locked(progress: 0, lastUpdate: nil) + } + // assuming that events is sorted in ascending order, this will give us the first event that fulfilled the target count + if let event = events[safe: target - 1] { + return .unlocked(unlockDate: event.timestamp) + } else { + return .locked( + progress: Double(events.count) / Double(target), + lastUpdate: events.last?.timestamp + ) + } + } + } + } + + struct Trigger: Identifiable, Hashable, Codable, Sendable { + enum RecordingMode: String, Hashable, Codable, Sendable { + /// The ``AchievementsManager`` will keep records of all times the trigger was fired. + case keepAll = "keep-all" + /// The ``AchievementsManager`` will keep record of only the first time the trigger was fired. + case recordOnce = "record-once" + } + let id: String + let recordingMode: RecordingMode + } + + struct Metric: Identifiable, Hashable, Codable, Sendable { + let id: String + let rule: ThresholdRule + } + + struct Category: Identifiable, Hashable, Sendable { + let id: String + let title: LocalizedStringResource + } + + struct Subcategory: Identifiable, Hashable, Sendable { + let id: String + let formsLadder: Bool + } + + enum ThresholdRule: RawRepresentable, Hashable, Codable, Sendable { + /// A rule that triggers if the metric's observed value is greater than or equal to its target value. + /// - parameter base: The rule's base value. Used to compute the user's progress in reaching the target. + /// For example, a "daily step count" metric would set its base to `0`, since that's the starting point from which any progress should be computed. + case atLeast(base: MetricValue?) + /// A rule that triggers if the metric's observed value is less than or equal to its target value. + /// - parameter base: The rule's base value. Used to compute the user's progress in reaching the target. + /// For example, a "resting heart rate" metric could set its base to `90`, since that's the starting point from which any progress should be computed. + case atMost(base: MetricValue?) + + var rawValue: String { + switch self { + case .atLeast(.none): + "atLeast" + case .atLeast(base: .some(let value)): + "atLeast(\(value.description))" + case .atMost(.none): + "atMost" + case .atMost(base: .some(let value)): + "atMost(\(value.description))" + } + } + + init?(rawValue: String) { + let name: Substring + let value: MetricValue? + if let parenIdx = rawValue.firstIndex(of: "(") { + guard rawValue.last == ")", let val = MetricValue(rawValue[parenIdx...].dropFirst().dropLast()) else { + return nil + } + name = rawValue[..