From 60bc3bbe6450b9209d398e2c9bdff7418a9c9b1f Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Mon, 18 May 2026 14:37:26 +0200 Subject: [PATCH 01/29] checkpoint --- .../xcschemes/MyHeartCounts.xcscheme | 4 +- MyHeartCounts/Account/AccountSheet.swift | 3 + .../Account/ParticipationStatsView.swift | 213 ++++++++++++++++++ MyHeartCounts/Resources/Localizable.xcstrings | 24 ++ .../SensorKit/SensorKitDataFetcher.swift | 1 + .../Debug Stuff/DataProcessingDebugView.swift | 5 + 6 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 MyHeartCounts/Account/ParticipationStatsView.swift diff --git a/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme b/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme index 1a15726f..ebb891e8 100644 --- a/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme +++ b/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme @@ -121,11 +121,11 @@ + isEnabled = "NO"> + isEnabled = "NO"> diff --git a/MyHeartCounts/Account/AccountSheet.swift b/MyHeartCounts/Account/AccountSheet.swift index e5dff396..d78c0cd2 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" as String) { + ParticipationStatsView(enrollment: enrollment) + } PostTrialNudgesToggle() NavigationLink("Review Consent Forms") { SignedConsentForms() diff --git a/MyHeartCounts/Account/ParticipationStatsView.swift b/MyHeartCounts/Account/ParticipationStatsView.swift new file mode 100644 index 00000000..30aa033c --- /dev/null +++ b/MyHeartCounts/Account/ParticipationStatsView.swift @@ -0,0 +1,213 @@ +// +// 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 all + +import Algorithms +import HealthKit +import MyHeartCountsShared +import SpeziFoundation +import SpeziHealthKit +import SpeziHealthKitUI +import SpeziStudy +import SwiftUI + + +struct ParticipationStatsView: View { + @Environment(\.calendar) private var cal + @Environment(HealthKit.self) private var healthKit + + private let enrollment: StudyEnrollment +// @StudyManagerQuery private var enrollments: [StudyEnrollment] + +// @HealthKitStatisticsQuery private var stepCountStats: [HKStatistics] +// @HealthKitQuery private var heartRateMeasurements: Slice> + @HealthKitStatisticsQuery private var heartRateAverages: [HKStatistics] + + @State private var totalNumSteps = 0 + @State private var totalNumHeartBeats: Double = 0 + @State private var totalNumHeartBeats2: Double = 0 + + init(enrollment: StudyEnrollment) { + self.enrollment = enrollment +// self._stepCountStats = .init(.stepCount, aggregatedBy: .sum, over: .year, timeRange: .startingAt(enrollment.enrollmentDate)) +// self._heartRateAverages = .init(.heartRate, aggregatedBy: .avg, over: .day, timeRange: .startingAt(Calendar.current.startOfDay(for: enrollment.enrollmentDate))) + self._heartRateAverages = .init(.heartRate, aggregatedBy: .avg, over: .hour, timeRange: .startingAt(enrollment.enrollmentDate)) + } + + var body: some View { + // TOOD make it look cool! +// Form { +// Section { +// LabeledContent("Enrolled Since" as String, value: enrollment.enrollmentDate, format: .dateTime) +// if let numDaysEnrolled = cal.dateComponents([.day], from: cal.startOfDay(for: enrollment.enrollmentDate), to: cal.startOfDay(for: .now)).day { +// LabeledContent("Days Enrolled" as String, value: numDaysEnrolled, format: .number) +// } +// } +// Section { +// LabeledContent("#steps" as String, value: totalNumSteps, format: .number) +// LabeledContent("#heartBeats" as String, value: totalNumHeartBeats, format: .number) +// } +// } + ScrollView { + content + } + .navigationTitle("Participation Stats") +// .task(id: stepCountStats) { +// totalNumSteps = stepCountStats.reduce(0) { $0 + Int($1.sumQuantity()?.doubleValue(for: .count()) ?? 0) } +// } +// .task(id: heartRateMeasurements) { + .task { + totalNumSteps = try! await healthKit + .statisticsQuery(.stepCount, aggregatedBy: [.sum], over: .year, timeRange: .startingAt(enrollment.enrollmentDate)) + .reduce(0) { $0 + Int($1.sumQuantity()?.doubleValue(for: .count()) ?? 0) } + let allHeartbeats = try! await healthKit + .query(.heartRate, timeRange: .startingAt(enrollment.enrollmentDate)) +// .reduce(into: 0) { result, sample in +// precondition(sample.count == 1) +// precondition(sample.startDate == sample.endDate, "sample") +// } + for window in allHeartbeats.windows(ofCount: 3) { + let left = window[window.startIndex] + let sample = window[window.startIndex + 1] + let right = window[window.startIndex + 2] + let bpm = sample.quantity.doubleValue(for: .count() / .minute()) + let timeIntervalIMin = (((left.endDate..: View { + private let title: LocalizedStringResource + private let subtitle: LocalizedStringResource + private let content: Content + + var body: some View { + VStack { + Text(title) + content + Text(subtitle) + } + } + + init(title: LocalizedStringResource, subtitle: LocalizedStringResource, @ViewBuilder content: () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content() + } +} + + +private struct StatCellContentNumberWithUnit>: View { + private let value: Value + private let format: Format + private let unit: String + private let doubleValue: Double + + var body: some View { + Text(value, format: format) + .font(.system(size: 32, weight: .bold)) + .overlay(alignment: .trailingLastTextBaseline) { + Text(unit) + .alignmentGuide(.trailing) { $0[.leading] } + .padding(.leading, 4) + } + .contentTransition(.numericText(value: doubleValue)) + .monospacedDigit() + } + + init(_ value: Value, format: Format, unit: String = "") where Value: BinaryInteger { + self.value = value + self.format = format + self.unit = unit + self.doubleValue = Double(value) + } + + init(_ value: Value, format: Format, unit: String = "") where Value: BinaryFloatingPoint { + self.value = value + self.format = format + self.unit = unit + self.doubleValue = Double(value) + } +} diff --git a/MyHeartCounts/Resources/Localizable.xcstrings b/MyHeartCounts/Resources/Localizable.xcstrings index bf8fdcd6..aea208d0 100644 --- a/MyHeartCounts/Resources/Localizable.xcstrings +++ b/MyHeartCounts/Resources/Localizable.xcstrings @@ -2599,6 +2599,9 @@ } } } + }, + "Enrolled for" : { + }, "Enrolled since: %@" : { "localizations" : { @@ -3647,6 +3650,9 @@ } } } + }, + "Heartbeats" : { + }, "Height" : { "localizations" : { @@ -3871,6 +3877,9 @@ } } } + }, + "Increment Stats" : { + }, "Indiana" : { "localizations" : { @@ -6165,6 +6174,9 @@ } } } + }, + "Participation Stats" : { + }, "Past 2 Weeks" : { "localizations" : { @@ -7604,6 +7616,9 @@ } } } + }, + "Since %@" : { + }, "Some College" : { "localizations" : { @@ -7962,6 +7977,9 @@ } } } + }, + "Steps Taken" : { + }, "Stop Suggesting This" : { "localizations" : { @@ -8470,6 +8488,12 @@ } } } + }, + "Total Number of Heartbeats While Enrolled (Est.)" : { + + }, + "Total Number of Steps While Enrolled" : { + }, "Transient Ischemic Attack (TIA)" : { "localizations" : { diff --git a/MyHeartCounts/SensorKit/SensorKitDataFetcher.swift b/MyHeartCounts/SensorKit/SensorKitDataFetcher.swift index 93b66b0e..629be5b3 100644 --- a/MyHeartCounts/SensorKit/SensorKitDataFetcher.swift +++ b/MyHeartCounts/SensorKit/SensorKitDataFetcher.swift @@ -125,6 +125,7 @@ final class SensorKitDataFetcher: ServiceModule, EnvironmentAccessible, @uncheck if let processingTask { // if we're already performing this task, we simply wait on that task's result, instead of starting a competing second one. // this is in order to properly support background processing/fetches. + // TODO DO WE NEED A LOCK HERE? _ = await processingTask.result } else { let task = Task { @concurrent in diff --git a/MyHeartCounts/Utils/Debug Stuff/DataProcessingDebugView.swift b/MyHeartCounts/Utils/Debug Stuff/DataProcessingDebugView.swift index 4cfae231..a9151f17 100644 --- a/MyHeartCounts/Utils/Debug Stuff/DataProcessingDebugView.swift +++ b/MyHeartCounts/Utils/Debug Stuff/DataProcessingDebugView.swift @@ -68,5 +68,10 @@ struct DataProcessingDebugView: View { } } } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + MemoryUsageIndicator(style: .toolbarItem) + } + } } } From fd509254d0e58373fea42513502580822e0733bf Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Tue, 19 May 2026 12:56:15 +0200 Subject: [PATCH 02/29] checkpoint --- .../Account/ParticipationStatsView.swift | 1077 ++++++++++++++--- MyHeartCounts/Resources/Localizable.xcstrings | 94 +- 2 files changed, 1001 insertions(+), 170 deletions(-) diff --git a/MyHeartCounts/Account/ParticipationStatsView.swift b/MyHeartCounts/Account/ParticipationStatsView.swift index 30aa033c..40c30695 100644 --- a/MyHeartCounts/Account/ParticipationStatsView.swift +++ b/MyHeartCounts/Account/ParticipationStatsView.swift @@ -8,12 +8,13 @@ // swiftlint:disable all -import Algorithms +import Foundation import HealthKit -import MyHeartCountsShared +import MHCStudyDefinition +import SFSafeSymbols import SpeziFoundation import SpeziHealthKit -import SpeziHealthKitUI +import SpeziScheduler import SpeziStudy import SwiftUI @@ -21,193 +22,957 @@ import SwiftUI struct ParticipationStatsView: View { @Environment(\.calendar) private var cal @Environment(HealthKit.self) private var healthKit - + @Environment(Scheduler.self) private var scheduler + private let enrollment: StudyEnrollment -// @StudyManagerQuery private var enrollments: [StudyEnrollment] - -// @HealthKitStatisticsQuery private var stepCountStats: [HKStatistics] -// @HealthKitQuery private var heartRateMeasurements: Slice> - @HealthKitStatisticsQuery private var heartRateAverages: [HKStatistics] - - @State private var totalNumSteps = 0 - @State private var totalNumHeartBeats: Double = 0 - @State private var totalNumHeartBeats2: Double = 0 - - init(enrollment: StudyEnrollment) { - self.enrollment = enrollment -// self._stepCountStats = .init(.stepCount, aggregatedBy: .sum, over: .year, timeRange: .startingAt(enrollment.enrollmentDate)) -// self._heartRateAverages = .init(.heartRate, aggregatedBy: .avg, over: .day, timeRange: .startingAt(Calendar.current.startOfDay(for: enrollment.enrollmentDate))) - self._heartRateAverages = .init(.heartRate, aggregatedBy: .avg, over: .hour, timeRange: .startingAt(enrollment.enrollmentDate)) - } - + + @State private var enrollmentInfo: EnrollmentInfo? + @State private var engagement: EngagementStats? + @State private var healthTotals: HealthTotals? + @State private var personalBests: PersonalBests? + var body: some View { - // TOOD make it look cool! -// Form { -// Section { -// LabeledContent("Enrolled Since" as String, value: enrollment.enrollmentDate, format: .dateTime) -// if let numDaysEnrolled = cal.dateComponents([.day], from: cal.startOfDay(for: enrollment.enrollmentDate), to: cal.startOfDay(for: .now)).day { -// LabeledContent("Days Enrolled" as String, value: numDaysEnrolled, format: .number) -// } -// } -// Section { -// LabeledContent("#steps" as String, value: totalNumSteps, format: .number) -// LabeledContent("#heartBeats" as String, value: totalNumHeartBeats, format: .number) -// } -// } ScrollView { - content + VStack(spacing: 24) { + heroSection + engagementSection + healthTotalsSection + personalBestsSection + funFactsSection + } + .padding(.horizontal) + .padding(.bottom, 32) } + .background(Color(.systemGroupedBackground)) .navigationTitle("Participation Stats") -// .task(id: stepCountStats) { -// totalNumSteps = stepCountStats.reduce(0) { $0 + Int($1.sumQuantity()?.doubleValue(for: .count()) ?? 0) } -// } -// .task(id: heartRateMeasurements) { + .navigationBarTitleDisplayMode(.inline) .task { - totalNumSteps = try! await healthKit - .statisticsQuery(.stepCount, aggregatedBy: [.sum], over: .year, timeRange: .startingAt(enrollment.enrollmentDate)) - .reduce(0) { $0 + Int($1.sumQuantity()?.doubleValue(for: .count()) ?? 0) } - let allHeartbeats = try! await healthKit - .query(.heartRate, timeRange: .startingAt(enrollment.enrollmentDate)) -// .reduce(into: 0) { result, sample in -// precondition(sample.count == 1) -// precondition(sample.startDate == sample.endDate, "sample") -// } - for window in allHeartbeats.windows(ofCount: 3) { - let left = window[window.startIndex] - let sample = window[window.startIndex + 1] - let right = window[window.startIndex + 2] - let bpm = sample.quantity.doubleValue(for: .count() / .minute()) - let timeIntervalIMin = (((left.endDate.. String { + let percent = totalDays > 0 ? Double(active) / Double(totalDays) : 0 + return "\(percent.formatted(.percent.precision(.fractionLength(0)))) of days" + } + + private func formatBestDate(_ date: Date) -> String { + date.formatted(.dateTime.month(.abbreviated).day()) + } + + private func makeFunFacts() -> [FunFact]? { + var facts: [FunFact] = [] + if let steps = healthTotals?.totalSteps, steps > 0 { + let distanceKm = Double(steps) * 0.000762 // ~0.762m per step + let isMetric = Locale.current.measurementSystem != .us + let distanceFormatted: String = { + let measurement = Measurement(value: distanceKm, unit: .kilometers) + let converted = isMetric ? measurement : measurement.converted(to: .miles) + return converted.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) + }() + if let comparison = stepDistanceComparison(distanceKm: distanceKm) { + facts.append(.init( + symbol: .figureWalk, + color: .green, + text: "Your \(steps.formatted(.number)) steps cover about \(distanceFormatted) \u{2014} that's \(comparison)." + )) + } else { + facts.append(.init( + symbol: .figureWalk, + color: .green, + text: "Your \(steps.formatted(.number)) steps cover roughly \(distanceFormatted)." + )) + } + } + if let beats = healthTotals?.totalHeartbeats, beats > 0 { + let lifetimePercent = Double(beats) / 3_000_000_000 + let percentFormatted = lifetimePercent.formatted(.percent.precision(.fractionLength(0...2))) + facts.append(.init( + symbol: .heartFill, + color: .red, + text: "Your heart has beaten about \(beats.formatted(.number.notation(.compactName))) times since you enrolled \u{2014} roughly \(percentFormatted) of an average lifetime." + )) + } + if let kcal = healthTotals?.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).formatted(.number)) active calories \u{2014} the equivalent of \(pizzaSlices.formatted(.number)) slices of pizza." + )) + } + } + if let sleepSec = healthTotals?.totalSleepSeconds, sleepSec > TimeConstants.day { + let days = sleepSec / TimeConstants.day + facts.append(.init( + symbol: .bedDoubleFill, + color: .indigo, + text: "You've spent about \(days.formatted(.number.precision(.fractionLength(1)))) full days asleep since enrolling. Rest is part of the work." + )) + } + if let active = engagement?.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 \u{2014} that's a fantastic \(percent.formatted(.percent.precision(.fractionLength(0)))). Keep it up!" + )) + } + } + return facts + } + + private func stepDistanceComparison(distanceKm: Double) -> String? { + // Pick the most "fun" reference for the user's actual distance. + if distanceKm >= 20_000 { + let earths = distanceKm / 40_075 + return "about \(earths.formatted(.number.precision(.fractionLength(1)))) trips around the Earth" + } else if distanceKm >= 1_000 { + let coastToCoast = distanceKm / 3_940 // SF to NY + return "roughly \(coastToCoast.formatted(.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.formatted(.number.precision(.fractionLength(1)))) marathons" + } else if distanceKm >= 5 { + let bridges = distanceKm / 2.737 // Golden Gate Bridge length + return "\(Int(bridges.rounded())) lengths of the Golden Gate Bridge" + } else if distanceKm >= 0.5 { + let laps = (distanceKm * 1000) / 400 // 400m track + return "\(Int(laps.rounded())) laps around a running track" + } else { + return nil + } + } +} + + +// MARK: - Loading + +extension ParticipationStatsView { + private func loadAll() async { + async let enrollment: Void = loadEnrollmentInfo() + async let engagement: Void = loadEngagement() + async let totals: Void = loadHealthTotals() + async let bests: Void = loadPersonalBests() + _ = await (enrollment, engagement, totals, bests) + } + + private func loadEnrollmentInfo() async { + let enrollmentDay = cal.startOfDay(for: enrollment.enrollmentDate) + let today = cal.startOfDay(for: .now) + let days = cal.dateComponents([.day], from: enrollmentDay, to: today).day ?? 0 + let (prev, next) = milestones(around: days) + await MainActor.run { + self.enrollmentInfo = .init(daysEnrolled: days, previousMilestone: prev, nextMilestone: next) + } + } + + private func loadEngagement() async { + let range = enrollment.enrollmentDate..() + for event in studyEvents { + if let cat = event.task.category { + perCategory[cat, default: 0] += 1 + } + if let completionDate = event.outcome?.completionDate { + daysWithCompletion.insert(cal.startOfDay(for: completionDate)) + } else { + daysWithCompletion.insert(cal.startOfDay(for: event.occurrence.start)) + } + } + let total = studyEvents.count + let activeDays = daysWithCompletion.count + let (current, longest) = computeStreaks(activeDays: daysWithCompletion, today: cal.startOfDay(for: .now)) + let daysSinceEnrollment = (cal.dateComponents( + [.day], + from: cal.startOfDay(for: enrollment.enrollmentDate), + to: cal.startOfDay(for: .now) + ).day ?? 0) + 1 + let walkRun = (perCategory[.timedWalkingTest] ?? 0) + (perCategory[.timedRunningTest] ?? 0) + let ecgs = perCategory[.customActiveTask(.ecg)] ?? 0 + let stats = EngagementStats( + totalCompleted: total, + questionnairesCompleted: perCategory[.questionnaire] ?? 0, + articlesRead: perCategory[.informational] ?? 0, + ecgsRecorded: ecgs, + walkRunTestsCompleted: walkRun, + activeDays: activeDays, + daysSinceEnrollment: daysSinceEnrollment, + currentStreak: current, + longestStreak: longest + ) + await MainActor.run { + self.engagement = stats + } + } + + private func loadHealthTotals() async { + let startDate = enrollment.enrollmentDate + let dateRange = startDate.., + unit: HKUnit, + timeRange: HealthKitQueryTimeRange + ) async -> Double? { + do { + let stats = try await healthKit.statisticsQuery( + sampleType, + aggregatedBy: [.sum], + over: .year, + timeRange: timeRange + ) + return stats.reduce(0) { $0 + ($1.sumQuantity()?.doubleValue(for: unit) ?? 0) } + } catch { + return nil + } + } + + private func bestDay( + of sampleType: SampleType, + unit: HKUnit, + timeRange: HealthKitQueryTimeRange + ) async -> (value: Double, date: Date)? { + do { + let stats = try await healthKit.statisticsQuery( + sampleType, + aggregatedBy: [.sum], + over: .day, + timeRange: timeRange + ) + return stats + .compactMap { stat -> (Double, Date)? in + guard let value = stat.sumQuantity()?.doubleValue(for: unit), value > 0 else { + return nil + } + return (value, stat.startDate) + } + .max(by: { $0.0 < $1.0 }) + } catch { + return nil + } + } + + private func discreteStat( + _ sampleType: SampleType, + option: HealthKit.DiscreteAggregationOption, + aggregator: (HKStatistics) -> Double?, + timeRange: HealthKitQueryTimeRange, + reducer: ([Double]) -> Double? + ) async -> Double? { + do { + let stats = try await healthKit.statisticsQuery( + sampleType, + aggregatedBy: [option], + over: .year, + timeRange: timeRange + ) + return reducer(stats.compactMap(aggregator)) + } catch { + return nil + } + } + + private func estimateTotalHeartbeats(in range: 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. + do { + let stats = try await healthKit.statisticsQuery( + .heartRate, + aggregatedBy: [.average], + over: .day, + timeRange: .init(range) + ) + 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: range) + let minutes = clamped.timeInterval / 60 + return acc + bpm * minutes + } + } catch { + return 0 + } + } + + private func totalSleepSeconds(in range: Range) async -> Double { + do { + let samples = try await healthKit.query( + .sleepAnalysis, + timeRange: .init(range) + ) + let asleepValues: Set = [ + HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue, + HKCategoryValueSleepAnalysis.asleepCore.rawValue, + HKCategoryValueSleepAnalysis.asleepDeep.rawValue, + HKCategoryValueSleepAnalysis.asleepREM.rawValue + ] + return samples + .filter { asleepValues.contains($0.value) } + .reduce(0) { acc, sample in + acc + sample.endDate.timeIntervalSince(sample.startDate) + } + } catch { + return 0 + } + } + + private func totalWorkouts(in range: Range) async -> (count: Int, totalSeconds: Double) { + do { + let workouts = try await healthKit.query(.workout, timeRange: .init(range)) + let total = workouts.reduce(0.0) { $0 + $1.duration } + return (workouts.count, total) + } catch { + return (0, 0) + } + } + + private func longestWorkout(in range: Range) async -> (duration: Double, date: Date)? { + do { + let workouts = try await healthKit.query(.workout, timeRange: .init(range)) + return workouts + .max(by: { $0.duration < $1.duration }) + .map { ($0.duration, $0.startDate) } + } catch { + return nil } } - - - @ViewBuilder private var enrollmentDurationSection: some View { - if let numDaysEnrolled = cal.dateComponents([.day], from: cal.startOfDay(for: enrollment.enrollmentDate), to: cal.startOfDay(for: .now)).day { - StatCell(title: "Enrolled for", subtitle: "Since \(enrollment.enrollmentDate, format: .dateTime.omittingTime())") { - StatCellContentNumberWithUnit(numDaysEnrolled, format: .number, unit: "days") +} + + +// MARK: - Domain types + +extension ParticipationStatsView { + fileprivate struct EnrollmentInfo { + let daysEnrolled: Int + let previousMilestone: Int + let nextMilestone: Int + + var progressToNext: Double { + guard nextMilestone > previousMilestone else { + return 1 } + let span = Double(nextMilestone - previousMilestone) + let progress = Double(daysEnrolled - previousMilestone) + return max(0, min(1, progress / span)) } } - - @ViewBuilder private var healthStatsSections: some View { - StatCell( - title: "Steps Taken", - subtitle: "Total Number of Steps While Enrolled" - ) { - StatCellContentNumberWithUnit(totalNumSteps, format: .number) + + fileprivate struct EngagementStats { + let totalCompleted: Int + let questionnairesCompleted: Int + let articlesRead: Int + let ecgsRecorded: Int + let walkRunTestsCompleted: Int + let activeDays: Int + let daysSinceEnrollment: Int + let currentStreak: Int + let longestStreak: Int + } + + fileprivate struct HealthTotals { + let totalSteps: Int + let totalActiveEnergyKcal: Double + let totalDistanceMeters: Double + let totalExerciseSeconds: Double + let totalFlightsClimbed: Int + let totalHeartbeats: Int + let totalSleepSeconds: Double + let workoutCount: Int + let totalWorkoutSeconds: Double + } + + fileprivate struct PersonalBests { + let bestDailySteps: Int? + let bestDailyStepsDate: Date? + let longestWorkoutSeconds: Double? + let longestWorkoutDate: Date? + let maxHeartRateBPM: Int? + let avgRestingHeartRateBPM: Int? + } +} + + +// MARK: - Streak / milestone math + +private func computeStreaks(activeDays: Set, today: Date) -> (current: Int, longest: Int) { + guard !activeDays.isEmpty else { + return (0, 0) + } + let cal = Calendar.current + let sorted = activeDays.sorted() + var longest = 0 + var run = 0 + var previous: Date? + for day in sorted { + if let prev = previous, cal.dateComponents([.day], from: prev, to: day).day == 1 { + run += 1 + } else { + run = 1 } - Divider() - StatCell( - title: "Heartbeats", - subtitle: "Total Number of Heartbeats While Enrolled (Est.)" - ) { - StatCellContentNumberWithUnit(totalNumHeartBeats, format: .number.rounded(rule: .down, increment: 1)) + longest = max(longest, run) + previous = day + } + var cursor = today + if !activeDays.contains(cursor) { + guard let yesterday = cal.date(byAdding: .day, value: -1, to: cursor) else { + return (0, longest) } - StatCell( - title: "Heartbeats", - subtitle: "Total Number of Heartbeats While Enrolled (Est.)" - ) { - StatCellContentNumberWithUnit(totalNumHeartBeats2, format: .number.rounded(rule: .down, increment: 1)) + cursor = yesterday + } + var current = 0 + while activeDays.contains(cursor) { + current += 1 + guard let next = cal.date(byAdding: .day, value: -1, to: cursor) else { + break } + cursor = next } + return (current, longest) } -private struct StatCell: View { - private let title: LocalizedStringResource - private let subtitle: LocalizedStringResource - private let content: Content - + +private func milestones(around days: Int) -> (previous: Int, next: Int) { + let fixed = [1, 7, 30, 60, 100, 200, 365] + var all = fixed + var year = 2 + while all.last ?? 0 <= days + 365 { + all.append(365 * year) + year += 1 + } + let prev = all.last(where: { $0 <= days }) ?? 0 + let next = all.first(where: { $0 > days }) ?? (prev + 365) + return (prev, next) +} + + +// MARK: - Subviews + +private struct HeroEnrollmentCard: View { + let enrollmentDate: Date + let info: ParticipationStatsView.EnrollmentInfo? + var body: some View { - VStack { - Text(title) - content - Text(subtitle) + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let info { + Text(info.daysEnrolled, format: .number) + .font(.system(size: 64, weight: .bold, design: .rounded)) + .contentTransition(.numericText(value: Double(info.daysEnrolled))) + .monospacedDigit() + } else { + Text("—") + .font(.system(size: 64, weight: .bold, design: .rounded)) + .foregroundStyle(.secondary) + } + Text("days") + .font(.title2.weight(.medium)) + .foregroundStyle(.secondary) + Spacer() + } + VStack(alignment: .leading, spacing: 4) { + Text("Enrolled since") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Text(enrollmentDate, format: .dateTime.year().month(.wide).day()) + .font(.headline) + } + if let info { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Next milestone") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text("\(info.nextMilestone, format: .number) days") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + ProgressView(value: info.progressToNext) + .progressViewStyle(.linear) + .tint(.pink) + } + } } - } - - init(title: LocalizedStringResource, subtitle: LocalizedStringResource, @ViewBuilder content: () -> Content) { - self.title = title - self.subtitle = subtitle - self.content = content() + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } } -private struct StatCellContentNumberWithUnit>: View { - private let value: Value - private let format: Format - private let unit: String - private let doubleValue: Double - +private struct StatsSection: View { + let title: LocalizedStringResource + let symbol: SFSymbol + @ViewBuilder let content: Content + + private let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + var body: some View { - Text(value, format: format) - .font(.system(size: 32, weight: .bold)) - .overlay(alignment: .trailingLastTextBaseline) { - Text(unit) - .alignmentGuide(.trailing) { $0[.leading] } - .padding(.leading, 4) - } - .contentTransition(.numericText(value: doubleValue)) - .monospacedDigit() + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemSymbol: symbol) + .foregroundStyle(.secondary) + Text(title) + .font(.title3.bold()) + } + .padding(.horizontal, 4) + LazyVGrid(columns: columns, spacing: 12) { + content + } + } } - - init(_ value: Value, format: Format, unit: String = "") where Value: BinaryInteger { - self.value = value +} + + +private struct StatCard: View { + enum Format { + case number + case compactNumber + case dayCount + case distance // expects meters + case energyKcal // expects kcal + case duration // expects seconds + case heartRate // expects BPM + } + + let title: LocalizedStringResource + let value: Double? + let format: Format + let symbol: SFSymbol + let accentColor: Color + let subtitle: String? + + init( + title: LocalizedStringResource, + value: V?, + format: Format, + symbol: SFSymbol, + accentColor: Color, + subtitle: String? = nil + ) { + self.title = title + self.value = value.map { Double($0) } self.format = format - self.unit = unit - self.doubleValue = Double(value) + self.symbol = symbol + self.accentColor = accentColor + self.subtitle = subtitle } - - init(_ value: Value, format: Format, unit: String = "") where Value: BinaryFloatingPoint { + + init( + title: LocalizedStringResource, + value: Double?, + format: Format, + symbol: SFSymbol, + accentColor: Color, + subtitle: String? = nil + ) { + self.title = title self.value = value self.format = format - self.unit = unit - self.doubleValue = Double(value) + self.symbol = symbol + self.accentColor = accentColor + self.subtitle = subtitle + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemSymbol: symbol) + .font(.callout) + .foregroundStyle(accentColor) + 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) + .background(.background, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + @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 .dayCount: + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(Int(value), format: .number) + Text("d").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: + Text(formatDuration(seconds: value)) + case .heartRate: + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(Int(value), format: .number) + Text("bpm").font(.title3.weight(.medium)).foregroundStyle(.secondary) + } + } + } + + private func formatDuration(seconds: Double) -> String { + let totalMinutes = Int(seconds / 60) + if totalMinutes >= 60 * 24 { + let days = totalMinutes / (60 * 24) + let hours = (totalMinutes / 60) % 24 + return hours == 0 ? "\(days)d" : "\(days)d \(hours)h" + } else if totalMinutes >= 60 { + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m" + } else { + return "\(totalMinutes)m" + } + } +} + + +private struct FunFact: Identifiable { + let id = UUID() + let symbol: SFSymbol + let color: Color + let text: String +} + + +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) +// .background(fact.color.opacity(0.12), in: Circle()) + Text(fact.text) + .font(.subheadline) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } } diff --git a/MyHeartCounts/Resources/Localizable.xcstrings b/MyHeartCounts/Resources/Localizable.xcstrings index aea208d0..46481a68 100644 --- a/MyHeartCounts/Resources/Localizable.xcstrings +++ b/MyHeartCounts/Resources/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { "shouldTranslate" : false + }, + " " : { + }, " 0" : { "shouldTranslate" : false @@ -180,6 +183,9 @@ }, "%@ +" : { "shouldTranslate" : false + }, + "%@ days" : { + }, "%@-Minute Run Test" : { "localizations" : { @@ -910,6 +916,12 @@ } } } + }, + "Active Days" : { + + }, + "Active Energy" : { + }, "Actively smoking" : { "localizations" : { @@ -1269,6 +1281,9 @@ } } } + }, + "Articles Read" : { + }, "Atrial fibrillation (Afib)" : { "localizations" : { @@ -1301,6 +1316,9 @@ } } } + }, + "Best Step Day" : { + }, "Biological Sex at Birth" : { "localizations" : { @@ -1387,6 +1405,9 @@ } } } + }, + "bpm" : { + }, "California" : { "localizations" : { @@ -1907,6 +1928,9 @@ } } } + }, + "Current Streak" : { + }, "Cycling" : { "localizations" : { @@ -1923,6 +1947,9 @@ } } } + }, + "d" : { + }, "Daily Average" : { "localizations" : { @@ -2019,6 +2046,9 @@ } } } + }, + "days" : { + }, "Debug" : { "localizations" : { @@ -2357,6 +2387,9 @@ } } } + }, + "ECGs Recorded" : { + }, "Education Level" : { "localizations" : { @@ -2567,6 +2600,9 @@ } } } + }, + "Engagement" : { + }, "England (%@)" : { "localizations" : { @@ -2600,7 +2636,7 @@ } } }, - "Enrolled for" : { + "Enrolled since" : { }, "Enrolled since: %@" : { @@ -2698,6 +2734,9 @@ } } } + }, + "Exercise Time" : { + }, "EXERCISE_MINUTES_SCORE_EXPLAINER" : { "localizations" : { @@ -3054,6 +3093,9 @@ } } } + }, + "Flights Climbed" : { + }, "Florida" : { "localizations" : { @@ -3086,6 +3128,9 @@ } } } + }, + "Fun Facts" : { + }, "Gender Identity" : { "localizations" : { @@ -3362,6 +3407,9 @@ } } } + }, + "Health Totals" : { + }, "HEALTH_RECORDS_NUDGE_SUBTITLE" : { "localizations" : { @@ -3877,9 +3925,6 @@ } } } - }, - "Increment Stats" : { - }, "Indiana" : { "localizations" : { @@ -4484,6 +4529,12 @@ } } } + }, + "Longest Streak" : { + + }, + "Longest Workout" : { + }, "Louisiana" : { "localizations" : { @@ -4628,6 +4679,9 @@ } } } + }, + "Max Heart Rate" : { + }, "Mental Well Being" : { "localizations" : { @@ -5294,6 +5348,9 @@ } } } + }, + "Next milestone" : { + }, "NHS Number" : { "localizations" : { @@ -6311,6 +6368,9 @@ } } } + }, + "Personal Bests" : { + }, "Ph.D., M.D., J.D., etc." : { "localizations" : { @@ -7144,6 +7204,9 @@ } } } + }, + "Resting HR" : { + }, "Review Consent Forms" : { "localizations" : { @@ -7617,7 +7680,7 @@ } } }, - "Since %@" : { + "Sleep" : { }, "Some College" : { @@ -7977,9 +8040,6 @@ } } } - }, - "Steps Taken" : { - }, "Stop Suggesting This" : { "localizations" : { @@ -8076,6 +8136,9 @@ } } } + }, + "Surveys Answered" : { + }, "Swimming" : { "localizations" : { @@ -8188,6 +8251,9 @@ } } } + }, + "Tasks Done" : { + }, "Tennessee" : { "localizations" : { @@ -8488,12 +8554,6 @@ } } } - }, - "Total Number of Heartbeats While Enrolled (Est.)" : { - - }, - "Total Number of Steps While Enrolled" : { - }, "Transient Ischemic Attack (TIA)" : { "localizations" : { @@ -8824,6 +8884,9 @@ } } } + }, + "Walk / Run Tests" : { + }, "Walking" : { "localizations" : { @@ -9142,6 +9205,9 @@ } } } + }, + "Workouts" : { + }, "Wyoming" : { "localizations" : { From 4ab143feefaee38c72f76fa7c3317cdb84771c59 Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Sun, 24 May 2026 12:28:38 +0200 Subject: [PATCH 03/29] x --- .../xcshareddata/xcschemes/MyHeartCounts.xcscheme | 2 +- MyHeartCounts/SensorKit/SensorKitDataFetcher.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme b/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme index ebb891e8..21466c5d 100644 --- a/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme +++ b/MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme @@ -89,7 +89,7 @@ + isEnabled = "NO"> Date: Sat, 30 May 2026 21:15:15 +0200 Subject: [PATCH 04/29] checkpoint --- .../Account/ParticipationStatsProvider.swift | 144 ++++ .../Account/ParticipationStatsView.swift | 681 +++++++++++------- .../Achievements/AchievementDefinitions.swift | 250 +++++++ .../Achievements/AchievementIcon.swift | 38 + .../Achievements/AchievementsManager.swift | 632 ++++++++++++++++ .../Achievements/AchievementsView.swift | 89 +++ .../Achievements/Achievement.swift | 5 +- .../CircularProgressView.swift | 64 +- .../Health Dashboard/DashboardChrome.swift | 87 +++ .../DefaultHealthDashboardTile.swift | 4 +- .../Health Dashboard/HealthDashboard.swift | 2 +- .../HealthDashboardTile.swift | 2 +- .../HeartHealthDashboard.swift | 9 +- .../HeartHealthDashboardTab.swift | 6 + .../Heart Health Dashboard/Score.swift | 2 +- MyHeartCounts/Home Tab/HomeTab.swift | 3 + MyHeartCounts/MyHeartCountsDelegate.swift | 1 + MyHeartCounts/Resources/Localizable.xcstrings | 28 +- MyHeartCounts/Utils/SwiftUI/Badge.swift | 40 + 19 files changed, 1810 insertions(+), 277 deletions(-) create mode 100644 MyHeartCounts/Account/ParticipationStatsProvider.swift create mode 100644 MyHeartCounts/Achievements/AchievementDefinitions.swift create mode 100644 MyHeartCounts/Achievements/AchievementIcon.swift create mode 100644 MyHeartCounts/Achievements/AchievementsManager.swift create mode 100644 MyHeartCounts/Achievements/AchievementsView.swift create mode 100644 MyHeartCounts/Heart Health Dashboard/Health Dashboard/DashboardChrome.swift create mode 100644 MyHeartCounts/Utils/SwiftUI/Badge.swift diff --git a/MyHeartCounts/Account/ParticipationStatsProvider.swift b/MyHeartCounts/Account/ParticipationStatsProvider.swift new file mode 100644 index 00000000..b03da888 --- /dev/null +++ b/MyHeartCounts/Account/ParticipationStatsProvider.swift @@ -0,0 +1,144 @@ +// +// 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 all + +import Foundation +import HealthKit +import MHCStudyDefinition +import SFSafeSymbols +import SpeziFoundation +import SpeziHealthKit +import SpeziScheduler +import SpeziStudy +import SpeziViews +import SwiftUI +import OSLog +import Spezi + + +@Observable +@MainActor // ??? +final class ParticipationStatsProvider: Module { + @ObservationIgnored @Dependency(HealthKit.self) private var healthKit + @ObservationIgnored @Dependency(Scheduler.self) private var scheduler + + private let enrollment: StudyEnrollment + private var timeRange: Range + + init(enrollment: StudyEnrollment) { + self.enrollment = enrollment + self.timeRange = Self.enrollmentTimeRange(for: enrollment, upTo: .now) + } + + static func enrollmentTimeRange(for enrollment: StudyEnrollment, upTo now: Date) -> Range { + let startDate = enrollment.enrollmentDate + return if startDate < now { + startDate.. + } + + static let empty = Self( + totalSteps: nil, + totalActiveEnergyKcal: nil, + totalDistanceWalkingRunning: nil, + totalExerciseTime: nil, + totalFlightsClimbed: nil, + totalHeartbeats: nil, + totalSleepTime: nil, + workoutInfo: nil + ) + + let totalSteps: Int? + let totalActiveEnergyKcal: Double? + let totalDistanceWalkingRunning: Measurement? + let totalExerciseTime: Measurement? + let totalFlightsClimbed: Int? + let totalHeartbeats: Int? + let totalSleepTime: Measurement? + let workoutInfo: WorkoutInfo? + } +} + + +extension ParticipationStatsProvider { + func computeStats(for enrollment: StudyEnrollment) async throws -> Stats { + let cal = Calendar.current +// async let healthStats = (try? computeHealthStats(for: enrollment)) ?? .empty +// async let taskEngagement = (try? computeTaskEngagementStats(for: enrollment)) ?? .empty + return Stats( + enrollmentDate: enrollment.enrollmentDate, + numDaysEnrolled: cal.countDistinctDays(from: enrollment.enrollmentDate, to: .now), + numWeeksEnrolled: cal.countDistinctWeeks(from: enrollment.enrollmentDate, to: .now), +// taskEngagement: await taskEngagement, +// health: await healthStats + taskEngagement: .empty, + health: .empty + ) + } + + private func computeTaskEngagementStats(for enrollment: StudyEnrollment) async throws -> TaskEngagementStats { + // TODO + throw NSError(mhcErrorCode: .unspecified, localizedDescription: "") + } + + private func computeHealthStats(for enrollment: StudyEnrollment) async throws -> HealthStats { + // TODO + throw NSError(mhcErrorCode: .unspecified, localizedDescription: "") + } +} + + +extension ParticipationStatsProvider { + func refresh() { + // updating the time range here will trigger the task above, which will update the stats + timeRange = Self.enrollmentTimeRange(for: enrollment, upTo: .now) + } +} diff --git a/MyHeartCounts/Account/ParticipationStatsView.swift b/MyHeartCounts/Account/ParticipationStatsView.swift index 40c30695..e10218af 100644 --- a/MyHeartCounts/Account/ParticipationStatsView.swift +++ b/MyHeartCounts/Account/ParticipationStatsView.swift @@ -16,46 +16,89 @@ import SpeziFoundation import SpeziHealthKit import SpeziScheduler import SpeziStudy +import SpeziViews import SwiftUI +import OSLog +private let statsLogger = Logger(subsystem: "edu.stanford.MHC", category: "Stats") + +/// 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(HealthKit.self) private var healthKit @Environment(Scheduler.self) private var scheduler - + @Environment(AchievementsManager.self) private var achievementsManager + private let enrollment: StudyEnrollment - +// @Environment(ParticipationStatsProvider.self) private var stats + @State private var enrollmentTimeRange: Range +// +// + private var enrollmentHealthQueryTimeRange: HealthKitQueryTimeRange { + .init(cal.startOfDay(for: enrollmentTimeRange.lowerBound).. TimeConstants.day { + if let sleepSec = healthTotals?.totalSleepTime?.value(in: .seconds), sleepSec > TimeConstants.day { let days = sleepSec / TimeConstants.day facts.append(.init( symbol: .bedDoubleFill, @@ -348,7 +386,71 @@ extension ParticipationStatsView { } -// MARK: - Loading +// MARK: Stats + +extension ParticipationStatsView { + fileprivate struct EnrollmentInfo { + let daysEnrolled: Int + let previousMilestone: Int + let nextMilestone: Int + + var progressToNext: Double { + guard nextMilestone > previousMilestone else { + return 1 + } + let span = Double(nextMilestone - previousMilestone) + let progress = Double(daysEnrolled - previousMilestone) + return max(0, min(1, progress / span)) + } + } + + + private struct EngagementStats { + let totalCompleted: Int + let questionnairesCompleted: Int + let articlesRead: Int + let ecgsRecorded: Int + let walkRunTestsCompleted: Int + let activeDays: Int + let daysSinceEnrollment: Int + let currentStreak: Int + let longestStreak: Int + } + + + private struct HealthTotals { + struct WorkoutInfo { + let numWorkouts: Int + let totalDuration: Measurement + } + let totalSteps: Int? + let totalActiveEnergyKcal: Double? + let totalDistanceWalkingRunning: Measurement? + let totalExerciseTime: Measurement? + let totalFlightsClimbed: Int? + let totalHeartbeats: Int? + let totalSleepTime: Measurement? + let workoutInfo: WorkoutInfo? + } + + + private struct PersonalBests: Sendable { + struct Entry: Sendable { + let date: Date + let value: Value + + func map(_ transform: (Value) -> NewValue) -> Entry { + .init(date: date, value: transform(value)) + } + } + + let bestDailySteps: Entry? + let longestWorkoutDuration: Entry>? + let maxHeartRateBPM: Int? + let avgRestingHeartRateBPM: Int? + } +} + extension ParticipationStatsView { private func loadAll() async { @@ -370,39 +472,40 @@ extension ParticipationStatsView { } private func loadEngagement() async { - let range = enrollment.enrollmentDate..() + var weeksWithCompletion = Set() for event in studyEvents { if let cat = event.task.category { perCategory[cat, default: 0] += 1 } - if let completionDate = event.outcome?.completionDate { - daysWithCompletion.insert(cal.startOfDay(for: completionDate)) - } else { - daysWithCompletion.insert(cal.startOfDay(for: event.occurrence.start)) + let date = event.outcome?.completionDate ?? event.occurrence.start + daysWithCompletion.insert(cal.startOfDay(for: date)) + if let weekStart = cal.dateInterval(of: .weekOfYear, for: date)?.start { + weeksWithCompletion.insert(weekStart) } } - let total = studyEvents.count let activeDays = daysWithCompletion.count - let (current, longest) = computeStreaks(activeDays: daysWithCompletion, today: cal.startOfDay(for: .now)) + let thisWeekStart = cal.dateInterval(of: .weekOfYear, for: .now)?.start ?? cal.startOfDay(for: .now) + let (current, longest) = computeWeekStreaks(activeWeeks: weeksWithCompletion, thisWeek: thisWeekStart, calendar: cal) let daysSinceEnrollment = (cal.dateComponents( [.day], from: cal.startOfDay(for: enrollment.enrollmentDate), to: cal.startOfDay(for: .now) ).day ?? 0) + 1 let walkRun = (perCategory[.timedWalkingTest] ?? 0) + (perCategory[.timedRunningTest] ?? 0) - let ecgs = perCategory[.customActiveTask(.ecg)] ?? 0 let stats = EngagementStats( - totalCompleted: total, + totalCompleted: studyEvents.count, questionnairesCompleted: perCategory[.questionnaire] ?? 0, articlesRead: perCategory[.informational] ?? 0, - ecgsRecorded: ecgs, + ecgsRecorded: (await ecgCount) ?? 0, walkRunTestsCompleted: walkRun, activeDays: activeDays, daysSinceEnrollment: daysSinceEnrollment, @@ -414,65 +517,58 @@ extension ParticipationStatsView { } } - private func loadHealthTotals() async { - let startDate = enrollment.enrollmentDate - let dateRange = startDate..) async -> Int? { + do { + return try await healthKit.query(.electrocardiogram, timeRange: .init(range)).count + } catch { + return nil + } + } + private func loadHealthTotals() async { + async let steps = sumCumulative(.stepCount, unit: .count()).map { Int($0) } + async let energy = sumCumulative(.activeEnergyBurned, unit: .kilocalorie()) + async let distance = sumCumulative(.distanceWalkingRunning, unit: .meter()) + async let exerciseMin = sumCumulative(.appleExerciseTime, unit: .minute()) + async let flights = sumCumulative(.flightsClimbed, unit: .count()).map { Int($0) } + async let heartbeats = estimateTotalHeartbeats() + async let sleepSec = totalSleepSeconds() + async let workoutStats = loadWorkoutStats(in: enrollmentTimeRange) let stats = HealthTotals( totalSteps: await steps, totalActiveEnergyKcal: await energy, - totalDistanceMeters: await distance, - totalExerciseSeconds: await exerciseMin * 60, + 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), - totalSleepSeconds: await sleepSec, - workoutCount: (await workoutInfo).0, - totalWorkoutSeconds: (await workoutInfo).1 + totalSleepTime: (await sleepSec).map { .init(value: $0, unit: .seconds) }, + workoutInfo: await workoutStats ) + statsLogger.notice("#workouts: \(stats.workoutInfo?.numWorkouts ?? 0)") await MainActor.run { self.healthTotals = stats } } - + private func loadPersonalBests() async { - let startDate = enrollment.enrollmentDate - let dateRange = startDate.., - unit: HKUnit, - timeRange: HealthKitQueryTimeRange + unit: HKUnit ) async -> Double? { do { let stats = try await healthKit.statisticsQuery( sampleType, aggregatedBy: [.sum], over: .year, - timeRange: timeRange + timeRange: enrollmentHealthQueryTimeRange ) return stats.reduce(0) { $0 + ($1.sumQuantity()?.doubleValue(for: unit) ?? 0) } } catch { @@ -506,24 +603,23 @@ extension ParticipationStatsView { private func bestDay( of sampleType: SampleType, - unit: HKUnit, - timeRange: HealthKitQueryTimeRange - ) async -> (value: Double, date: Date)? { + unit: HKUnit + ) async -> PersonalBests.Entry? { do { let stats = try await healthKit.statisticsQuery( sampleType, aggregatedBy: [.sum], over: .day, - timeRange: timeRange + timeRange: enrollmentHealthQueryTimeRange ) return stats - .compactMap { stat -> (Double, Date)? in - guard let value = stat.sumQuantity()?.doubleValue(for: unit), value > 0 else { + .compactMap { stat -> PersonalBests.Entry? in + guard let value = stat.sumQuantity()?.doubleValue(for: unit), value > 0 else { // TODO why the value > 0 check? return nil } - return (value, stat.startDate) + return .init(date: stat.startDate, value: value) } - .max(by: { $0.0 < $1.0 }) + .max { $0.value < $1.value } } catch { return nil } @@ -533,7 +629,6 @@ extension ParticipationStatsView { _ sampleType: SampleType, option: HealthKit.DiscreteAggregationOption, aggregator: (HKStatistics) -> Double?, - timeRange: HealthKitQueryTimeRange, reducer: ([Double]) -> Double? ) async -> Double? { do { @@ -541,7 +636,7 @@ extension ParticipationStatsView { sampleType, aggregatedBy: [option], over: .year, - timeRange: timeRange + timeRange: enrollmentHealthQueryTimeRange ) return reducer(stats.compactMap(aggregator)) } catch { @@ -549,22 +644,23 @@ extension ParticipationStatsView { } } - private func estimateTotalHeartbeats(in range: Range) async -> Double { + private func estimateTotalHeartbeats() 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. + let range = enrollmentHealthQueryTimeRange do { let stats = try await healthKit.statisticsQuery( .heartRate, aggregatedBy: [.average], over: .day, - timeRange: .init(range) + timeRange: range ) 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: range) + let clamped = stat.timeRange.clamped(to: range.range) let minutes = clamped.timeInterval / 60 return acc + bpm * minutes } @@ -573,11 +669,11 @@ extension ParticipationStatsView { } } - private func totalSleepSeconds(in range: Range) async -> Double { + private func totalSleepSeconds() async -> Double? { do { let samples = try await healthKit.query( .sleepAnalysis, - timeRange: .init(range) + timeRange: enrollmentHealthQueryTimeRange ) let asleepValues: Set = [ HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue, @@ -591,26 +687,28 @@ extension ParticipationStatsView { acc + sample.endDate.timeIntervalSince(sample.startDate) } } catch { - return 0 + return nil } } - private func totalWorkouts(in range: Range) async -> (count: Int, totalSeconds: Double) { + private func loadWorkoutStats(in range: Range) async -> HealthTotals.WorkoutInfo? { do { let workouts = try await healthKit.query(.workout, timeRange: .init(range)) - let total = workouts.reduce(0.0) { $0 + $1.duration } - return (workouts.count, total) + return .init( + numWorkouts: workouts.count, + totalDuration: .init(value: workouts.reduce(0.0) { $0 + $1.duration }, unit: .seconds) + ) } catch { - return (0, 0) + return nil } } - private func longestWorkout(in range: Range) async -> (duration: Double, date: Date)? { + private func longestWorkout(in range: Range) async -> PersonalBests.Entry>? { do { let workouts = try await healthKit.query(.workout, timeRange: .init(range)) return workouts - .max(by: { $0.duration < $1.duration }) - .map { ($0.duration, $0.startDate) } + .max { $0.duration < $1.duration } + .map { .init(date: $0.startDate, value: .init(value: $0.duration, unit: .seconds)) } } catch { return nil } @@ -618,90 +716,49 @@ extension ParticipationStatsView { } -// MARK: - Domain types - -extension ParticipationStatsView { - fileprivate struct EnrollmentInfo { - let daysEnrolled: Int - let previousMilestone: Int - let nextMilestone: Int - - var progressToNext: Double { - guard nextMilestone > previousMilestone else { - return 1 - } - let span = Double(nextMilestone - previousMilestone) - let progress = Double(daysEnrolled - previousMilestone) - return max(0, min(1, progress / span)) - } - } - - fileprivate struct EngagementStats { - let totalCompleted: Int - let questionnairesCompleted: Int - let articlesRead: Int - let ecgsRecorded: Int - let walkRunTestsCompleted: Int - let activeDays: Int - let daysSinceEnrollment: Int - let currentStreak: Int - let longestStreak: Int - } - - fileprivate struct HealthTotals { - let totalSteps: Int - let totalActiveEnergyKcal: Double - let totalDistanceMeters: Double - let totalExerciseSeconds: Double - let totalFlightsClimbed: Int - let totalHeartbeats: Int - let totalSleepSeconds: Double - let workoutCount: Int - let totalWorkoutSeconds: Double - } - - fileprivate struct PersonalBests { - let bestDailySteps: Int? - let bestDailyStepsDate: Date? - let longestWorkoutSeconds: Double? - let longestWorkoutDate: Date? - let maxHeartRateBPM: Int? - let avgRestingHeartRateBPM: Int? - } -} - - // MARK: - Streak / milestone math -private func computeStreaks(activeDays: Set, today: Date) -> (current: Int, longest: Int) { - guard !activeDays.isEmpty else { +/// Computes streak metrics over a set of week-start dates. +/// +/// A "week" here is one entry in `activeWeeks`, normalized to the start of the week (as produced by +/// `Calendar.dateInterval(of: .weekOfYear, for:)`). Two weeks are considered adjacent if they're +/// exactly one `.weekOfYear` apart, so the math respects the user's locale/firstWeekday. +/// +/// The current streak walks back from `thisWeek`. If the current week has no completions yet, +/// we tolerate that and start the streak at the previous week — losing your streak the moment a new +/// week begins (and before you've had a chance to complete anything in it) would be punishing, +/// especially given that study tasks have at most a biweekly cadence. +private func computeWeekStreaks( + activeWeeks: Set, + thisWeek: Date, + calendar cal: Calendar +) -> (current: Int, longest: Int) { + guard !activeWeeks.isEmpty else { return (0, 0) } - let cal = Calendar.current - let sorted = activeDays.sorted() var longest = 0 var run = 0 var previous: Date? - for day in sorted { - if let prev = previous, cal.dateComponents([.day], from: prev, to: day).day == 1 { + 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 = day + previous = week } - var cursor = today - if !activeDays.contains(cursor) { - guard let yesterday = cal.date(byAdding: .day, value: -1, to: cursor) else { + var cursor = thisWeek + if !activeWeeks.contains(cursor) { + guard let previousWeek = cal.date(byAdding: .weekOfYear, value: -1, to: cursor) else { return (0, longest) } - cursor = yesterday + cursor = previousWeek } var current = 0 - while activeDays.contains(cursor) { + while activeWeeks.contains(cursor) { current += 1 - guard let next = cal.date(byAdding: .day, value: -1, to: cursor) else { + guard let next = cal.date(byAdding: .weekOfYear, value: -1, to: cursor) else { break } cursor = next @@ -724,84 +781,145 @@ private func milestones(around days: Int) -> (previous: Int, next: Int) { } + + // MARK: - Subviews -private struct HeroEnrollmentCard: View { + +private struct EnrollmentStatsSection: View { + @Environment(AchievementsManager.self) private var achievements + let enrollmentDate: Date - let info: ParticipationStatsView.EnrollmentInfo? 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: Achievement? { + 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) { - if let info { - Text(info.daysEnrolled, format: .number) - .font(.system(size: 64, weight: .bold, design: .rounded)) - .contentTransition(.numericText(value: Double(info.daysEnrolled))) - .monospacedDigit() - } else { - Text("—") - .font(.system(size: 64, weight: .bold, design: .rounded)) - .foregroundStyle(.secondary) - } + Text(numDaysEnrolled, format: .number) + .font(.system(size: 64, weight: .bold, design: .rounded)) + .monospacedDigit() Text("days") .font(.title2.weight(.medium)) .foregroundStyle(.secondary) Spacer() } - VStack(alignment: .leading, spacing: 4) { - Text("Enrolled since") + 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 { + 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] } ?? []) + .prefix(3) + if !upcoming.isEmpty { + VStack(alignment: .leading) { + Text("Upcoming Achievements") .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) - Text(enrollmentDate, format: .dateTime.year().month(.wide).day()) - .font(.headline) + ForEach(upcoming, id: \.achievement) { upcoming in + let achievement = upcoming.achievement + achievementInfoCapsule(for: achievement) + } } - if let info { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Next milestone") - .font(.caption) - .foregroundStyle(.secondary) - .textCase(.uppercase) - Spacer() - Text("\(info.nextMilestone, format: .number) days") - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - } - ProgressView(value: info.progressToNext) - .progressViewStyle(.linear) - .tint(.pink) + } + } + + @ViewBuilder + private var nextAchievementRow: some View { + if let achievement = achievements.nextLockedAchievement(in: .studyParticipation, subcategory: .enrollmentDuration) { + let state = achievements.state(of: achievement) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Next milestone") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text(achievement.title) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) } + ProgressView(value: state.progress) + .progressViewStyle(.linear) } } - .padding(20) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.background, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } -} - - -private struct StatsSection: View { - let title: LocalizedStringResource - let symbol: SFSymbol - @ViewBuilder let content: Content - - private let columns: [GridItem] = [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ] - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemSymbol: symbol) + + 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) - Text(title) - .font(.title3.bold()) - } - .padding(.horizontal, 4) - LazyVGrid(columns: columns, spacing: 12) { - content } } } @@ -813,18 +931,23 @@ private struct StatCard: View { case number case compactNumber case dayCount - case distance // expects meters - case energyKcal // expects kcal - case duration // expects seconds - case heartRate // expects BPM + case weekCount + /// in meters + case distance + /// in kcal + case energyKcal + /// in seconds + case duration + /// in BPM + case heartRate } let title: LocalizedStringResource + let subtitle: String? let value: Double? let format: Format let symbol: SFSymbol let accentColor: Color - let subtitle: String? init( title: LocalizedStringResource, @@ -835,12 +958,23 @@ private struct StatCard: View { subtitle: String? = nil ) { self.title = title + self.subtitle = subtitle self.value = value.map { Double($0) } self.format = format self.symbol = symbol self.accentColor = accentColor - self.subtitle = subtitle } + +// init( +// title: LocalizedStringResource, +// value: Measurement, +// format: Format, +// symbol: SFSymbol, +// accentColor: Color, +// subtitle: String? = nil +// ) { +// +// } init( title: LocalizedStringResource, @@ -896,7 +1030,7 @@ private struct StatCard: View { } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .background(.background, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .dashboardTileBackground(cornerRadius: 14) } @ViewBuilder @@ -911,6 +1045,11 @@ private struct StatCard: View { Text(Int(value), format: .number) Text("d").font(.title3.weight(.medium)).foregroundStyle(.secondary) } + 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( @@ -973,6 +1112,46 @@ private struct FunFactCard: View { } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .background(.background, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .dashboardTileBackground(cornerRadius: 0 /*14*/) + } +} + + + +extension Measurement { + func value(in unit: UnitType) -> Double where UnitType: Dimension { + self.converted(to: unit).value + } +} + + +// MARK: TMP + +struct ParticipationStatsButton: View { + @Environment(StudyManager.self) + private var studyManager: StudyManager? + + @State private var showStats = false + + var body: some View { + Button { + showStats = true + } label: { + Label(symbol: .medalStar) { + Text("Stats and Achievements") + } + } + .sheet(isPresented: $showStats) { + if let enrollment = studyManager?.studyEnrollments.first { + NavigationStack { + ParticipationStatsView(enrollment: enrollment) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + DismissButton() + } + } + } + } + } } } diff --git a/MyHeartCounts/Achievements/AchievementDefinitions.swift b/MyHeartCounts/Achievements/AchievementDefinitions.swift new file mode 100644 index 00000000..bc11c9a2 --- /dev/null +++ b/MyHeartCounts/Achievements/AchievementDefinitions.swift @@ -0,0 +1,250 @@ +// +// 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 all + +// TODO ideally we'd also have some of these achievements, when displayed in the UI, be buttons that directly take the user to where they can perform the action that would give them the acheivement, or maybe even directly initiates the thing?! + +import Foundation +import SpeziFoundation +import SFSafeSymbols + + +extension Achievement.Category { + static let all: [Self] = [ + .studyParticipation, .appUsage, .health + ] + + static let appUsage = Self(id: "app-usage", title: "General") + static let studyParticipation = Self(id: "study-participation", title: "Study Participation") + static let health = Self(id: "health", title: "Health") +} + + +extension Achievement.Subcategory { + static let enrollmentDuration = Self(id: "enrollment-duration", formsLadder: true) + static let stepCount = Self(id: "step-count", formsLadder: true) +} + + +extension Achievement.Trigger { + static let completeEnrollment = Self( + id: "complete-enrollment", + recordingMode: .recordOnce + ) + + // TODO or have these modeled as metrics as well, so that we can easily do first and N...? + static let completeQuestionnaire = Self( + id: "complete-questionnaire", + recordingMode: .keepAll + ) + static let complete6MinWalkTest = Self( + id: "complete-6mwt", + recordingMode: .keepAll + ) + static let complete12MinRunTest = Self( + id: "complete-12mrt", + recordingMode: .keepAll + ) +} + + +extension Achievement.Metric { + static let enrollmentDurationInDays = Self( + id: "enrollment-duration-days", + rule: .atLeast(base: 0) + ) + static let enrollmentDurationInWeeks = Self( + id: "enrollment-duration-weeks", + rule: .atLeast(base: 0) + ) + static let enrollmentDurationInMonths = Self( + id: "enrollment-duration-months", + rule: .atLeast(base: 0) + ) + static let enrollmentDurationInYears = Self( + id: "enrollment-duration-years", + rule: .atLeast(base: 0) + ) + + static let dailyStepCount = Self( + id: "step-count-daily", + rule: .atLeast(base: 0) + ) + +// static let walkRunDistanceDaily = Self( +// id: "distance-walking-running-daily-meters", +// rule: .atLeast(base: 0) +// ) +} + + +extension Achievement { + // TODO switch to fancy new Date.ComponentsFormatStyle?!!!(!) + private static let durationFmt: DateComponentsFormatter = { + let fmt = DateComponentsFormatter() + fmt.unitsStyle = .full + fmt.allowedUnits = [.weekOfMonth, .month, .year] + return fmt + }() + + private struct EnrollmentDurationAchievementInput { + let metric: Metric + let component: Calendar.Component + let count: Int + let symbol: SFSymbol + + init(_ metric: Metric, _ component: Calendar.Component, _ count: Int, _ symbol: SFSymbol) { + self.metric = metric + self.component = component + self.count = count + self.symbol = symbol + } + } + + static func registerDefaultAchievements(with manager: AchievementsManager) { + manager.register(achievements: Array { + Self( + id: "first-questionnaire", + category: .appUsage, + subcategory: nil, + kind: .eventOnce(trigger: .completeQuestionnaire), + title: "Questionnaire Extraordinaire", + description: "Complete your first questionnaire", + symbol: .textPage, + visibility: .always + ) + Self( + id: "first-6mwt", + category: .appUsage, + subcategory: nil, + kind: .eventOnce(trigger: .completeQuestionnaire), + title: "Pedestrian Pioneer", + description: "Record your first Walk Test", + symbol: .figureWalk, + visibility: .always + ) + Self( + id: "first-12mrt", + category: .appUsage, + subcategory: nil, + kind: .eventOnce(trigger: .completeQuestionnaire), + title: "Cooper Trooper", + description: "Record your first Run Test", + symbol: .figureRun, + visibility: .always + ) + Self( + id: "first-ecg", + category: .appUsage, + subcategory: nil, + kind: .eventOnce(trigger: .completeQuestionnaire), + title: "Cardio Connoisseur", + description: "Record your first ECG", + symbol: .waveformPathEcgRectangle, + visibility: .always + ) + + for (count, title) in [ + (10, "I'm walking here!"), + (15, "Super Streaker"), + (20, "Mega Streaker"), + (30, "Giga Streaker"), + (40, "Uber Streaker"), + (50, "The Humble Walker"), + ] { + Self( + id: "step-count-daily-\(count)k", + category: .health, + subcategory: .stepCount, + kind: .threshold(metric: .dailyStepCount, target: Double(count * 1000)), + title: title, + description: "Walk \((count * 1000).formatted(.number)) steps in a day", + symbol: .figureWalk, + visibility: .secretUnlessNext + ) + } + + Achievement( + id: "initial-enrollment", + category: .studyParticipation, + subcategory: nil, // standalone one-off, not a level of the .enrollmentDuration progression + kind: .eventOnce(trigger: .completeEnrollment), + title: "Welcome to the fold", + description: "Enroll into the study", + symbol: .partyPopper, + visibility: .always + ) + + // participation streaks + for (idx, input) in enrollmentDurationAchievementInputs.enumerated() { + let durationText = Self.durationFmt.string(from: DateComponents(component: input.component, value: input.count)) ?? "TODOTODOTODO" + Achievement( + id: "participation-streak-\(input.count)-\(input.component)", + category: .studyParticipation, + subcategory: .enrollmentDuration, + kind: .threshold(metric: input.metric, target: Double(input.count)), + title: Self.anniversaryNames[idx], + description: "Cross \(durationText) of study enrollment", + symbol: input.symbol, + visibility: .secretUnlessNext + ) + } + }) + } + + + /// source: https://en.wikipedia.org/wiki/Wedding_anniversary#Traditional_anniversary_gifts + private static let anniversaryNames: [String] = [ // TODO localize + "Paper Anniversary", + "Cotton Anniversary", + "Leather Anniversary", + "Flower Anniversary", + "Wood Anniversary", + "Iron Anniversary", + "Copper Anniversary", + "Bronze Anniversary", + "Pottery Anniversary", + "Tin Anniversary", + "Steel Anniversary", + "Silk Anniversary", + "Lace Anniversary", + "Ivory Anniversary", + "Crystal Anniversary", + "Porcelain Anniversary", + "Silver Anniversary", + "Pearl Anniversary", + "Coral Anniversary", + "Ruby Anniversary", + "Sapphire Anniversary", + "Gold Anniversary", + "Emerald Anniversary", + "Diamond Anniversary" + ] + + private static let enrollmentDurationAchievementInputs: [EnrollmentDurationAchievementInput] = [ + .init(.enrollmentDurationInWeeks, .weekOfYear, 1, .calendar), +// .init(metric: .enrollmentDurationInWeeks, component: .weekOfYear, count: 2, symbol: .calendar), + .init(.enrollmentDurationInMonths, .month, 1, .calendar), + .init(.enrollmentDurationInMonths, .month, 3, .calendar), + .init(.enrollmentDurationInMonths, .month, 6, .calendar), + .init(.enrollmentDurationInYears, .year, 1, .calendar), + .init(.enrollmentDurationInYears, .year, 2, .calendar), + .init(.enrollmentDurationInYears, .year, 3, .calendar), + .init(.enrollmentDurationInYears, .year, 4, .calendar), + .init(.enrollmentDurationInYears, .year, 5, .calendar) + ] +} + + +extension DateComponents { + init(component: Calendar.Component, value: Int) { + self.init() + setValue(value, for: component) + } +} diff --git a/MyHeartCounts/Achievements/AchievementIcon.swift b/MyHeartCounts/Achievements/AchievementIcon.swift new file mode 100644 index 00000000..4930f92a --- /dev/null +++ b/MyHeartCounts/Achievements/AchievementIcon.swift @@ -0,0 +1,38 @@ +// +// 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 SFSafeSymbols +import SwiftUI + + +struct AchievementIcon: View { + @Environment(AchievementsManager.self) private var manager + + let achievement: Achievement + + var body: some View { + ZStack { + let progress = manager.unlockProgress(of: achievement) + CircularProgressView(progress, lineWidth: 3) + .tint(.green) + let symbol: SFSymbol? = if progress >= 1 { + achievement.symbol + } else if achievement.visibility == .secret { + nil + } else { + achievement.symbol // also nil??? + } + if let symbol { + Image(systemSymbol: symbol) + .imageScale(.small) + } + } + .frame(width: 40, height: 40) + } +} + diff --git a/MyHeartCounts/Achievements/AchievementsManager.swift b/MyHeartCounts/Achievements/AchievementsManager.swift new file mode 100644 index 00000000..b18a3f3a --- /dev/null +++ b/MyHeartCounts/Achievements/AchievementsManager.swift @@ -0,0 +1,632 @@ +// +// 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 all + +import Algorithms +import FirebaseFirestore +import Foundation +import Spezi +import SpeziAccount +import SpeziHealthKit +import SpeziStudy +import SpeziFirestore +import SFSafeSymbols +import SwiftUI +import OSLog +import SpeziFoundation +import MyHeartCountsShared + + +// MARK: Achievement Definitions + +/// 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 ``AchievementsState/TriggerEvent`` that caused the achievement to get unlocked, or `nil` if the achievement is still locked. TODO UPDATE NO LONGER TRUE + case event(trigger: Trigger, predicate: @Sendable (_ events: [AchievementsState.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. + static func counting(trigger: Trigger, target: Int) -> Self { + // TODO: + // 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 .unlocked(unlockDate: .now) + } + // 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: String // TODO localized! + } + + 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[..