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[.. Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
diff --git a/MyHeartCounts/Achievements/AchievementIcon.swift b/MyHeartCounts/Achievements/AchievementIcon.swift
new file mode 100644
index 00000000..1f492cb9
--- /dev/null
+++ b/MyHeartCounts/Achievements/AchievementIcon.swift
@@ -0,0 +1,37 @@
+//
+// 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 {
+ let state = manager.state(of: achievement)
+ CircularProgressView(state.progress, lineWidth: 3) {
+ let symbol: SFSymbol? = switch state {
+ case .unlocked:
+ achievement.symbol
+ case .locked:
+ achievement.visibility == .secret ? nil : achievement.symbol
+ }
+ if let symbol {
+ Image(systemSymbol: symbol)
+ .imageScale(.small)
+ .accessibilityHidden(true)
+ }
+ }
+ .tint(.green)
+ .frame(width: 40, height: 40)
+ }
+}
diff --git a/MyHeartCounts/Achievements/AchievementsManager.swift b/MyHeartCounts/Achievements/AchievementsManager.swift
new file mode 100644
index 00000000..4a4363f9
--- /dev/null
+++ b/MyHeartCounts/Achievements/AchievementsManager.swift
@@ -0,0 +1,1039 @@
+//
+// 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 file_length
+
+import Algorithms
+import FirebaseFirestore
+import Foundation
+import MyHeartCountsShared
+import OSLog
+import Spezi
+import SpeziAccount
+import SpeziFirestore
+import SpeziFoundation
+import SpeziHealthKit
+import SpeziStudy
+
+
+/// Manages tracked achievements and syncs them with Firebase.
+@Observable
+final class AchievementsManager: Module, EnvironmentAccessible, @unchecked Sendable { // call it just Achievements?
+ struct State: Hashable, Codable, Sendable {
+ /// An error that indicates that decoding a `State` failed because the version (i.e., schema) was incompatible.
+ struct IncompatibleVersionDecodingError: Error {
+ /// The version of the `State` that failed to decode.
+ let incomingVersion: UInt
+ /// The version supported by the app.
+ let supportedVersion: UInt
+
+ /// Determines if the decoding failed because the incoming encoded `State` was newer than what the app can support.
+ var incomingWasNewer: Bool {
+ incomingVersion > supportedVersion
+ }
+ }
+
+ /// A recorded event where a trigger was fired.
+ ///
+ /// - Note: This type intentionally stores only a reference to the Trigger (via its ``Achievement/Trigger/ID``) rather than the whole trigger itself,
+ /// in order to allow the trigger to evolve with future app releases, without the server-persisted state still containing then-outdated definitions.
+ struct TriggerEvent: Hashable, Codable, Sendable {
+ /// The trigger that occurred
+ let triggerId: Achievement.Trigger.ID
+ /// The timestamp when this trigger occurred
+ let timestamp: Date
+
+ init(triggerId: Achievement.Trigger.ID, timestamp: Date) {
+ self.triggerId = triggerId
+ self.timestamp = timestamp.normalizedForFirestore()
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ // we want to route this through the normalizing init
+ self.init(
+ triggerId: try container.decode(Achievement.Trigger.ID.self, forKey: .triggerId),
+ timestamp: try container.decode(Date.self, forKey: .timestamp)
+ )
+ }
+ }
+
+
+ /// An observed value for some tracked metric.
+ ///
+ /// - Note: This type intentionally stores only a reference to the Metric (via its ``Achievement/Metric/ID``) rather than the whole metric definition,
+ /// in order to allow the metric to evolve with future app releases, without the server-persisted state still containing then-outdated definitions.
+ struct MetricObservation: Hashable, Codable, Sendable {
+ /// The timestamp when this value was observed.
+ let timestamp: Date
+ /// The value that was observed
+ let value: Double
+
+ init(timestamp: Date, value: Double) {
+ self.value = value
+ self.timestamp = timestamp.normalizedForFirestore()
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ // we want to route this through the normalizing init
+ self.init(
+ timestamp: try container.decode(Date.self, forKey: .timestamp),
+ value: try container.decode(Double.self, forKey: .value)
+ )
+ }
+ }
+
+
+ struct AchievementUnlock: Hashable, Codable, Sendable {
+ let achievementId: Achievement.ID
+ let unlockDate: Date
+
+ init(achievementId: Achievement.ID, unlockDate: Date) {
+ self.achievementId = achievementId
+ self.unlockDate = unlockDate.normalizedForFirestore()
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ // we want to route this through the normalizing init
+ self.init(
+ achievementId: try container.decode(Achievement.ID.self, forKey: .achievementId),
+ unlockDate: try container.decode(Date.self, forKey: .unlockDate)
+ )
+ }
+ }
+
+
+ struct AchievementUnlocks: Hashable, Codable, Collection, Sendable {
+ typealias Storage = [Achievement.ID: Date] // swiftlint:disable:this nesting
+
+ private var storage: Storage
+
+ var startIndex: Storage.Index {
+ storage.startIndex
+ }
+ var endIndex: Storage.Index {
+ storage.endIndex
+ }
+
+ init() {
+ storage = [:]
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ storage = try container.decode(Storage.self)
+ .mapValues { $0.normalizedForFirestore() }
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(storage)
+ }
+
+ func index(after idx: Storage.Index) -> Storage.Index {
+ storage.index(after: idx)
+ }
+
+ subscript(achievementId: Achievement.ID) -> Date? {
+ get {
+ storage[achievementId]
+ }
+ set {
+ storage[achievementId] = newValue?.normalizedForFirestore()
+ }
+ }
+
+ subscript(position: Storage.Index) -> Storage.Element {
+ storage[position]
+ }
+ }
+
+ /// The version of the ``State`` type.
+ ///
+ /// Used to enable potential future evolution of the type.
+ fileprivate let version: UInt
+
+ fileprivate var triggerEvents: Set
+
+ /// - Note: "metric" here is not referring to the metric system, and rather to the fact that each entry belongs to some metric.
+ fileprivate var metricObservations: [Achievement.Metric.ID: MetricObservation]
+
+ /// Keeps track of recorded unlock events.
+ fileprivate var unlocks: AchievementUnlocks
+
+ fileprivate init() {
+ version = 1
+ triggerEvents = []
+ metricObservations = [:]
+ unlocks = .init()
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let version = try container.decode(UInt.self, forKey: .version)
+ switch version {
+ case 1:
+ self.version = version
+ self.triggerEvents = try container.decode(Set.self, forKey: .triggerEvents)
+ self.metricObservations = try container.decode([Achievement.Metric.ID: MetricObservation].self, forKey: .metricObservations)
+ self.unlocks = try container.decode(AchievementUnlocks.self, forKey: .unlocks)
+ default:
+ throw IncompatibleVersionDecodingError(incomingVersion: version, supportedVersion: 1)
+ }
+ }
+ }
+
+ /// The current overall availability of the achievements system
+ enum SystemAvailability: Hashable {
+ case available
+ case unavailable(UnavailableReason)
+
+ enum UnavailableReason: Hashable {
+ /// The current install of the app is too old to be able to parse and ingest the achievements state stored on the server.
+ case appOutdated
+ /// No user is logged in to sync achievements with.
+ case noUser
+ }
+ }
+
+
+ private enum SyncError: Error {
+ case notEnrolled
+ }
+
+ // swiftlint:disable attributes
+ @ObservationIgnored @Application(\.logger) private var logger
+ @ObservationIgnored @Dependency(Account.self) private var account: Account?
+ @ObservationIgnored @Dependency(StudyManager.self) private var studyManager: StudyManager?
+ @ObservationIgnored @Dependency(FirebaseConfiguration.self) var firebaseConfiguration
+ @ObservationIgnored @Dependency(HealthKit.self) private var healthKit
+ // swiftlint:enable attributes
+
+ /// Protects ``_achievements``
+ @ObservationIgnored private let achievementsLock = RWLock()
+ /*nonisolated(unsafe)*/ private var _achievements: [Achievement] = []
+
+
+ var achievements: [Achievement] {
+ achievementsLock.withReadLock {
+ _achievements
+ }
+ }
+
+
+ /// The current availability of the achievement system.
+ ///
+ /// The system will be available most of the time, but if it is unavailable it typically is because the achievements state on the server is using a newer schema than what the app can support,
+ /// and since we don't want to override the newer server schema with the outdated client schema, we disable achievements entirely, until the next launch (where the app will
+ /// either have been updated to a version that understands the server schema, or will also disable itself again).
+ ///
+ /// Note that this does open a possible failure path where the user performs some achievement trigger-firing task, which is then not persisted to the server,
+ /// Additionally, there currently is no local caching mechanism for these cases. Overall, this should not be terribly bad, since the only triggers we have are completions
+ /// of tasks that are persisted to the server anyway, so we could easily fill in the missing trigger events server-side.
+ ///
+ /// For the time being, this is fine, because we only have one ``State`` schema version at the moment (the initial one),
+ /// and are not planning on making any changes to that anytime soon...
+ @MainActor private(set) var systemAvailability: SystemAvailability = .available
+
+ /// The single in-flight sync. All sync funnels through this one task, so two syncs can never
+ /// interleave their read-merge-write. Cleared by the task itself (via `defer`) on completion.
+ @MainActor private var runningSync: Task?
+ /// Set to `true` whenever local state changes and a sync is scheduled.
+ ///
+ /// The purpose of this flag is to tell an in-flight sync loop it must run another pass, in order to handle situations where the state is changed while the sync is ongoing.
+ @MainActor private var syncDirty = false
+ /// Pending debounce timer for a requested-but-not-yet-started sync.
+ @MainActor private var debounceTask: Task?
+ /// The task used to observe server-side changes, and respond to them
+ @MainActor private var remoteChangesObserver: Task?
+
+ /// Locally cached achievements state.
+ ///
+ /// Automatically kept in sync with the server.
+ @MainActor private var achievementsState = State() {
+ didSet {
+ if achievementsState != oldValue {
+ scheduleSync()
+ }
+ }
+ }
+
+ @MainActor private var achievementTrackingDoc: DocumentReference {
+ get throws {
+ guard let studyId = studyManager?.studyEnrollments.first?.studyId else {
+ throw SyncError.notEnrolled
+ }
+ return try firebaseConfiguration.userDocumentReference.collection("achievementTracking").document(studyId.uuidString)
+ }
+ }
+
+
+ nonisolated init() {}
+
+
+ func configure() {
+ Achievement.registerDefaultAchievements(with: self)
+ Task {
+ do {
+ // try to connect the manager w/ the account. this will fail if no user is logged in, in which case we fall back
+ // to the association happening when the Standard calls -associateWithAccount in response to the user logging in
+ try await self.associateWithAccount()
+ } catch {
+ logger.error("Setup failed: \(error)")
+ }
+ }
+ }
+
+
+ func register(achievement: Achievement) {
+ register(achievements: CollectionOfOne(achievement))
+ }
+
+ func register(achievements newEntries: some Sequence) {
+ achievementsLock.withWriteLock {
+ var seenIds = _achievements.mapIntoSet(\.id)
+ for new in newEntries where seenIds.insert(new.id).inserted {
+ assert(
+ new.visibility != .secretUnlessNextInLadder || new.subcategory != nil,
+ "Achievement '\(new.id)' is .secretUnlessNextInLadder but has no subcategory. 'next' is undefined without a ladder."
+ )
+ self._achievements.append(new)
+ }
+ }
+ }
+
+
+ func refresh() async throws {
+ // make sure the local version is up-to-date
+ try await syncNow()
+ if let enrollmentDate = await MainActor.run(body: { studyManager?.studyEnrollments.first?.enrollmentDate }) {
+ await updateEnrollmentStats(enrollmentDate: enrollmentDate)
+ await updateHealthMetrics(enrollmentDate: enrollmentDate)
+ }
+ }
+}
+
+
+// MARK: Sync
+
+extension AchievementsManager {
+ /// Scheduled a debounced sync.
+ @MainActor
+ private func scheduleSync() {
+ guard systemAvailability == .available else {
+ return
+ }
+ syncDirty = true
+ // A running sync will pick up `syncDirty` and run another pass; a pending debounce will fire
+ // on its own. In either case there's nothing more to schedule.
+ guard runningSync == nil, debounceTask == nil else {
+ return
+ }
+ debounceTask = Task { @MainActor in
+ try? await Task.sleep(for: .seconds(2))
+ self.debounceTask = nil
+ do {
+ try await self._runSync()
+ } catch {
+ self.logger.error("Scheduled sync failed: \(error)")
+ }
+ }
+ }
+
+ /// Performs an immediate sync between the local state and the firebase state.
+ @MainActor
+ func syncNow() async throws {
+ debounceTask?.cancel()
+ debounceTask = nil
+ syncDirty = true // guarantees a coalesced caller still gets one pass over the latest state
+ try await _runSync()
+ }
+
+ /// Performs a sync between the local state and the firebase state.
+ ///
+ /// It is safe to call this function while another sync is already in progress.
+ /// It also is safe to mutate ``achievementsState`` while a sync is in progress; the changes will be picked up and synced as well.
+ @MainActor
+ private func _runSync() async throws { // swiftlint:disable:this function_body_length cyclomatic_complexity
+ guard systemAvailability == .available else {
+ return
+ }
+ if let runningSync {
+ try await runningSync.value
+ return
+ }
+ /// Performs a single read-merge-write pass against the cloud document
+ let syncImpl = { @MainActor () async throws in // swiftlint:disable:this closure_body_length
+ let doc: DocumentReference
+ do {
+ doc = try self.achievementTrackingDoc
+ } catch SyncError.notEnrolled {
+ // loading the doc failed bc the user is not enrolled.
+ // in this case, we intentionally don't want to go down the retry path.
+ self.achievementsState = State()
+ self.syncDirty = false
+ return
+ } catch {
+ self.syncDirty = true
+ throw error
+ }
+ // would ideally use a firestore transaction here to wrap all interactions with `doc`,
+ // but that isn't possible as the API does not support async transactions.
+ let snapshot = try await doc.getDocument()
+ try Task.checkCancellation()
+ // Capture the state this pass commits and clear the dirty flag in the SAME synchronous turn.
+ // Anything recorded after this line re-sets `syncDirty` (handled by the next pass); anything
+ // recorded before (including during the fetch above) is included in `local`.
+ let local = self.achievementsState
+ self.syncDirty = false
+ guard snapshot.exists else {
+ // No cloud doc yet: create it, but never write empty state over the server (e.g. a
+ // fresh/offline launch before anything has been recorded).
+ guard !local.isEmpty else {
+ return
+ }
+ do {
+ try await doc.setData(from: local)
+ try Task.checkCancellation()
+ } catch {
+ self.syncDirty = true // upload failed: keep the change pending so runSync re-arms
+ throw error
+ }
+ return
+ }
+ let cloud: State
+ do {
+ cloud = try snapshot.data(as: State.self)
+ } catch let error as State.IncompatibleVersionDecodingError where error.incomingWasNewer {
+ // The decoding failed because the state on the server uses a coding schema that is newer than what the app can support.
+ // In this case, we need to be careful, because we don't want the app to accidentally overwrite the state on the server.
+ self.systemAvailability = .unavailable(.appOutdated)
+ self.achievementsState = State()
+ throw error
+ } catch {
+ self.syncDirty = true
+ throw error
+ }
+ // Fold cloud into the captured local state (least-upper-bound). `merged >= local`, so
+ // adopting it never regresses local progress.
+ let merged = local.merging(cloud, allAchievements: self.achievements)
+ if merged != self.achievementsState {
+ try Task.checkCancellation()
+ self.achievementsState = merged
+ }
+ guard merged != cloud else {
+ return // cloud already has everything; nothing to upload
+ }
+ do {
+ try await doc.setData(from: merged)
+ } catch {
+ self.syncDirty = true // upload failed: keep the change pending so runSync re-arms
+ throw error
+ }
+ }
+ let syncTask = Task { @MainActor in
+ // Cleared synchronously with the loop's exit decision: on the serial executor no `record()`
+ // can interleave between the final `while self.syncDirty` read and this assignment.
+ defer {
+ runningSync = nil
+ }
+ repeat {
+ try await syncImpl()
+ } while syncDirty
+ }
+ runningSync = syncTask
+ defer {
+ // By the time we resume here, the task's own defer has already cleared `runningSync`.
+ // If a change is still pending (a `record(...)` that landed in the teardown gap, or a pass
+ // that threw with `syncDirty` still set), re-arm a debounced retry now that we are no
+ // longer the runner (so `scheduleSync`'s `runningSync == nil` guard lets it through).
+ if syncDirty {
+ scheduleSync()
+ }
+ }
+ try await syncTask.value
+ }
+
+
+ /// Configures the `AchievementsManager` to populate its state from the currently logged-in `Account`
+ ///
+ /// This function also sets up an observer on the firestore document tracking the current user's achievements progress, and automatically syncs any remote changes back into the local state.
+ ///
+ /// This function only performs actual work the first time is called; any subsequent calls will see that an association already exists and return early.
+ /// Call ``disassociateFromAccount()`` to clear the association (e.g., in response to user logout), in which case the next call to this function will set up a new one.
+ @MainActor
+ func associateWithAccount() async throws {
+ guard remoteChangesObserver == nil else {
+ return
+ }
+ let doc = try achievementTrackingDoc
+ // if we can obtain the doc, there must be a user.
+ systemAvailability = .available
+ let snapshots = doc.snapshots
+ remoteChangesObserver = Task {
+ defer {
+ self.remoteChangesObserver = nil
+ }
+ do {
+ for try await snapshot: DocumentSnapshot in snapshots {
+ guard !snapshot.metadata.hasPendingWrites else {
+ // if `snapshot.metadata.hasPendingWrites` is true, we're being informed about a client-side mutation
+ // (which obv we want to skip)
+ continue
+ }
+ do {
+ try await syncNow()
+ } catch {
+ logger.error("Sync in response to remote doc update failed: \(error)")
+ }
+ }
+ } catch {
+ self.logger.error("Remote changes observation failed: \(error)")
+ }
+ }
+ try await refresh()
+ }
+
+
+ /// Cancels the association set up by ``associateWithAccount()`` and clears the local achievments state.
+ ///
+ /// - Important: you very likely want to perform a final sync (``syncNow()``) before calling this function, to ensure the local state is correctly persisted in the cloud.
+ /// This function will cancel all pending debounces and all in-progress syncs!
+ @MainActor
+ func disassociateFromAccount() {
+ func cancel(_ task: inout Task?) {
+ task?.cancel()
+ task = nil
+ }
+ systemAvailability = .unavailable(.noUser)
+ cancel(&remoteChangesObserver)
+ cancel(&debounceTask)
+ cancel(&runningSync)
+ achievementsState = .init()
+ }
+}
+
+
+// MARK: Mutations
+
+extension AchievementsManager {
+ @MainActor
+ private func updateEnrollmentStats(enrollmentDate: Date) async {
+ record(.completeEnrollment, timestamp: enrollmentDate)
+ let now = Date()
+ let cal = Calendar.current
+ let enrollmentDate = cal.startOfDay(for: enrollmentDate)
+ // Note that `numDays` here intentionally differs from `num{Weeks|Months|Years}` in that it starts counting at 1 instead of 0.
+ let numDays = cal.countDistinctDays(from: enrollmentDate, to: now)
+ let numWeeks = cal.dateComponents([.weekOfYear], from: enrollmentDate, to: now).weekOfYear ?? (numDays / 7)
+ let numMonths = cal.dateComponents([.month], from: enrollmentDate, to: now).month ?? (numWeeks / 4)
+ let numYears = cal.dateComponents([.year], from: enrollmentDate, to: now).year ?? (numMonths / 12)
+ record(.enrollmentDurationInDays, value: numDays, timestamp: now)
+ record(.enrollmentDurationInWeeks, value: numWeeks, timestamp: now)
+ record(.enrollmentDurationInMonths, value: numMonths, timestamp: now)
+ record(.enrollmentDurationInYears, value: numYears, timestamp: now)
+ }
+
+ @MainActor
+ private func updateHealthMetrics(enrollmentDate: Date) async {
+ await withDiscardingTaskGroup { taskGroup in
+ let queryTimeRange = HealthKitQueryTimeRange(Calendar.current.startOfDay(for: enrollmentDate).. max value, `.atMost` -> min
+ /// value; ties on value resolve to the earliest timestamp to stay commutative).
+ ///
+ /// - parameter recordOnceTriggerIDs: all currently-known trigger ids whose rule is ``Achievement/Trigger/RecordingMode/recordOnce``.
+ /// - parameter metricRuleThresholds: the currently-registered metric rule thresholds.
+ fileprivate func merging( // swiftlint:disable:this cyclomatic_complexity function_body_length
+ _ other: Self,
+ allAchievements: some Collection
+ ) -> Self {
+ /// Trigger IDs whose events collapse to a single (earliest) entry when merging two states.
+ let recordOnceTriggerIds: Set = allAchievements.reduce(into: []) { result, achievement in
+ switch achievement.kind {
+ case .event(let trigger, predicate: _):
+ if trigger.recordingMode == .recordOnce {
+ result.insert(trigger.id)
+ }
+ case .threshold:
+ break
+ }
+ }
+ /// Metric rules by metric id
+ let metricRuleThresholds: [Achievement.Metric.ID: Achievement.ThresholdRule] = allAchievements.reduce(into: [:]) { result, achievement in
+ switch achievement.kind {
+ case .threshold(let metric, target: _):
+ result[metric.id] = metric.rule
+ case .event:
+ break
+ }
+ }
+ var merged = self
+ // 1: merge trigger events
+ merged.triggerEvents.formUnion(other.triggerEvents)
+ // collapse each record-once trigger to its earliest event
+ for triggerID in recordOnceTriggerIds {
+ let events = merged.triggerEvents.filter { $0.triggerId == triggerID }
+ guard events.count > 1, let earliest = events.min(by: { $0.timestamp < $1.timestamp }) else {
+ continue
+ }
+ merged.triggerEvents.subtract(events)
+ merged.triggerEvents.insert(earliest)
+ }
+ // 2: merge observed metrics
+ for (metricId, incomingObservation) in other.metricObservations {
+ if let rule = metricRuleThresholds[metricId] {
+ merged.record(
+ .init(id: metricId, rule: rule),
+ value: incomingObservation.value,
+ timestamp: incomingObservation.timestamp,
+ allAchievements: allAchievements
+ )
+ } else {
+ // unknown metric (likely written by server / other build, and we're currently running an outdated version of the app)
+ if let localObservation = merged.metricObservations[metricId] {
+ // the observation exists in self, and in other.
+ // ISSUE: this presents us with a problem w.r.t. the question of how this should be handled
+ // (the issue being that we don't have the definition so we don't know how to reduce these 2 observations into 1)...
+ // SO: what we do in this case is that we resolve the conflict exclusively based on the timestamp
+ // (i.e., the newer entry wins). this isn't ideal, and might in fact even lead to some small data loss, but it's the
+ // least incorrect option we can do.
+ // ALSO: it should be pointed out that in the specific context of MHC, any metric observations we have in the local state
+ // that don't have a corresponding Metric definition shipped with the app, will very likely always be imported from the
+ // cloud state anyway (since there is no other way these observations can be added to the local state).
+ merged.metricObservations[metricId] = if localObservation.timestamp >= incomingObservation.timestamp {
+ localObservation
+ } else {
+ incomingObservation
+ }
+ } else { // present in other but not in self
+ // the observation is present in the incoming State, but not in the destination one.
+ // we simply preserve it as-is.
+ merged.metricObservations[metricId] = incomingObservation
+ }
+ }
+ }
+ // 3: merge unlocks
+ for (id, date) in other.unlocks {
+ merged.unlocks[id] = merged.unlocks[id].map { min($0, date) } ?? date
+ }
+ return merged
+ }
+
+ fileprivate mutating func record(_ trigger: Achievement.Trigger, timestamp: Date) {
+ switch trigger.recordingMode {
+ case .recordOnce:
+ if let idx = triggerEvents.firstIndex(where: { $0.triggerId == trigger.id }) {
+ if timestamp < triggerEvents[idx].timestamp {
+ triggerEvents.remove(at: idx)
+ fallthrough
+ }
+ } else {
+ fallthrough
+ }
+ case .keepAll:
+ triggerEvents.insert(.init(triggerId: trigger.id, timestamp: timestamp))
+ }
+ }
+
+ fileprivate mutating func record(
+ _ metric: Achievement.Metric,
+ value: Double,
+ timestamp: Date,
+ allAchievements: some Collection
+ ) {
+ guard let oldEntry = metricObservations[metric.id] else {
+ metricObservations[metric.id] = .init(timestamp: timestamp, value: value)
+ evaluateUnlocks(trigger: .updatedMetricObservation(metric: metric), allAchievements: allAchievements)
+ return
+ }
+ switch metric.rule {
+ case .atLeast: // tracking upwards: keep the max value, earliest timestamp on a tie
+ if value > oldEntry.value || (value == oldEntry.value && timestamp < oldEntry.timestamp) {
+ metricObservations[metric.id] = .init(timestamp: timestamp, value: value)
+ evaluateUnlocks(trigger: .updatedMetricObservation(metric: metric), allAchievements: allAchievements)
+ }
+ case .atMost: // tracking downwards: keep the min value, earliest timestamp on a tie
+ if value < oldEntry.value || (value == oldEntry.value && timestamp < oldEntry.timestamp) {
+ metricObservations[metric.id] = .init(timestamp: timestamp, value: value)
+ evaluateUnlocks(trigger: .updatedMetricObservation(metric: metric), allAchievements: allAchievements)
+ }
+ }
+ }
+
+
+ fileprivate func state(of achievement: Achievement) -> AchievementsManager.AchievementState {
+ if let unlockDate = unlocks[achievement.id] {
+ return .unlocked(unlockDate: unlockDate)
+ }
+ switch achievement.kind {
+ case let .event(trigger, predicate):
+ return predicate(
+ triggerEvents
+ .filter { $0.triggerId == trigger.id }
+ .sorted(using: KeyPathComparator(\.timestamp))
+ )
+ case let .threshold(metric, target):
+ guard let observation = metricObservations[metric.id] else {
+ return .locked(progress: 0, lastUpdate: nil)
+ }
+ let progress: Double = switch metric.rule {
+ case .atLeast(let base): // tracking upwards
+ if let base {
+ ((observation.value - base) / (target - base)).clamped(to: 0...1)
+ } else {
+ (observation.value >= target) ? 1 : 0
+ }
+ case .atMost(let base): // tracking downwards
+ if let base {
+ ((base - observation.value) / (base - target)).clamped(to: 0...1)
+ } else {
+ (observation.value <= target) ? 1 : 0
+ }
+ }
+ return if progress >= 1 {
+ .unlocked(unlockDate: observation.timestamp)
+ } else {
+ .locked(progress: progress, lastUpdate: observation.timestamp)
+ }
+ }
+ }
+}
+
+
+extension AchievementsManager.State {
+ /// Info about the specific thing that caused the evaluation to be performed.
+ private enum UnlocksEvalTrigger {
+ case updatedMetricObservation(metric: Achievement.Metric)
+ case unknown
+ }
+
+ private mutating func evaluateUnlocks(
+ trigger: UnlocksEvalTrigger,
+ allAchievements: some Collection
+ ) {
+ for achievement in allAchievements {
+ switch achievement.kind {
+ case .event:
+ // currently not covered by `AchievementUnlocks`.
+ break
+ case .threshold(let metric, _):
+ switch trigger {
+ case .updatedMetricObservation(let updatedMetric):
+ guard metric == updatedMetric else {
+ continue
+ }
+ case .unknown:
+ break
+ }
+ guard self.unlocks[achievement.id] == nil else {
+ // already unlocked
+ break
+ }
+ switch state(of: achievement) {
+ case .locked:
+ // still unlocked
+ break
+ case .unlocked(let unlockDate):
+ // previously locked, now unlocked
+ unlocks[achievement.id] = unlockDate
+ }
+ }
+ }
+ }
+}
+
+
+// MARK: Querying
+
+extension Achievement {
+ /// Used to identify an "achievements ladder", i.e., a sequence of achievements that track the same event/metric, and unlock in order.
+ ///
+ /// Achievement ladders are implicitly derived from the achievements' ``Achievement/category`` and ``Achievement/subcategory`` values.
+ /// All achievements with the same category and subcategory values belong to a ladder, if ``Achievement/Subcategory/formsLadder`` is enabled.
+ /// The order within the ladder is based on the order in which the achievements were registered.
+ ///
+ /// For example, the app defines a series of "Walk N steps in a day" achievements, with N=(10k, 20k, 30k, ...).
+ fileprivate struct Ladder: Hashable {
+ let category: Achievement.Category
+ let subcategory: Achievement.Subcategory
+ }
+
+ /// The achievement's ladder, if applicable.
+ fileprivate var ladder: Ladder? {
+ if let subcategory, subcategory.formsLadder {
+ Ladder(category: category, subcategory: subcategory)
+ } else {
+ nil
+ }
+ }
+}
+
+extension AchievementsManager {
+ enum AchievementsFilter: Sendable {
+ /// No filtering is applied, i.e. all achievements are considered
+ case none
+ /// Only achievements from the specifiec category and subcategory are considered. If `subcategory` is nil, the filter will look only at the category.
+ case category(Achievement.Category, subcategory: Achievement.Subcategory? = nil)
+
+ fileprivate func evaluate(_ achievement: Achievement) -> Bool {
+ switch self {
+ case .none:
+ true
+ case .category(let category, subcategory: .none):
+ achievement.category == category
+ case let .category(category, subcategory: .some(subcategory)):
+ achievement.category == category && achievement.subcategory == subcategory
+ }
+ }
+ }
+
+ /// An already unlocked achievement
+ struct UnlockedAchievement: Sendable {
+ let unlockDate: Date
+ let achievement: Achievement
+ }
+
+ /// A yet-to-be unlocked achievement
+ struct UpcomingAchievement: Sendable {
+ let achievement: Achievement
+ /// The achivement's current progress.
+ ///
+ /// Since this is representing an upcoming (i.e., yet to be unlocked) achievement, this value will always be in the range `0..<1`.
+ let progress: Double
+ let lastUpdate: Date?
+ }
+
+
+ enum AchievementState {
+ case locked(progress: Double, lastUpdate: Date?)
+ case unlocked(unlockDate: Date)
+
+ /// The progress wrt unlocking this achievement, on a scale from `0` to `1`.
+ ///
+ /// - Note: Not all achievement support fractional progress reports; in these cases the value will be either `0` or `1`, but never anything inbetween.
+ var progress: Double {
+ switch self {
+ case .locked(let progress, lastUpdate: _):
+ progress
+ case .unlocked:
+ 1
+ }
+ }
+ }
+
+
+ /// user-displayable number of achievements existing in the app
+ var userDisplayableTotalAchievementCount: Int {
+ achievements.count { $0.visibility != .internal }
+ }
+
+ /// user-displayable number of currently unlocked achievements
+ @MainActor var userDisplayableUnlockedAchievementsCount: Int {
+ achievements.count { $0.visibility != .internal && didUnlock($0) }
+ }
+
+
+ /// Returns all unlocked achievements, optionally sorted by the date they were unlocked
+ @MainActor
+ func unlockedAchievements(
+ filter: AchievementsFilter = .none,
+ sortByUnlockDate: Bool
+ ) -> [UnlockedAchievement] {
+ let unlocked = achievements.lazy
+ .filter { filter.evaluate($0) }
+ .compactMap { achievement -> UnlockedAchievement? in
+ guard achievement.visibility != .internal else {
+ return nil
+ }
+ return switch self.state(of: achievement) {
+ case .locked:
+ nil
+ case .unlocked(let unlockDate):
+ UnlockedAchievement(unlockDate: unlockDate, achievement: achievement)
+ }
+ }
+ return if sortByUnlockDate {
+ unlocked.sorted(using: KeyPathComparator(\.unlockDate))
+ } else {
+ Array(unlocked)
+ }
+ }
+
+
+ /// Whether `achievement` is currently the first still-locked level of its ladder, in registration order.
+ ///
+ /// Standalone achievements (no ladder) are trivially "next" while locked. Already-unlocked achievements
+ /// return `false` (they aren't *locked* levels).
+ @MainActor
+ func isNextLockedLevel(_ achievement: Achievement) -> Bool {
+ guard let ladder = achievement.ladder else {
+ return !didUnlock(achievement)
+ }
+ return achievements.first { $0.ladder == ladder && !didUnlock($0) } == achievement
+ }
+
+
+ @MainActor
+ func nextLockedAchievement(
+ in category: Achievement.Category,
+ subcategory: Achievement.Subcategory?
+ ) -> UpcomingAchievement? {
+ nextLockedAchievements(in: category).first {
+ AchievementsFilter.category(category, subcategory: subcategory).evaluate($0.achievement)
+ }
+ }
+
+ /// Computes a list of upcoming, currently still locked achievements.
+ ///
+ /// - parameter category: The category to fetch from. Set to `nil` to consider all categories.
+ /// - parameter excluding: A set of ``Achievement``s that should not be included in the list. This filter is applied prior to `limit`.
+ @MainActor
+ func nextLockedAchievements(
+ in category: Achievement.Category? = nil,
+ excluding: Set = []
+ ) -> [UpcomingAchievement] {
+ // 1. user-visible + still-locked, carrying state so we don't recompute it for sorting/rendering.
+ // .internal and plain .secret are excluded; .secretUnlessNextInCategory is allowed
+ // (ladder-collapse below reveals only its next level and keeps later ones hidden).
+ let candidates: [UpcomingAchievement] = achievements
+ .compactMap { achievement in
+ if let category, achievement.category != category {
+ return nil
+ }
+ switch achievement.visibility {
+ case .internal, .secret:
+ return nil
+ case .always, .secretUnlessNextInLadder:
+ break
+ }
+ guard case .locked(let progress, let lastUpdate) = self.state(of: achievement) else {
+ return nil
+ }
+ return UpcomingAchievement(achievement: achievement, progress: progress, lastUpdate: lastUpdate)
+ }
+ // 2. collapse each ladder to its first (= next) locked level, in authored order.
+ // NOTE: this is the "what's next" surface, so we collapse EVERY ladder to one level regardless
+ // of visibility — unlike `AchievementsView`, where only `.secretUnlessNext` hides future levels.
+ var seenLadders = Set()
+ let collapsed = candidates.filter { entry in
+ guard let key = entry.achievement.ladder else {
+ // keep all standalone achievements
+ return true
+ }
+ // for ladder-like achievements, we want to keep only the first one
+ return seenLadders.insert(key).inserted
+ }
+ // 3. closest-to-unlock first; stable tie-break preserves authored order for equal progress
+ return collapsed
+ .enumerated()
+ .sorted {
+ $0.element.progress != $1.element.progress
+ ? $0.element.progress > $1.element.progress
+ : $0.offset < $1.offset
+ }
+ .map(\.element)
+ .filter { !excluding.contains($0.achievement) }
+ }
+
+ @MainActor
+ func didUnlock(_ achievement: Achievement) -> Bool {
+ switch state(of: achievement) {
+ case .locked: false
+ case .unlocked: true
+ }
+ }
+
+ @MainActor
+ func unlockProgress(of achievement: Achievement) -> Double {
+ state(of: achievement).progress
+ }
+
+ @MainActor
+ func state(of achievement: Achievement) -> AchievementState {
+ achievementsState.state(of: achievement)
+ }
+}
+
+
+extension Date {
+ /// Creates a new date value by normalizing (rounding) this date in a way that will make it resilient to Firestore decoding/encoding round trips.
+ func normalizedForFirestore() -> Date {
+ Date(timeIntervalSinceReferenceDate: timeIntervalSinceReferenceDate.rounded(toNearestMultipleOf: 0x1p-3)) // 1 * 2^-3
+ }
+}
+
+
+extension FloatingPoint {
+ func rounded(toNearestMultipleOf step: Self, rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Self {
+ (self / step).rounded(rule) * step
+ }
+}
diff --git a/MyHeartCounts/Achievements/AchievementsView.swift b/MyHeartCounts/Achievements/AchievementsView.swift
new file mode 100644
index 00000000..5ca9667e
--- /dev/null
+++ b/MyHeartCounts/Achievements/AchievementsView.swift
@@ -0,0 +1,87 @@
+//
+// 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 file_types_order
+
+import Algorithms
+import SFSafeSymbols
+import SwiftUI
+
+
+struct AchievementsView: View {
+ @Environment(AchievementsManager.self)
+ private var manager
+
+ private var achievementsByCategory: [Achievement.Category: [Achievement]] {
+ manager.achievements.grouped(by: \.category)
+ }
+
+ var body: some View {
+ Form {
+ // we intentionally do this (instead of eg simply achievements.mapIntoSet)
+ // bc we want to append unknown categories in the order in which they appear.
+ let allCategories = manager.achievements.reduce(into: Achievement.Category.all) { allCategories, achievement in
+ if !allCategories.contains(achievement.category) {
+ allCategories.append(achievement.category)
+ }
+ }
+ ForEach(allCategories) { category in
+ if let achievements = achievementsByCategory[category]?.filter(shouldDisplay), !achievements.isEmpty {
+ Section(category.title) {
+ ForEach(achievements) { (achievement: Achievement) in
+ AchievementRow(achievement: achievement)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Achievements")
+ }
+
+ /// Whether a `.secretUnlessNext` achievement should be shown: once unlocked, or while it's the next
+ /// locked level of its ladder. Unlike the "what's next" rail, this only collapses `.secretUnlessNext`
+ /// ladders — `.always` ladders show every level here.
+ private func shouldDisplay(_ achievement: Achievement) -> Bool {
+ switch achievement.visibility {
+ case .always, .secret:
+ true
+ case .internal:
+ false
+ case .secretUnlessNextInLadder:
+ // check if this is the first locked one in the category
+ manager.didUnlock(achievement) || manager.isNextLockedLevel(achievement)
+ }
+ }
+}
+
+
+private struct AchievementRow: View {
+ @Environment(AchievementsManager.self)
+ private var manager
+
+ let achievement: Achievement
+
+ var body: some View {
+ HStack {
+ AchievementIcon(achievement: achievement)
+ VStack(alignment: .leading) {
+ Text(achievement.title)
+ .font(.body)
+ Text(achievement.description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ let progress = manager.unlockProgress(of: achievement)
+ if progress > 0 && progress < 1 {
+ Badge("\(progress, format: .percent.precision(.fractionLength(0)))")
+ .tint(.green)
+ }
+ }
+ }
+}
diff --git a/MyHeartCounts/Achievements/MHCAchievements.swift b/MyHeartCounts/Achievements/MHCAchievements.swift
new file mode 100644
index 00000000..67fab02b
--- /dev/null
+++ b/MyHeartCounts/Achievements/MHCAchievements.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
+//
+
+// IDEA 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 SFSafeSymbols
+import SpeziFoundation
+
+
+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
+ )
+
+ 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 {
+ // currently unused but we wanna track it anyways,
+ // just in case we wanna do smth with this down the road
+ 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 numRecordedECGs = Self(
+ id: "num-recorded-ecgs",
+ rule: .atLeast(base: 0)
+ )
+}
+
+
+extension Achievement {
+ 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
+ }
+ }
+
+ private static let durationFmt: DateComponentsFormatter = {
+ let fmt = DateComponentsFormatter()
+ fmt.unitsStyle = .full
+ fmt.allowedUnits = [.weekOfMonth, .month, .year]
+ return fmt
+ }()
+
+ static func registerDefaultAchievements(with manager: AchievementsManager) { // swiftlint:disable:this function_body_length
+ manager.register(achievements: Array { // swiftlint:disable:this closure_body_length
+ 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: .complete6MinWalkTest),
+ title: "Pedestrian Pioneer",
+ description: "Record your first Walk Test",
+ symbol: .figureWalk,
+ visibility: .always
+ )
+ Self(
+ id: "first-12mrt",
+ category: .appUsage,
+ subcategory: nil,
+ kind: .eventOnce(trigger: .complete12MinRunTest),
+ title: "Cooper Trooper",
+ description: "Record your first Run Test",
+ symbol: .figureRun,
+ visibility: .always
+ )
+ Self(
+ id: "first-ecg",
+ category: .appUsage,
+ subcategory: nil,
+ kind: .threshold(metric: .numRecordedECGs, target: 1),
+ title: "Cardio Connoisseur",
+ description: "Record your first ECG",
+ symbol: .waveformPathEcgRectangle,
+ visibility: .always
+ )
+
+ for (count, title): (Int, LocalizedStringResource) 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: .secretUnlessNextInLadder
+ )
+ }
+
+ 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() {
+ if let durationText = Self.durationFmt.string(from: DateComponents(component: input.component, value: input.count)) {
+ 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: .secretUnlessNextInLadder
+ )
+ }
+ }
+ })
+ }
+}
+
+
+extension Achievement {
+ /// source: https://en.wikipedia.org/wiki/Wedding_anniversary#Traditional_anniversary_gifts
+ private static let anniversaryNames: [LocalizedStringResource] = [
+ "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(.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 {
+ fileprivate init(component: Calendar.Component, value: Int) {
+ self.init()
+ setValue(value, for: component)
+ }
+}
diff --git a/MyHeartCounts/Heart Health Dashboard/Achievements/Achievement.swift b/MyHeartCounts/Heart Health Dashboard/Achievements/LegacyAchievement.swift
similarity index 95%
rename from MyHeartCounts/Heart Health Dashboard/Achievements/Achievement.swift
rename to MyHeartCounts/Heart Health Dashboard/Achievements/LegacyAchievement.swift
index 5f44f5e9..dea53e06 100644
--- a/MyHeartCounts/Heart Health Dashboard/Achievements/Achievement.swift
+++ b/MyHeartCounts/Heart Health Dashboard/Achievements/LegacyAchievement.swift
@@ -7,12 +7,13 @@
//
// periphery:ignore:all - currently unused but supported (we keep it in case we want to go down this route in the future)
+// swiftlint:disable all
import Foundation
import HealthKit
-struct Achievement: Hashable, Codable, Sendable {
+struct LegacyAchievement: Hashable, Codable, Sendable {
enum Goal: Hashable, Codable, Sendable {
enum ReachLevelTarget: Hashable, Codable, Sendable { // swiftlint:disable:this type_contents_order
case absolute(Double)
@@ -32,7 +33,7 @@ struct Achievement: Hashable, Codable, Sendable {
}
-extension Achievement {
+extension LegacyAchievement {
enum ResolvedGoal: Hashable, Sendable {
/// The goal is achieved by reaching (or surpassing) a specified quantity.
/// - parameter quantity: The target quantity
diff --git a/MyHeartCounts/Heart Health Dashboard/CircularProgressView.swift b/MyHeartCounts/Heart Health Dashboard/CircularProgressView.swift
index 6aee676f..585db26a 100644
--- a/MyHeartCounts/Heart Health Dashboard/CircularProgressView.swift
+++ b/MyHeartCounts/Heart Health Dashboard/CircularProgressView.swift
@@ -6,20 +6,27 @@
// SPDX-License-Identifier: MIT
//
+// swiftlint:disable file_types_order
+
import Foundation
import SwiftUI
-struct CircularProgressView: View {
+/// A circular progress view.
+///
+/// Content placed inside the progress view (via ``init(_:lineWidth:content:)``) is automatically centered and constrained to the largest square that fits within the ring.
+///
+/// Use SwiftUI's `.tint(_:)` view modifier to set the color of the progress indicator.
+struct CircularProgressView: View {
private let value: Double
private let lineWidth: Double
- private let showProgressAsLabel: Bool
+ private let content: Content
var body: some View {
ZStack {
Circle()
.stroke(
- .tint.opacity(0.5),
+ .gray.tertiary,
lineWidth: lineWidth
)
Circle()
@@ -33,17 +40,54 @@ struct CircularProgressView: View {
)
.rotationEffect(.degrees(-90))
.animation(.easeOut, value: value)
- if showProgressAsLabel {
- Text(String(format: "%.0f%%", value * 100))
- .monospacedDigit()
+ if !(content is EmptyView) {
+ // Constrain the content to the inscribed square of the ring's interior (side = diameter / √2),
+ // so it sits fully within the circle rather than under the stroke, then re-center it.
+ GeometryReader { geometry in
+ let diameter = min(geometry.size.width, geometry.size.height)
+ let side = max(0, (diameter - lineWidth) / 2.squareRoot())
+ content
+ .frame(width: side, height: side)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
}
}
- .padding(EdgeInsets(horizontal: lineWidth / 2, vertical: lineWidth / 2))
+ .padding(lineWidth / 2)
}
-
- init(_ value: some BinaryFloatingPoint, lineWidth: Double = 5, showProgressAsLabel: Bool = false) {
+
+ init(
+ _ value: some BinaryFloatingPoint,
+ lineWidth: Double = 5,
+ @ViewBuilder content: () -> Content = { EmptyView() }
+ ) {
self.value = Double(value)
self.lineWidth = lineWidth
- self.showProgressAsLabel = showProgressAsLabel
+ self.content = content()
+ }
+}
+
+
+extension CircularProgressView where Content == CircularProgressPercentLabel {
+ /// Creates a circular progress view that displays the progress as a percentage in its center.
+ ///
+ /// The label automatically scales down to fit within the circle.
+ init(percent value: some BinaryFloatingPoint, lineWidth: Double = 5) {
+ let value = Double(value)
+ self.init(value, lineWidth: lineWidth) {
+ CircularProgressPercentLabel(value: value)
+ }
+ }
+}
+
+
+/// The percentage label used by ``CircularProgressView/init(percent:lineWidth:)``.
+struct CircularProgressPercentLabel: View {
+ let value: Double
+
+ var body: some View {
+ Text(value, format: .percent.precision(.fractionLength(0)))
+ .monospacedDigit()
+ .lineLimit(1)
+ .minimumScaleFactor(0.1)
}
}
diff --git a/MyHeartCounts/Heart Health Dashboard/Health Dashboard/DashboardChrome.swift b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/DashboardChrome.swift
new file mode 100644
index 00000000..3c59e246
--- /dev/null
+++ b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/DashboardChrome.swift
@@ -0,0 +1,87 @@
+//
+// 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 file_types_order
+
+import SFSafeSymbols
+import SwiftUI
+import class UIKit.UIColor
+
+
+extension ShapeStyle where Self == Color {
+ /// The fill used for tiles in a dashboard-style layout (e.g. the Heart Health Dashboard, the Participation Stats view).
+ ///
+ /// Resolves to white in light mode and a subtle dark gray in dark mode, ensuring the tile reads
+ /// as distinct from the surrounding `dashboardBackground` in both color schemes.
+ static var dashboardTile: Color {
+ Color(uiColor: .secondarySystemGroupedBackground)
+ }
+
+ /// The outer background for a dashboard-style layout, behind the tiles.
+ static var dashboardBackground: Color {
+ Color(uiColor: .systemGroupedBackground)
+ }
+}
+
+
+extension View {
+ /// Applies the standard dashboard-tile chrome: a rounded-rect fill in ``ShapeStyle/dashboardTile``.
+ ///
+ /// Use this instead of `.background(.background, in: ...)` for any tile that lives on a
+ /// ``ShapeStyle/dashboardBackground``-colored surface — `.background` (i.e. `systemBackground`) and
+ /// `systemGroupedBackground` are visually identical in dark mode, so a tile using `.background` will
+ /// disappear into the dashboard background.
+ func dashboardTileBackground(cornerRadius: CGFloat = 14) -> some View {
+ background(.dashboardTile, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
+ }
+}
+
+
+/// A `Section` containing a two-column `LazyVGrid` of tile views.
+///
+/// Use inside a `Form` (or `List`). The contained grid renders edge-to-edge by clearing the section row's
+/// insets and background; individual tiles inside the `tiles` builder are responsible for their own chrome
+/// (typically via ``SwiftUICore/View/dashboardTileBackground(cornerRadius:)``).
+struct TiledSection: View {
+ private let title: LocalizedStringResource
+ private let symbol: SFSymbol?
+ private let tiles: Tiles
+
+ var body: some View {
+ Section {
+ LazyVGrid(
+ columns: [
+ GridItem(.flexible(), spacing: 12, alignment: .top),
+ GridItem(.flexible(), spacing: 12, alignment: .top)
+ ],
+ spacing: 12
+ ) {
+ tiles
+ }
+ .listRowInsets(.zero)
+ .listRowBackground(Color.clear)
+ } header: {
+ if let symbol {
+ Label {
+ Text(title)
+ } icon: {
+ Image(systemSymbol: symbol)
+ .accessibilityHidden(true)
+ }
+ } else {
+ Text(title)
+ }
+ }
+ }
+
+ init(_ title: LocalizedStringResource, symbol: SFSymbol? = nil, @ViewBuilder tiles: () -> Tiles) {
+ self.title = title
+ self.symbol = symbol
+ self.tiles = tiles()
+ }
+}
diff --git a/MyHeartCounts/Heart Health Dashboard/Health Dashboard/DefaultHealthDashboardTile.swift b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/DefaultHealthDashboardTile.swift
index 6cb87932..7c325e84 100644
--- a/MyHeartCounts/Heart Health Dashboard/Health Dashboard/DefaultHealthDashboardTile.swift
+++ b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/DefaultHealthDashboardTile.swift
@@ -132,7 +132,7 @@ private struct TileImpl: View {
private let timeRange: HealthKitQueryTimeRange
private let style: HealthDashboardLayout.Style
private let accessory: DefaultHealthDashboardTile.Accessory
- private let goal: Achievement.ResolvedGoal?
+ private let goal: LegacyAchievement.ResolvedGoal?
var body: some View {
HealthDashboardTile(title: sampleType.displayTitle) {
@@ -191,7 +191,7 @@ private struct TileImpl: View {
timeRange: HealthKitQueryTimeRange,
style: HealthDashboardLayout.Style,
accessory: DefaultHealthDashboardTile.Accessory,
- goal: Achievement.ResolvedGoal?
+ goal: LegacyAchievement.ResolvedGoal?
) {
self.sampleType = sampleType
self.samples = samples
diff --git a/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboard.swift b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboard.swift
index 4026056a..ce1f4668 100644
--- a/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboard.swift
+++ b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboard.swift
@@ -22,7 +22,7 @@ extension Gradient {
enum HealthDashboardConstants {}
-typealias HealthDashboardGoalProvider = @Sendable @MainActor (QuantitySample.SampleType) -> Achievement.ResolvedGoal?
+typealias HealthDashboardGoalProvider = @Sendable @MainActor (QuantitySample.SampleType) -> LegacyAchievement.ResolvedGoal?
extension EnvironmentValues {
@Entry var healthDashboardGoalProvider: HealthDashboardGoalProvider?
diff --git a/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboardTile.swift b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboardTile.swift
index ecc8bdce..f75a1d83 100644
--- a/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboardTile.swift
+++ b/MyHeartCounts/Heart Health Dashboard/Health Dashboard/HealthDashboardTile.swift
@@ -53,7 +53,7 @@ struct HealthDashboardTile: View {
.if(!isRecentValuesViewInDetailedStatsSheet) {
$0
.padding(EdgeInsets(top: 0, leading: Self.insets.leading, bottom: Self.insets.bottom, trailing: Self.insets.trailing))
- .background(.background)
+ .background(.dashboardTile)
}
.frame(minHeight: 129)
}
diff --git a/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboard.swift b/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboard.swift
index c8390c27..518fa682 100644
--- a/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboard.swift
+++ b/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboard.swift
@@ -181,9 +181,12 @@ struct HeartHealthDashboard: View {
) {
VStack(spacing: 0) {
ScoreResultGauge(scoreResult: score)
- .frame(width: 80, height: 80)
- .padding(.top, 4)
- .padding(.bottom, -8)
+ .frame(width: 80, height: 80)
+ .padding(.top, 4)
+ .padding(.bottom, -8)
+// Text(score.title)
+// .font(.footnote)
+// .foregroundStyle(.secondary)
if let timeRange = score.timeRange, score.scoreAvailable {
Text(timeRange.upperBound.shortDescription())
.font(.footnote)
diff --git a/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboardTab.swift b/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboardTab.swift
index aa5a9a45..c850a5b7 100644
--- a/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboardTab.swift
+++ b/MyHeartCounts/Heart Health Dashboard/HeartHealthDashboardTab.swift
@@ -30,6 +30,12 @@ struct HeartHealthDashboardTab: RootViewTab {
HeartHealthDashboard()
.navigationTitle("MHC Heart Health")
.toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ ParticipationStatsButton()
+ }
+ if #available(iOS 26, *) {
+ ToolbarSpacer(.fixed, placement: .topBarTrailing)
+ }
accountToolbarItem
}
}
diff --git a/MyHeartCounts/Heart Health Dashboard/Score.swift b/MyHeartCounts/Heart Health Dashboard/Score.swift
index e9dae1cc..e1e63a6d 100644
--- a/MyHeartCounts/Heart Health Dashboard/Score.swift
+++ b/MyHeartCounts/Heart Health Dashboard/Score.swift
@@ -191,7 +191,7 @@ final class ScoreDefinition: Hashable, Sendable, AnyObjectBasedDefaultImpls {
struct ScoreResult: Hashable, Sendable {
/// a user-visible title that explains the kind of this score result, e.g. "Most Recent Sample" or "Daily Average"
- let title: LocalizedStringResource
+ let title: LocalizedStringResource // rename?!!
let definition: ScoreDefinition
let sampleType: MHCSampleType
@MakeHashable var inputValue: (any Hashable & Sendable)?
diff --git a/MyHeartCounts/Home Tab/HomeTab.swift b/MyHeartCounts/Home Tab/HomeTab.swift
index ab4e2364..c04d80f2 100644
--- a/MyHeartCounts/Home Tab/HomeTab.swift
+++ b/MyHeartCounts/Home Tab/HomeTab.swift
@@ -41,6 +41,9 @@ struct HomeTab: RootViewTab {
.navigationTitle("My Heart Counts")
.toolbar {
accountToolbarItem
+ ToolbarItem {
+ ParticipationStatsButton()
+ }
}
}
}
diff --git a/MyHeartCounts/Modules/EnvironmentTracking.swift b/MyHeartCounts/Modules/EnvironmentTracking.swift
index 08fd05d4..658c8c8f 100644
--- a/MyHeartCounts/Modules/EnvironmentTracking.swift
+++ b/MyHeartCounts/Modules/EnvironmentTracking.swift
@@ -124,6 +124,10 @@ extension EnvironmentTracking {
guard let account else {
return
}
+ // since these updates typically happen very early into the launch of the app, the account details
+ // might not be fully populated & available yet; we need to wait until they are available in order
+ // to work around https://github.com/SchmiedmayerLab/MyHeartCounts-iOS/issues/169
+ await account.waitForAccountDetailsReady()
let current = await account.details
var updated = AccountDetails()
var removed = AccountDetails()
diff --git a/MyHeartCounts/Modules/NotificationsManager.swift b/MyHeartCounts/Modules/NotificationsManager.swift
index 55946435..d167fa30 100644
--- a/MyHeartCounts/Modules/NotificationsManager.swift
+++ b/MyHeartCounts/Modules/NotificationsManager.swift
@@ -115,6 +115,10 @@ extension NotificationsManager: NotificationTokenHandler {
guard let account else {
return
}
+ // since these updates typically happen very early into the launch of the app, the account details
+ // might not be fully populated & available yet; we need to wait until they are available in order
+ // to work around https://github.com/SchmiedmayerLab/MyHeartCounts-iOS/issues/169
+ await account.waitForAccountDetailsReady()
var updatedDetails = AccountDetails()
var removedDetails = AccountDetails()
switch (account.details?.fcmToken, newToken) {
diff --git a/MyHeartCounts/MyHeartCountsDelegate.swift b/MyHeartCounts/MyHeartCountsDelegate.swift
index eb0db438..5486d73b 100644
--- a/MyHeartCounts/MyHeartCountsDelegate.swift
+++ b/MyHeartCounts/MyHeartCountsDelegate.swift
@@ -66,6 +66,8 @@ final class MyHeartCountsDelegate: SpeziAppDelegate {
NotificationsManager()
AccountFeatureFlags()
DemoSetup()
+ ParticipationStatsProvider()
+ AchievementsManager()
}
}
}
diff --git a/MyHeartCounts/MyHeartCountsStandard+QuestionnaireResponse.swift b/MyHeartCounts/MyHeartCountsStandard+QuestionnaireResponse.swift
index 2c5727b0..03adfe3b 100644
--- a/MyHeartCounts/MyHeartCountsStandard+QuestionnaireResponse.swift
+++ b/MyHeartCounts/MyHeartCountsStandard+QuestionnaireResponse.swift
@@ -34,6 +34,10 @@ extension MyHeartCountsStandard {
logger.error("Could not store questionnaire response: \(error)")
}
await parseIfApplicable(response)
+ await achievementsManager.record(
+ .completeQuestionnaire,
+ timestamp: (try? response.authored?.value?.asNSDate()) ?? .now
+ )
}
diff --git a/MyHeartCounts/MyHeartCountsStandard.swift b/MyHeartCounts/MyHeartCountsStandard.swift
index 1c35f577..9e4b52e1 100644
--- a/MyHeartCounts/MyHeartCountsStandard.swift
+++ b/MyHeartCounts/MyHeartCountsStandard.swift
@@ -49,6 +49,7 @@ actor MyHeartCountsStandard: Standard, EnvironmentAccessible, AccountNotifyConst
@Dependency(ClinicalRecordPermissions.self) private var clinicalRecordPermissions
@Dependency(NotificationsManager.self) private var notificationsManager
@Dependency(AppState.self) private var appState
+ @Dependency(AchievementsManager.self) var achievementsManager
@Application(\.registerRemoteNotifications) private var registerRemoteNotifications
// swiftlint:disable attributes
@@ -80,10 +81,8 @@ actor MyHeartCountsStandard: Standard, EnvironmentAccessible, AccountNotifyConst
}
func enroll(in studyBundle: StudyBundle) async throws {
- guard let account, let studyManager else {
- throw NSError(domain: "edu.stanford.MyHeartCounts", code: 0, userInfo: [
- NSLocalizedDescriptionKey: "Missing Account / StudyManager"
- ])
+ guard let account, await account.signedIn, let studyManager else {
+ throw NSError(mhcErrorCode: .unspecified, localizedDescription: "Missing Account / StudyManager")
}
do {
if let enrollmentDate = await account.details?.dateOfEnrollment {
@@ -102,8 +101,15 @@ actor MyHeartCountsStandard: Standard, EnvironmentAccessible, AccountNotifyConst
}
}
LocalPreferencesStore.standard[.studyActivationDate] = .now
- _Concurrency.Task(priority: .background) {
+ Swift::Task(priority: .background) {
historicalUploadManager.startAutomaticExportingIfNeeded()
+ // the .associatedAccount event below will already have called this, but it likely will have failed,
+ // since there was an account logged in, but the enrollment didn't exist yet at that point.
+ // so we call it again after creating the enrollment.
+ // this only is relevant if the user wasn't logged in and enrolled when the app was launched.
+ // all subsequent launches will go only through the `associateWithAccount()` call below, and will work correctly
+ // bc both the account and the enrollment will exist in these cases.
+ try await achievementsManager.associateWithAccount()
}
await Self._updateCurrentEnrollmentInfo(studyManager)
} catch StudyManager.StudyEnrollmentError.alreadyEnrolledInNewerStudyRevision {
@@ -120,23 +126,30 @@ actor MyHeartCountsStandard: Standard, EnvironmentAccessible, AccountNotifyConst
switch event {
case .associatedAccount(let details):
logger.notice("account was associated (account id: \(details.accountId))")
- _Concurrency.Task {
- await environmentTracking?.triggerAll()
- _ = try? await registerRemoteNotifications()
+ Swift::Task {
+ async let updateEnvTracking = environmentTracking?.triggerAll()
+ async let registerNotifications = try? registerRemoteNotifications()
+ async let syncAchievements = try? achievementsManager.associateWithAccount()
+ _ = await (updateEnvTracking, registerNotifications, syncAchievements)
}
case .deletingAccount:
logger.notice("account is being deleted")
+ // not really doing anything in here since each deletion should also trigger an account disassociation, which will then be handled below
case .disassociatingAccount:
logger.notice("account did disassociate")
try? await performLogoutCleanup(context: .explicitUserLogoutEvent)
- case .detailsChanged:
+ case let .detailsChanged(old, new):
+ logger.notice("Account Details Changed:\n\(new.debugDescOfDifference(from: old))")
break
}
}
func willLogOut(_ details: AccountDetails) async {
logger.notice("account is being logged out")
- try? await notificationsManager.setFCMToken(nil)
+ async let updateFCMToken = try? notificationsManager.setFCMToken(nil)
+ async let syncAchievements = try? achievementsManager.syncNow()
+ _ = await (updateFCMToken, syncAchievements)
+ await achievementsManager.disassociateFromAccount()
}
}
diff --git a/MyHeartCounts/Resources/Localizable.xcstrings b/MyHeartCounts/Resources/Localizable.xcstrings
index bf8fdcd6..4e8ed134 100644
--- a/MyHeartCounts/Resources/Localizable.xcstrings
+++ b/MyHeartCounts/Resources/Localizable.xcstrings
@@ -4,6 +4,9 @@
"" : {
"shouldTranslate" : false
},
+ " " : {
+ "shouldTranslate" : false
+ },
" 0" : {
"shouldTranslate" : false
},
@@ -52,7 +55,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%1$@ – %2$@ (peso normal)"
+ "value" : "%1$@ – %2$@ (Peso normal)"
}
}
}
@@ -162,7 +165,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%1$@ – %2$@ (bajo peso)"
+ "value" : "%1$@ – %2$@ (Bajo peso)"
+ }
+ }
+ }
+ },
+ "%@ / %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ / %2$@"
}
}
}
@@ -181,6 +194,26 @@
"%@ +" : {
"shouldTranslate" : false
},
+ "%@ laps around a running track" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%1$@ vueltas a una pista de atletismo"
+ }
+ }
+ }
+ },
+ "%@ lengths of the Golden Gate Bridge" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%1$@ veces la longitud del puente Golden Gate"
+ }
+ }
+ }
+ },
"%@-Minute Run Test" : {
"localizations" : {
"en-GB" : {
@@ -192,7 +225,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Prueba de Carrera de %@ Minutos"
+ "value" : "Prueba de carrera de %@ minutos"
}
}
}
@@ -208,7 +241,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Prueba de Caminata de %@ Minutos"
+ "value" : "Prueba de caminata de %@ minutos"
}
}
}
@@ -477,7 +510,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "< %@ (delgadez severa)"
+ "value" : "< %@ (Delgadez severa)"
}
}
}
@@ -792,7 +825,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aneurismo aórtico abdominal"
+ "value" : "Aneurisma aórtico abdominal"
}
}
}
@@ -813,6 +846,16 @@
}
}
},
+ "about %@ trips around the Earth" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "unas %1$@ vueltas alrededor de la Tierra"
+ }
+ }
+ }
+ },
"About the My Heart Counts Study" : {
"localizations" : {
"en-GB" : {
@@ -895,6 +938,16 @@
}
}
},
+ "Achievements" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Logros"
+ }
+ }
+ }
+ },
"Active" : {
"localizations" : {
"en-GB" : {
@@ -911,6 +964,16 @@
}
}
},
+ "Active Energy" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Energía activa"
+ }
+ }
+ }
+ },
"Actively smoking" : {
"localizations" : {
"en-GB" : {
@@ -922,7 +985,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Fuma frequentemente"
+ "value" : "Fuma actualmente"
}
}
}
@@ -1185,7 +1248,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¿Tienes %lld años o más?"
+ "value" : "¿Tiene %lld años o más?"
}
}
}
@@ -1201,7 +1264,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¿Eres hispano/latino?"
+ "value" : "¿Es usted hispano/latino?"
}
}
}
@@ -1211,7 +1274,7 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Are you sure you want to withdraw from the My Heart Counts study?\nYou can re-enroll later if you choose."
+ "value" : "Are you sure you want to withdraw from the My Heart Counts study?\nYou can re-enrol later if you choose."
}
},
"es" : {
@@ -1270,6 +1333,16 @@
}
}
},
+ "Articles Read" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Artículos leídos"
+ }
+ }
+ }
+ },
"Atrial fibrillation (Afib)" : {
"localizations" : {
"en-GB" : {
@@ -1286,6 +1359,16 @@
}
}
},
+ "Average" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Promedio"
+ }
+ }
+ }
+ },
"Bachelor" : {
"localizations" : {
"en-GB" : {
@@ -1302,6 +1385,16 @@
}
}
},
+ "Best Step Day" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mejor día de pasos"
+ }
+ }
+ }
+ },
"Biological Sex at Birth" : {
"localizations" : {
"en-GB" : {
@@ -1388,6 +1481,26 @@
}
}
},
+ "bpm" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "bpm"
+ }
+ }
+ }
+ },
+ "Bronze Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de bronce"
+ }
+ }
+ }
+ },
"California" : {
"localizations" : {
"en-GB" : {
@@ -1436,6 +1549,16 @@
}
}
},
+ "Cardio Connoisseur" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Experto del corazón"
+ }
+ }
+ }
+ },
"Carotid Artery Blockage/Stenosis" : {
"localizations" : {
"en-GB" : {
@@ -1606,6 +1729,16 @@
}
}
},
+ "Complete your first questionnaire" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Complete su primer cuestionario"
+ }
+ }
+ }
+ },
"COMPREHENSION_QUESTION_1" : {
"localizations" : {
"en" : {
@@ -1623,7 +1756,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Si en algún momento durante este estudio no me siento bien, debo ponerme en contacto inmediatamente con mi médico."
+ "value" : "Si en algún momento durante este estudio no me siento bien, debo ponerme en contacto inmediatamente con mi proveedor de atención médica."
}
}
}
@@ -1689,7 +1822,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Por favor, responde unas preguntas para asegurarnos de que entiende el formulario de consentimiento."
+ "value" : "Por favor, responda unas preguntas para asegurarnos de que entiende el formulario de consentimiento."
}
}
}
@@ -1711,7 +1844,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cuestionario de Consentimiento"
+ "value" : "Cuestionario de consentimiento"
}
}
}
@@ -1860,6 +1993,36 @@
}
}
},
+ "Cooper Trooper" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Veterano del test de Cooper"
+ }
+ }
+ }
+ },
+ "Copper Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de cobre"
+ }
+ }
+ }
+ },
+ "Coral Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de coral"
+ }
+ }
+ }
+ },
"Coronary Artery Disease" : {
"localizations" : {
"en-GB" : {
@@ -1908,6 +2071,52 @@
}
}
},
+ "Cotton Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de algodón"
+ }
+ }
+ }
+ },
+ "Cross %@ of study enrollment" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cross %@ of study enrolment"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Alcance %@ de inscripción en el estudio"
+ }
+ }
+ }
+ },
+ "Crystal Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de cristal"
+ }
+ }
+ }
+ },
+ "Current Streak" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racha actual"
+ }
+ }
+ }
+ },
"Cycling" : {
"localizations" : {
"en-GB" : {
@@ -2009,7 +2218,7 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Date of Enrollment"
+ "value" : "Date of Enrolment"
}
},
"es" : {
@@ -2020,6 +2229,26 @@
}
}
},
+ "day" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "día"
+ }
+ }
+ }
+ },
+ "days" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "días"
+ }
+ }
+ }
+ },
"Debug" : {
"localizations" : {
"en-GB" : {
@@ -2069,7 +2298,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Programe un recordatorio para levantarse y caminar durante cinco minutos cada hora esta mañana. El movimiento ligero puede mejorar su estado de ánimo y ayudarlo a mantenerse encaminado."
+ "value" : "Programe un recordatorio para levantarse y caminar durante cinco minutos cada hora esta mañana. El movimiento ligero puede mejorar su estado de ánimo y ayudarlo a mantenerse en el buen camino."
}
}
}
@@ -2107,7 +2336,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Demografía"
+ "value" : "Datos demográficos"
}
}
}
@@ -2128,6 +2357,16 @@
}
}
},
+ "Diamond Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de diamante"
+ }
+ }
+ }
+ },
"Did Opt In to Trial" : {
"localizations" : {
"en-GB" : {
@@ -2139,7 +2378,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Se inscribió en el ensayo"
+ "value" : "Optó por participar en el ensayo"
}
}
}
@@ -2187,7 +2426,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Miocardiopatía Dilatada "
+ "value" : "Miocardiopatía dilatada (MCD)"
}
}
}
@@ -2315,7 +2554,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hecho"
+ "value" : "Listo"
}
}
}
@@ -2353,7 +2592,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "## Realizar un ECG con su Apple Watch\n\nTu Apple Watch puede registrar un electrocardiograma (ECG) para verificar el ritmo de tu corazón. Esta función te ayuda a capturar la actividad eléctrica de tu corazón en solo 30 segundos.\n\nPara realizar un ECG, asegúrete de que tu reloj esté bien ajustado en tu muñeca. Abre la aplicación ECG en tu Apple Watch y descansa los brazos sobre una mesa o en tu regazo. Coloca tu dedo en la Corona Digital sin presionarla, y mantéte inmóvil durante 30 segundos mientras se completa la captura.\n\nLa aplicación clasificará tu ritmo cardíaco como Ritmo Sinusal, Fibrilación Auricular, Frecuencia Cardíaca Baja/Alta o Inconcluso. Los resultados se guardan en la aplicación Salud en su iPhone para que los revises o compartas con tu médico.\n\nRecuerde: Esta función no monitorea continuamente la Fibrilación Auricular y no puede detectar ataques cardíacos. Si experimenta dolor en el pecho, presión u otros síntomas, llame a los servicios de emergencia inmediatamente."
+ "value" : "## Realizar un ECG con su Apple Watch\n\nSu Apple Watch puede registrar un electrocardiograma (ECG) para verificar el ritmo de su corazón. Esta función le ayuda a capturar la actividad eléctrica de su corazón en solo 30 segundos.\n\nPara realizar un ECG, asegúrese de que su reloj esté bien ajustado en su muñeca. Abra la aplicación ECG en su Apple Watch y descanse los brazos sobre una mesa o en su regazo. Coloque el dedo en la Corona Digital sin presionarla y manténgase inmóvil durante 30 segundos mientras se completa la captura.\n\nLa aplicación clasificará su ritmo cardíaco como Ritmo Sinusal, Fibrilación Auricular, Frecuencia Cardíaca Baja/Alta o Inconcluso. Los resultados se guardan en la aplicación Salud en su iPhone para que los revise o los comparta con su médico.\n\nRecuerde: Esta función no monitorea continuamente la fibrilación auricular y no puede detectar ataques cardíacos. Si experimenta dolor en el pecho, presión u otros síntomas, llame a los servicios de emergencia de inmediato."
+ }
+ }
+ }
+ },
+ "ECGs Recorded" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ECG registrados"
}
}
}
@@ -2423,7 +2672,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Antes de comenzar, te haremos unas preguntas rápidas para asegurarnos de que el estudio sea adecuado para tí."
+ "value" : "Antes de comenzar, le haremos unas preguntas rápidas para asegurarnos de que el estudio sea adecuado para usted."
}
}
}
@@ -2466,6 +2715,16 @@
}
}
},
+ "Emerald Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de esmeralda"
+ }
+ }
+ }
+ },
"Enable" : {
"localizations" : {
"en-GB" : {
@@ -2547,7 +2806,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Al habilitar SensorKit, se obtienen datos más precisos y detallados como el electrocardiograma (ECG), contribuyéndo al avance de la investigación cardiovascular"
+ "value" : "Al habilitar SensorKit, se obtienen datos más precisos y detallados, como el electrocardiograma (ECG), para contribuir al avance de la investigación cardiovascular."
}
}
}
@@ -2568,6 +2827,16 @@
}
}
},
+ "Engagement" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Participación"
+ }
+ }
+ }
+ },
"England (%@)" : {
"localizations" : {
"en-GB" : {
@@ -2600,6 +2869,32 @@
}
}
},
+ "Enroll into the study" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enrol into the study"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inscríbase en el estudio"
+ }
+ }
+ }
+ },
+ "Enrolled since" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inscrito desde"
+ }
+ }
+ }
+ },
"Enrolled since: %@" : {
"localizations" : {
"en-GB" : {
@@ -2659,7 +2954,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingresar IMC (Índice de Masa Corporal)"
+ "value" : "Ingresar IMC"
}
}
}
@@ -2696,6 +2991,26 @@
}
}
},
+ "Estimate" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Estimación"
+ }
+ }
+ }
+ },
+ "Exercise Time" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tiempo de ejercicio"
+ }
+ }
+ }
+ },
"EXERCISE_MINUTES_SCORE_EXPLAINER" : {
"localizations" : {
"en" : {
@@ -2739,7 +3054,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Falló al Descargar Información del Estudio"
+ "value" : "No se pudo descargar la información del estudio"
}
}
}
@@ -2755,7 +3070,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Falló al Cargar el Estudio en la Aplicación"
+ "value" : "No se pudo cargar el estudio en la aplicación"
}
}
}
@@ -2771,7 +3086,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Falló al procesar las Sesiones de Sueño"
+ "value" : "No se pudieron procesar las sesiones de sueño"
}
}
}
@@ -2835,7 +3150,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Evaluar producto"
+ "value" : "Comentarios"
}
}
}
@@ -2857,7 +3172,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tus comentarios nos ayudan a mejorar My Heart Counts y te los agradecemos de corazón."
+ "value" : "Sus comentarios nos ayudan a mejorar My Heart Counts y todos los comentarios son muy apreciados.\n\nLos comentarios que comparta con nosotros se asociarán a su dirección de correo electrónico.\nEsto nos permite contactarle si es necesario."
}
}
}
@@ -2873,7 +3188,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¡Chequea más tarde!"
+ "value" : "¡Puede volver a consultar más tarde!"
}
}
}
@@ -2911,7 +3226,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tras la semana inicial, comenzarás un ensayo de 2 semanas de duración que compara diferentes tipos de recomendaciones. Recibirás notificaciones cuando comience esta fase."
+ "value" : "Tras la semana inicial, comenzará un ensayo de 2 semanas de duración que compara diferentes tipos de asesoramiento sobre actividad física. Recibirá notificaciones cuando comience esta fase."
}
}
}
@@ -2927,13 +3242,13 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "The app will begin collecting activity data from your phone and any connected wearable devices you’ve authorized."
+ "value" : "The app will begin collecting activity data from your phone and any connected wearable devices you’ve authorised."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "La aplicación comenzará a recopilar datos de actividad de tu teléfono y de cualquier dispositivo conectado y autorizado."
+ "value" : "La aplicación comenzará a recopilar datos de actividad de su teléfono y de cualquier dispositivo wearable conectado que haya autorizado."
}
}
}
@@ -2949,13 +3264,13 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Each day, you’ll see a personalized list of tasks in your dashboard. These typically take just 5-10 minutes to complete."
+ "value" : "Each day, you’ll see a personalised list of tasks in your dashboard. These typically take just 5-10 minutes to complete."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cada día verás una lista personalizada de tareas en su panel. Por lo general, toman solo 5 a 10 minutos en completarse."
+ "value" : "Cada día verá una lista personalizada de tareas en su panel. Por lo general, toman solo de 5 a 10 minutos en completarse."
}
}
}
@@ -2977,7 +3292,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Al unirte a este estudio, estás contribuyendo a una investigación importante que puede ayudar a mejorar nuestra comprensión de la salud del corazón en personas de diversas comunidades."
+ "value" : "Al unirse a este estudio, está contribuyendo a una investigación importante que puede ayudar a mejorar nuestra comprensión de la salud del corazón en personas de diversas comunidades."
}
}
}
@@ -2999,7 +3314,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Durante los próximos 7 días, se te guiará a través de varias actividades breves:\n- Completar encuestas sobre tu salud, estilo de vida y bienestar.\n- Registrar tu actividad física a través de la aplicación.\n- Realizar evaluaciones de aptitud física cuando sea apropiado para tu estado de salud."
+ "value" : "Durante los próximos 7 días, se le guiará a través de varias actividades breves:\n- Completar encuestas sobre su salud, estilo de vida y bienestar.\n- Registrar su actividad física inicial a través de la aplicación.\n- Realizar evaluaciones de aptitud física óptimas cuando sea apropiado para su estado de salud."
}
}
}
@@ -3052,9 +3367,19 @@
}
}
},
- "Florida" : {
+ "Flights Climbed" : {
"localizations" : {
- "en-GB" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tramos subidos"
+ }
+ }
+ }
+ },
+ "Florida" : {
+ "localizations" : {
+ "en-GB" : {
"stringUnit" : {
"state" : "translated",
"value" : "Florida"
@@ -3068,6 +3393,16 @@
}
}
},
+ "Flower Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de flores"
+ }
+ }
+ }
+ },
"For more information and updates, visit myheartcounts.stanford.edu" : {
"localizations" : {
"en-GB" : {
@@ -3084,6 +3419,16 @@
}
}
},
+ "Fun Facts" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Datos curiosos"
+ }
+ }
+ }
+ },
"Gender Identity" : {
"localizations" : {
"en-GB" : {
@@ -3139,7 +3484,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Varón"
+ "value" : "Hombre"
}
}
}
@@ -3161,7 +3506,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "De género fluído"
+ "value" : "Variante de género / no conforme"
}
}
}
@@ -3232,6 +3577,16 @@
}
}
},
+ "General" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "General"
+ }
+ }
+ }
+ },
"Georgia" : {
"localizations" : {
"en-GB" : {
@@ -3248,6 +3603,16 @@
}
}
},
+ "Giga Streaker" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racha giga"
+ }
+ }
+ }
+ },
"Go to next field" : {
"localizations" : {
"en-GB" : {
@@ -3280,6 +3645,16 @@
}
}
},
+ "Gold Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de oro"
+ }
+ }
+ }
+ },
"Grade School" : {
"localizations" : {
"en-GB" : {
@@ -3344,6 +3719,16 @@
}
}
},
+ "Health" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Salud"
+ }
+ }
+ }
+ },
"Health Records" : {
"localizations" : {
"en-GB" : {
@@ -3360,6 +3745,16 @@
}
}
},
+ "Health Totals" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Totales de salud"
+ }
+ }
+ }
+ },
"HEALTH_RECORDS_NUDGE_SUBTITLE" : {
"localizations" : {
"en" : {
@@ -3377,7 +3772,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Permitir el acceso a datos clínicos y de historiales médicos ayuda a respaldar la investigación cardiovascular mediante información de salud más rica y longitudinal."
+ "value" : "Permitir el acceso a datos clínicos y de historiales médicos ayuda a respaldar la investigación cardiovascular mediante información de salud más completa y longitudinal."
}
}
}
@@ -3387,19 +3782,19 @@
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "If you agree, this study can securely access selected health information that you have already stored in Apple’s Health app through your healthcare providers. This information may include:\n- Laboratory results (for example, cholesterol, blood sugar, or other blood tests)\n- Clinical measurements such as blood pressure, heart rate, or body mass index recorded by your medical provider\n- Medications prescribed to you\nImmunizations (such as flu or COVID-19 vaccines)\n- Allergies, diagnoses, and conditions listed in your medical record\n- Procedures and clinical visits documented by your health system\n\nAccess will occur only if you grant permission in Apple Health. Your health records are encrypted and transferred securely. The research team will not receive your full medical chart, only the specific data types you approve. You may withdraw this permission at any time through the Health app settings.\nThe purpose of collecting this information is to combine it with other data you choose to share—such as physical activity, heart rate, or survey responses—to help researchers better understand how clinical history relates to heart health and to develop improved strategies for prevention and treatment."
+ "value" : "If you agree, this study can securely access selected health information that you have already stored in Apple’s Health app through your healthcare providers. This information may include:\n- Laboratory results (for example, cholesterol, blood sugar, or other blood tests)\n- Clinical measurements such as blood pressure, heart rate, or body mass index recorded by your medical provider\n- Medications prescribed to you\n- Immunizations (such as flu or COVID-19 vaccines)\n- Allergies, diagnoses, and conditions listed in your medical record\n- Procedures and clinical visits documented by your health system\n\nAccess will occur only if you grant permission in Apple Health. Your health records are encrypted and transferred securely. The research team will not receive your full medical chart, only the specific data types you approve. You may withdraw this permission at any time through the Health app settings.\n\nThe purpose of collecting this information is to combine it with other data you choose to share—such as physical activity, heart rate, or survey responses—to help researchers better understand how clinical history relates to heart health and to develop improved strategies for prevention and treatment."
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "If you agree, this study can securely access selected health information that you have already stored in Apple’s Health app through your healthcare providers. This information may include:\n- Laboratory results (for example, cholesterol, blood sugar, or other blood tests)\n- Clinical measurements such as blood pressure, heart rate, or body mass index recorded by your medical provider\n- Medications prescribed to you\nImmunizations (such as flu or COVID-19 vaccines)\n- Allergies, diagnoses, and conditions listed in your medical record\n- Procedures and clinical visits documented by your health system\n\nAccess will occur only if you grant permission in Apple Health. Your health records are encrypted and transferred securely. The research team will not receive your full medical chart, only the specific data types you approve. You may withdraw this permission at any time through the Health app settings.\nThe purpose of collecting this information is to combine it with other data you choose to share—such as physical activity, heart rate, or survey responses—to help researchers better understand how clinical history relates to heart health and to develop improved strategies for prevention and treatment."
+ "value" : "If you agree, this study can securely access selected health information that you have already stored in Apple’s Health app through your healthcare providers. This information may include:\n- Laboratory results (for example, cholesterol, blood sugar, or other blood tests)\n- Clinical measurements such as blood pressure, heart rate, or body mass index recorded by your medical provider\n- Medications prescribed to you\n- Immunisations (such as flu or COVID-19 vaccines)\n- Allergies, diagnoses, and conditions listed in your medical record\n- Procedures and clinical visits documented by your health system\n\nAccess will occur only if you grant permission in Apple Health. Your health records are encrypted and transferred securely. The research team will not receive your full medical chart, only the specific data types you approve. You may withdraw this permission at any time through the Health app settings.\n\nThe purpose of collecting this information is to combine it with other data you choose to share—such as physical activity, heart rate, or survey responses—to help researchers better understand how clinical history relates to heart health and to develop improved strategies for prevention and treatment."
}
},
"es" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Si estás de acuerdo, este estudio puede acceder de forma segura a determinada información de salud que ya tiene almacenada en la Health app de Apple. Esta información puede incluir:\n- Resultados de laboratorio (por ejemplo, colesterol, glucosa en sangre u otras pruebas de sangre)\n- Mediciones clínicas, como presión arterial, frecuencia cardíaca o índice de masa corporal, registradas por tu proveedor de atención médica\n- Medicamentos recetados\nInmunizaciones (como vacunas contra la gripe o la COVID-19)\n- Alergias, diagnósticos y afecciones consignados en tu expediente médico\n- Procedimientos y consultas clínicas documentados por tu sistema de salud\n \nEl acceso se realizará únicamente si otorgas el permiso. Tus registros de salud están cifrados y se transfieren de forma segura. El equipo de investigación no recibirá tu expediente médico completo, sino únicamente los tipos específicos de datos que apruebes. Puedes retirar este permiso en cualquier momento desde la configuración de la Health app.\n\nEl propósito de recopilar esta información es combinarla con otros datos que elijas compartir —como actividad física, frecuencia cardíaca o respuestas a encuestas— para ayudar a los investigadores a comprender mejor cómo el historial clínico se relaciona con la salud del corazón y desarrollar estrategias mejoradas de prevención y tratamiento."
+ "state" : "needs_review",
+ "value" : "Si estás de acuerdo, este estudio puede acceder de forma segura a determinada información de salud que ya tiene almacenada en la Health app de Apple. Esta información puede incluir:\n- Resultados de laboratorio (por ejemplo, colesterol, glucosa en sangre u otras pruebas de sangre)\n- Mediciones clínicas, como presión arterial, frecuencia cardíaca o índice de masa corporal, registradas por tu proveedor de atención médica\n- Medicamentos recetados\n- Inmunizaciones (como vacunas contra la gripe o la COVID-19)\n- Alergias, diagnósticos y afecciones consignados en tu expediente médico\n- Procedimientos y consultas clínicas documentados por tu sistema de salud\n\nEl acceso se realizará únicamente si otorgas el permiso. Tus registros de salud están cifrados y se transfieren de forma segura. El equipo de investigación no recibirá tu expediente médico completo, sino únicamente los tipos específicos de datos que apruebes. Puedes retirar este permiso en cualquier momento desde la configuración de la Health app.\n\nEl propósito de recopilar esta información es combinarla con otros datos que elijas compartir —como actividad física, frecuencia cardíaca o respuestas a encuestas— para ayudar a los investigadores a comprender mejor cómo el historial clínico se relaciona con la salud del corazón y desarrollar estrategias mejoradas de prevención y tratamiento."
}
}
}
@@ -3415,13 +3810,13 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "You may choose to share certain health records from your Apple Health app, such as lab results, medications, or immunizations. This information will be used for research to better understand heart health. Sharing is voluntary and can be withdrawn at any time."
+ "value" : "You may choose to share certain health records from your Apple Health app, such as lab results, medications, or immunisations. This information will be used for research to better understand heart health. Sharing is voluntary and can be withdrawn at any time."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Puedes elegir compartir ciertos registros de salud desde la app Salud de Apple, como resultados de laboratorio, medicamentos o inmunizaciones. Esta información se utilizará con fines de investigación para comprender mejor la salud del corazón. El acceso a estos datos es voluntario y puede revocarse en cualquier momento."
+ "value" : "Puede elegir compartir ciertos registros de salud desde la app Salud de Apple, como resultados de laboratorio, medicamentos o inmunizaciones. Esta información se utilizará con fines de investigación para comprender mejor la salud del corazón. Compartir es voluntario y puede revocarse en cualquier momento."
}
}
}
@@ -3513,7 +3908,7 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "My Heart Counts needs read-access to automatically collect data such as heart rate, step counts, active calories, sleep durations, etc. Further, granting write-access will give the best user experience with the application for dashboard features. To enhance research and optimize user experience, select **“Turn On All”**.\n\nWe are committed to protecting your privacy. Your data will be securely shared only among the My Heart Counts Research Team and our trusted research partners. Any data shared publicly will be thoroughly de-identified to ensure it cannot be traced back to you. Additionally, we require all external researchers to prove their qualifications and obtain approval before accessing any shared data for their studies.\n\nYou can revoke this access at any time."
+ "value" : "My Heart Counts needs read-access to automatically collect data such as heart rate, step counts, active calories, sleep durations, etc. Further, granting write-access will give the best user experience with the application for dashboard features. To enhance research and optimise user experience, select **“Turn On All”**.\n\nWe are committed to protecting your privacy. Your data will be securely shared only among the My Heart Counts Research Team and our trusted research partners. Any data shared publicly will be thoroughly de-identified to ensure it cannot be traced back to you. Additionally, we require all external researchers to prove their qualifications and obtain approval before accessing any shared data for their studies.\n\nYou can revoke this access at any time."
}
},
"es" : {
@@ -3637,7 +4032,7 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Your personalized score reflects your overall health based on the factors listed below. Track it over time to see how lifestyle changes impact your heart health."
+ "value" : "Your personalised score reflects your overall health based on the factors listed below. Track it over time to see how lifestyle changes impact your heart health."
}
},
"es" : {
@@ -3648,6 +4043,16 @@
}
}
},
+ "Heartbeats" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Latidos"
+ }
+ }
+ }
+ },
"Height" : {
"localizations" : {
"en-GB" : {
@@ -3697,7 +4102,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Calcio coronario alto"
+ "value" : "Puntuación de calcio coronario alta"
}
}
}
@@ -3745,7 +4150,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Inicio\n"
+ "value" : "Inicio"
}
}
}
@@ -3761,7 +4166,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingreso Familiar"
+ "value" : "Ingresos del hogar"
}
}
}
@@ -3793,7 +4198,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Miocardiopatía Hipertrófica"
+ "value" : "Miocardiopatía hipertrófica (MCH)"
}
}
}
@@ -3809,7 +4214,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No tengo esta enfermedad"
+ "value" : "No tengo esta comorbilidad"
}
}
}
@@ -3825,7 +4230,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tengo esta enfermedad"
+ "value" : "Tengo esta comorbilidad"
+ }
+ }
+ }
+ },
+ "I'm walking here!" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¡Estoy caminando aquí!"
}
}
}
@@ -3954,6 +4369,16 @@
}
}
},
+ "Info" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Información"
+ }
+ }
+ }
+ },
"Install on Apple Watch" : {
"localizations" : {
"en-GB" : {
@@ -4018,6 +4443,16 @@
}
}
},
+ "Iron Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de hierro"
+ }
+ }
+ }
+ },
"Is selected" : {
"localizations" : {
"en-GB" : {
@@ -4034,6 +4469,16 @@
}
}
},
+ "Ivory Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de marfil"
+ }
+ }
+ }
+ },
"Kansas" : {
"localizations" : {
"en-GB" : {
@@ -4066,6 +4511,16 @@
}
}
},
+ "Lace Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de encaje"
+ }
+ }
+ }
+ },
"Language" : {
"localizations" : {
"en-GB" : {
@@ -4276,7 +4731,7 @@
"other" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Últimos %lld semanas"
+ "value" : "Últimas %lld semanas"
}
}
}
@@ -4401,7 +4856,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aprender Más"
+ "value" : "Más información"
+ }
+ }
+ }
+ },
+ "Leather Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de cuero"
}
}
}
@@ -4476,6 +4941,26 @@
}
}
},
+ "Longest Streak" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racha más larga"
+ }
+ }
+ }
+ },
+ "Longest Workout" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento más largo"
+ }
+ }
+ }
+ },
"Louisiana" : {
"localizations" : {
"en-GB" : {
@@ -4519,7 +5004,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Asegúrate de que tu dispositivo esté conectado a Internet y vuelve a intentarlo"
+ "value" : "Asegúrese de que su dispositivo esté conectado a Internet y vuelva a intentarlo"
}
}
}
@@ -4551,7 +5036,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Marcar como completado"
+ "value" : "Marcar como completada"
}
}
}
@@ -4620,6 +5105,26 @@
}
}
},
+ "Max Heart Rate" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Frecuencia cardíaca máx."
+ }
+ }
+ }
+ },
+ "Mega Streaker" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racha mega"
+ }
+ }
+ }
+ },
"Mental Well Being" : {
"localizations" : {
"en-GB" : {
@@ -4647,7 +5152,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "MHC Salud cardiovascular"
+ "value" : "MHC Salud del corazón"
}
}
}
@@ -4711,7 +5216,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tareas Perdidas"
+ "value" : "Tareas omitidas"
}
}
}
@@ -4727,7 +5232,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No tiene permiso requerido para el acceso a datos de movimiento"
+ "value" : "Falta el permiso requerido para acceder a los datos de movimiento"
}
}
}
@@ -4743,7 +5248,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Respuesta nula"
+ "value" : "Falta una respuesta"
}
}
}
@@ -4909,7 +5414,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "My Heart Counts no accede a tu Apple ID de ninguna manera; sin embargo, usar una cuenta compartida podría causar inexactitudes en la recopilación de datos de salud."
+ "value" : "My Heart Counts no accede a su Apple ID de ninguna manera; sin embargo, usar una cuenta compartida podría causar inexactitudes en la recopilación de datos de salud."
}
}
}
@@ -4989,7 +5494,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nunca he fumado"
+ "value" : "Nunca ha fumado"
}
}
}
@@ -5005,7 +5510,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nueva Hampshire"
+ "value" : "New Hampshire"
}
}
}
@@ -5021,7 +5526,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nueva Jersey"
+ "value" : "New Jersey"
}
}
}
@@ -5286,6 +5791,17 @@
}
}
},
+ "Next milestone" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Próximo hito"
+ }
+ }
+ }
+ },
"NHS Number" : {
"localizations" : {
"en-GB" : {
@@ -5393,7 +5909,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No hay tareas por hacer"
+ "value" : "No hay tareas omitidas"
}
}
}
@@ -5553,7 +6069,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Número del NHS inválido"
+ "value" : "Número NHS no válido"
}
}
}
@@ -5645,7 +6161,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hora preferida para las notificaciones de actividad. Puedes cambiarla más tarde."
+ "value" : "Hora preferida para las notificaciones de actividad. Puede cambiarla más tarde."
}
}
}
@@ -5769,7 +6285,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Este estudio de investigación es llevado a cabo por investigadores de la Facultad de Medicina de la Universidad de Stanford para entender cómo la actividad física se relaciona con la salud cardiovascular. La aplicación te guiará a través de varias evaluaciones, incluyendo la prueba de caminata de 6 minutos. Tu participación es flexible, lo que significa que puedes participar en el estudio tanto como desees. El enfoque basado en teléfonos inteligentes nos permite recopilar datos en tu entorno natural en lugar de requerir visitas a una instalación clínica."
+ "value" : "Este estudio de investigación es llevado a cabo por investigadores de la Facultad de Medicina de la Universidad de Stanford para entender cómo la actividad física se relaciona con la salud cardiovascular. La aplicación lo guiará a través de varias evaluaciones, incluida la prueba de caminata de 6 minutos. Su participación es flexible, lo que significa que puede participar en el estudio tanto como desee. El enfoque basado en teléfonos inteligentes nos permite recopilar datos en su entorno natural en lugar de requerir visitas a una instalación clínica."
}
}
}
@@ -5785,13 +6301,13 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "You’re invited to join an Imperial research study that tracks physical activity through your smartphone to improve heart health knowledge. You’ll complete brief health surveys and perform simple fitness assessments like the 6-minute walk test using your phone."
+ "value" : "You’re invited to join a Stanford research study that tracks physical activity through your smartphone to improve heart health knowledge. You’ll complete brief health surveys and perform simple fitness assessments like the 6-minute walk test using your phone."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Estás invitado a unirte a un estudio de investigación de Stanford que rastrea la actividad física a través de tu teléfono inteligente para mejorar el conocimiento sobre la salud del corazón. Completarás breves encuestas de salud y realizarás evaluaciones de condición física simples como la prueba de caminata de 6 minutos usando tu teléfono."
+ "value" : "Le invitamos a unirse a un estudio de investigación de Stanford que registra la actividad física a través de su teléfono inteligente para mejorar el conocimiento sobre la salud del corazón. Completará breves encuestas de salud y realizará evaluaciones de condición física sencillas, como la prueba de caminata de 6 minutos, usando su teléfono."
}
}
}
@@ -5829,13 +6345,13 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "The optional trial uses a \"randomized crossover\" design where you'll experience both types of coaching interventions - personalised activity prompts and standard activity prompts - for 7 days each (total of 14 days). This will begin after the baseline monitoring week. The order in which you receive these interventions will be randomly assigned, similar to flipping a coin. This design allows researchers to compare how the different coaching approaches affect your activity levels while controlling for individual differences, as each participant serves as their own control. You can choose not to participate in this trial component and still be part of the main My Heart Counts study."
+ "value" : "The optional trial uses a \"randomised crossover\" design where you'll experience both types of coaching interventions - personalised activity prompts and standard activity prompts - for 7 days each (total of 14 days). This will begin after the baseline monitoring week. The order in which you receive these interventions will be randomly assigned, similar to flipping a coin. This design allows researchers to compare how the different coaching approaches affect your activity levels while controlling for individual differences, as each participant serves as their own control. You can choose not to participate in this trial component and still be part of the main My Heart Counts study."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "El ensayo opcional utiliza un diseño de \"cruce aleatorio\" en el que experimentarás ambos tipos de intervenciones de coaching: indicaciones de actividad personalizadas e indicaciones de actividad estándar, durante 7 días cada una (un total de 14 días). Esto comenzará después de la semana inicial. El orden en que recibas estas intervenciones se asignará de manera aleatoria, similar a lanzar una moneda. Este diseño permite a los investigadores comparar cómo los diferentes enfoques de coaching afectan tus niveles de actividad mientras controlan las diferencias individuales, ya que cada participante actúa como su propio control. Puedes optar por no participar en este componente del ensayo y seguir siendo parte del estudio principal My Heart Counts."
+ "value" : "El ensayo opcional utiliza un diseño de \"cruce aleatorio\" en el que experimentará ambos tipos de intervenciones de coaching: indicaciones de actividad personalizadas e indicaciones de actividad estándar, durante 7 días cada una (un total de 14 días). Esto comenzará después de la semana de monitorización inicial. El orden en que reciba estas intervenciones se asignará de manera aleatoria, similar a lanzar una moneda. Este diseño permite a los investigadores comparar cómo los diferentes enfoques de coaching afectan sus niveles de actividad mientras controlan las diferencias individuales, ya que cada participante actúa como su propio control. Puede optar por no participar en este componente del ensayo y seguir siendo parte del estudio principal My Heart Counts."
}
}
}
@@ -5851,13 +6367,13 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "You have the option to enroll in a 2-week physical activity coaching trial. If you choose to join, you’ll experience both personalised and standard coaching messages for 7 days each, helping us learn which approaches better motivate healthy behaviours."
+ "value" : "You have the option to enrol in a 2-week physical activity coaching trial. If you choose to join, you’ll experience both personalised and standard coaching messages for 7 days each, helping us learn which approaches better motivate healthy behaviours."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Elige inscribirte en una prueba de entrenamiento físico de dos semanas de duración. Si decides unite, recibirás mensajes personalizados ó estándares durante 7 días cada uno. Esto nos ayuda a entender qué métodos motivan más eficientemente comportamientos saludables."
+ "value" : "Tiene la opción de inscribirse en un ensayo de entrenamiento de actividad física de dos semanas de duración. Si decide participar, recibirá tanto mensajes de entrenamiento personalizados como estándar durante 7 días cada uno, lo que nos ayuda a saber qué enfoques motivan mejor los comportamientos saludables."
}
}
}
@@ -5911,19 +6427,19 @@
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "We will collect data from the Health app on your iPhone which will be protected through encryption. Your personal information is stored separately from health data, and you control what sensor and health data you share with us.\n\nBy agreeing to this consent, your data will be will be combined with data from other participants for analysis by Stanford researchers and its research partners."
+ "value" : "We will collect data from the Health app on your iPhone which will be protected through encryption. Your personal information is stored separately from health data, and you control what sensor and health data you share with us.\n\nBy agreeing to this consent, your data will be combined with data from other participants for analysis by Stanford researchers and its research partners."
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "We will collect data from the Health app on your iPhone which will be protected through encryption. Your personal information is stored separately from health data, and you control what sensor and health data you share with us. \n\nBy agreeing to this consent, your data will be will be combined with data from other participants for analysis by Imperial researchers and its research partners."
+ "value" : "We will collect data from the Health app on your iPhone which will be protected through encryption. Your personal information is stored separately from health data, and you control what sensor and health data you share with us.\n\nBy agreeing to this consent, your data will be combined with data from other participants for analysis by Stanford researchers and its research partners."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Recopilaremos datos de la Health app en tu iPhone, los cuales estarán protegidos mediante cifrado. Tu información personal se almacena separadamente de los datos de salud, y tú controlas qué datos de sensores y salud compartes con nosotros.\n\nAl aceptar este consentimiento, tus datos se combinarán con datos de otros participantes para ser analizados por los investigadores de Stanford y sus socios de investigación."
+ "value" : "Recopilaremos datos de la Health app en su iPhone, los cuales estarán protegidos mediante cifrado. Su información personal se almacena por separado de los datos de salud, y usted controla qué datos de sensores y salud comparte con nosotros.\n\nAl aceptar este consentimiento, sus datos se combinarán con datos de otros participantes para ser analizados por los investigadores de Stanford y sus socios de investigación."
}
}
}
@@ -5945,7 +6461,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Recolección de Datos y Privacidad"
+ "value" : "Recopilación de Datos y Privacidad"
}
}
}
@@ -5967,7 +6483,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Los riesgos físicos son mínimos, pero podrían incluir dolor o tensión muscular temporal debido a actividades físicas. También hay un pequeño riesgo relacionado con la privacidad de los datos a pesar de nuestras medidas de seguridad. Los beneficios incluyen acceso a tus propios datos de actividad y el conocimiento de que estás contribuyendo a una investigación que puede ayudar a mejorar la salud del corazón. Tu participación es completamente voluntaria, y puedes dejar de participar en cualquier momento sin penalización contactando al equipo de investigación. Esta investigación ha sido revisada por la Junta de Revisión Institucional de Stanford para garantizar la protección de los participantes."
+ "value" : "Los riesgos físicos son mínimos, pero podrían incluir dolor o tensión muscular temporal debido a actividades físicas. También hay un pequeño riesgo relacionado con la privacidad de los datos a pesar de nuestras medidas de seguridad. Los beneficios incluyen el acceso a sus propios datos de actividad y el conocimiento de que está contribuyendo a una investigación que puede ayudar a mejorar las recomendaciones de salud del corazón. Su participación es completamente voluntaria, y puede dejar de participar en cualquier momento sin penalización contactando al equipo de investigación. Esta investigación ha sido revisada por la Junta de Revisión Institucional de Stanford para garantizar la protección de los participantes."
}
}
}
@@ -5983,13 +6499,13 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Participation is completely voluntary with minimal risks. Benefits include gaining insights about your heart health and contributing to cardiovascular research. You can withdraw from the study at anytime and request to have your identifying data forgotten."
+ "value" : "Participation is completely voluntary with minimal risks. Benefits include gaining insights about your heart health and contributing to cardiovascular research. You can withdraw from the study at any time and request to have your identifying data forgotten."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "La participación es completamente voluntaria y con riesgos mínimos. Los beneficios incluyen obtener información sobre la salud de su corazón y contribuir a la investigación cardiovascular. Puedes retirarte del estudio en cualquier momento y solicitar que se olvide tu información."
+ "value" : "La participación es completamente voluntaria y con riesgos mínimos. Los beneficios incluyen obtener información sobre la salud de su corazón y contribuir a la investigación cardiovascular. Puede retirarse del estudio en cualquier momento y solicitar que se elimine su información identificable."
}
}
}
@@ -6161,7 +6677,39 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nuestro documento de consentimiento del estudio ha sido actualizado desde la última vez que lo firmaste %@.\n\nPor favor, revisa el nuevo documento de consentimiento y fírmalo nuevamente."
+ "value" : "Nuestro documento de consentimiento del estudio ha sido actualizado desde la última vez que lo firmó%@.\n\nPor favor, revise el nuevo documento de consentimiento y fírmelo nuevamente."
+ }
+ }
+ }
+ },
+ "Paper Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de papel"
+ }
+ }
+ }
+ },
+ "PARTICIPATION_STATS_EXPLAINER(enrollmentDate: %@)" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Participation Stats cover all engagement and activity recorded.\n\nYour enrollment date is %1$(enrollmentDate)@."
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Participation Stats cover all engagement and activity recorded.\n\nYour enrolment date is %1$(enrollmentDate)@."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Las estadísticas de participación abarcan toda la interacción y actividad registradas.\n\nSu fecha de inscripción es %1$(enrollmentDate)@."
}
}
}
@@ -6236,6 +6784,26 @@
}
}
},
+ "Pearl Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de perla"
+ }
+ }
+ }
+ },
+ "Pedestrian Pioneer" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pionero del paso"
+ }
+ }
+ }
+ },
"Pennsylvania" : {
"localizations" : {
"en-GB" : {
@@ -6300,6 +6868,16 @@
}
}
},
+ "Personal Bests" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Récords personales"
+ }
+ }
+ }
+ },
"Ph.D., M.D., J.D., etc." : {
"localizations" : {
"en-GB" : {
@@ -6327,7 +6905,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Introduce una dirección de correo electrónico válida e inténtalo de nuevo."
+ "value" : "Introduzca una dirección de correo electrónico válida e inténtelo de nuevo."
}
}
}
@@ -6344,7 +6922,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Por favor, selecciona la opción que mejor se ajuste a lo que haces"
+ "value" : "Por favor, seleccione la opción que mejor se ajuste a lo que hace."
+ }
+ }
+ }
+ },
+ "Porcelain Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de porcelana"
}
}
}
@@ -6388,7 +6976,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Incentivos para la actividad posterior al ensayo"
+ "value" : "Recordatorios de actividad posteriores al ensayo"
}
}
}
@@ -6398,7 +6986,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Activar avisos posteriores al estudio"
+ "value" : "Activar recordatorios posteriores al ensayo"
+ }
+ }
+ }
+ },
+ "Pottery Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de cerámica"
}
}
}
@@ -6567,6 +7165,16 @@
}
}
},
+ "Questionnaire Extraordinaire" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maestro del cuestionario"
+ }
+ }
+ }
+ },
"Quit 1 to 5 years ago" : {
"localizations" : {
"en-GB" : {
@@ -6912,7 +7520,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Puedes seleccionar múltiples opciones."
+ "value" : "Puede seleccionar varias opciones."
}
}
}
@@ -7085,28 +7693,58 @@
}
}
},
- "Region" : {
+ "Record your first ECG" : {
"localizations" : {
- "en-GB" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Region"
- }
- },
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Región"
+ "value" : "Registre su primer ECG"
}
}
}
},
- "Region Not Yet Supported" : {
+ "Record your first Run Test" : {
"localizations" : {
- "en-GB" : {
+ "es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Region Not Yet Supported"
+ "value" : "Registre su primera prueba de carrera"
+ }
+ }
+ }
+ },
+ "Record your first Walk Test" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registre su primera prueba de caminata"
+ }
+ }
+ }
+ },
+ "Region" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Region"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Región"
+ }
+ }
+ }
+ },
+ "Region Not Yet Supported" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Region Not Yet Supported"
}
},
"es" : {
@@ -7133,6 +7771,16 @@
}
}
},
+ "Resting HR" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "FC en reposo"
+ }
+ }
+ }
+ },
"Review Consent Forms" : {
"localizations" : {
"en-GB" : {
@@ -7197,6 +7845,26 @@
}
}
},
+ "roughly %@ trips from San Francisco to New York" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "aproximadamente %1$@ viajes de San Francisco a Nueva York"
+ }
+ }
+ }
+ },
+ "Ruby Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de rubí"
+ }
+ }
+ }
+ },
"Running" : {
"localizations" : {
"en-GB" : {
@@ -7229,6 +7897,16 @@
}
}
},
+ "Sapphire Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de zafiro"
+ }
+ }
+ }
+ },
"Save" : {
"localizations" : {
"en-GB" : {
@@ -7288,7 +7966,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Resultados: %lld por ciento"
+ "value" : "Resultado de la puntuación: %lld por ciento"
}
}
}
@@ -7320,7 +7998,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Selecciona una Región"
+ "value" : "Seleccione una región"
}
}
}
@@ -7368,7 +8046,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Selecciona tu Estado o Territorio"
+ "value" : "Seleccione su Estado o Territorio"
}
}
}
@@ -7605,6 +8283,36 @@
}
}
},
+ "Silk Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de seda"
+ }
+ }
+ }
+ },
+ "Silver Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de plata"
+ }
+ }
+ }
+ },
+ "Sleep" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sueño"
+ }
+ }
+ }
+ },
"Some College" : {
"localizations" : {
"en-GB" : {
@@ -7756,7 +8464,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Participo regularmente en actividad física planificada por motivos de salud, deporte o fitness (por ejemplo, al menos 3 veces por semana, 30 minutos por día); Es parte de mi vida rutinaria."
+ "value" : "Participo regularmente en actividad física planificada por motivos de salud, deporte o fitness (por ejemplo, al menos 3 veces por semana, 30 minutos por día); es parte de mi vida rutinaria."
}
}
}
@@ -7910,7 +8618,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Desde hace varios años practico deporte o realizo actividad física recreativa de forma rutinaria, es parte de mi estilo de vida."
+ "value" : "Desde hace varios años he realizado de forma rutinaria actividad física planificada por motivos de salud, deporte o fitness con regularidad (al menos 3 veces por semana, 30 minutos por día); es parte de mi estilo de vida."
}
}
}
@@ -7947,6 +8655,26 @@
}
}
},
+ "Stats and Achievements" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Estadísticas y logros"
+ }
+ }
+ }
+ },
+ "Steel Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de acero"
+ }
+ }
+ }
+ },
"Steps" : {
"localizations" : {
"en-GB" : {
@@ -8059,6 +8787,26 @@
}
}
},
+ "Super Streaker" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racha súper"
+ }
+ }
+ }
+ },
+ "Surveys Answered" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Encuestas respondidas"
+ }
+ }
+ }
+ },
"Swimming" : {
"localizations" : {
"en-GB" : {
@@ -8150,7 +8898,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Toque para obtener más información..."
+ "value" : "Toque para obtener más información…"
}
}
}
@@ -8171,6 +8919,16 @@
}
}
},
+ "Tasks Done" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tareas completadas"
+ }
+ }
+ }
+ },
"Tennessee" : {
"localizations" : {
"en-GB" : {
@@ -8261,6 +9019,26 @@
}
}
},
+ "the distance of %@ marathons" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "la distancia de %1$@ maratones"
+ }
+ }
+ }
+ },
+ "The Humble Walker" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El caminante humilde"
+ }
+ }
+ }
+ },
"The My Heart Counts study isn't yet available in %@.\n\nAdd your email below and we'll update you when it launches in your region." : {
"localizations" : {
"en-GB" : {
@@ -8272,7 +9050,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "El estudio My Heart Counts aún no está disponible en %@.\n\nIntroduce tu correo electrónico y te avisaremos cuando esté disponible en tu región."
+ "value" : "El estudio My Heart Counts aún no está disponible en %@.\n\nIntroduzca su correo electrónico y le avisaremos cuando esté disponible en su región."
}
}
}
@@ -8374,7 +9152,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Esta prueba mide tu capacidad funcional de ejercicio al registrar la distancia que recorres caminando en 6 minutos sobre una superficie plana. (Para rangos de referencia, consulta [aquí](https://mhc-6mwts.streamlit.app).)\n\n**Cómo hacerlo:** Busqua un trayecto plano y libre de obstáculos. Usa calzado cómodo. Toqua \"Iniciar\" y camina a un ritmo constante que te resulte retador pero cómodo para cubrir la mayor distancia posible. Mantén tu teléfono en la mano hasta que vibre."
+ "value" : "Esta prueba mide su capacidad funcional de ejercicio al registrar la distancia que recorre caminando en 6 minutos sobre una superficie plana. (Para conocer los rangos de referencia, consulte [aquí](https://mhc-6mwts.streamlit.app).)\n\n**Cómo hacerlo:** Busque un trayecto plano y libre de obstáculos. Use calzado cómodo. Toque \"Iniciar\" y camine a un ritmo constante que le resulte exigente pero cómodo para cubrir la mayor distancia posible. Mantenga su teléfono en la mano hasta que vibre."
}
}
}
@@ -8418,7 +9196,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "**Advertencia:** Detente de inmediato si presentas dolor en el pecho, falta de aire intensa, mareo o fatiga inusual."
+ "value" : "**Advertencia:** Deténgase de inmediato si presenta dolor en el pecho, falta de aire intensa, mareo o fatiga inusual."
+ }
+ }
+ }
+ },
+ "Tin Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de estaño"
}
}
}
@@ -8466,7 +9254,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingreso Total del Hogar"
+ "value" : "Ingreso total del hogar"
}
}
}
@@ -8476,7 +9264,7 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Transient Ischemic Attack (TIA)"
+ "value" : "Transient Ischaemic Attack (TIA)"
}
},
"es" : {
@@ -8514,7 +9302,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Territorios de los Trusts"
+ "value" : "Territorios en Fideicomiso"
}
}
}
@@ -8529,6 +9317,16 @@
}
}
},
+ "Uber Streaker" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racha über"
+ }
+ }
+ }
+ },
"UK Region" : {
"localizations" : {
"en-GB" : {
@@ -8572,7 +9370,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No se pudo cargar los datos"
+ "value" : "No se pudieron cargar los datos"
}
}
}
@@ -8625,6 +9423,16 @@
}
}
},
+ "Upcoming Achievements" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Próximos logros"
+ }
+ }
+ }
+ },
"US State / Region" : {
"localizations" : {
"en-GB" : {
@@ -8720,6 +9528,9 @@
}
}
}
+ },
+ "View Participation Stats" : {
+
},
"Virgin Islands" : {
"localizations" : {
@@ -8780,7 +9591,17 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Formación profesional / aprendiz "
+ "value" : "Formación profesional / aprendizaje / diploma"
+ }
+ }
+ }
+ },
+ "w" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sem"
}
}
}
@@ -8801,6 +9622,26 @@
}
}
},
+ "Walk / Run Tests" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pruebas de caminata / carrera"
+ }
+ }
+ }
+ },
+ "Walk %@ steps in a day" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camine %@ pasos en un día"
+ }
+ }
+ }
+ },
"Walking" : {
"localizations" : {
"en-GB" : {
@@ -8844,7 +9685,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Washington "
+ "value" : "Washington"
}
}
}
@@ -8897,6 +9738,16 @@
}
}
},
+ "Welcome to the fold" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bienvenido al grupo"
+ }
+ }
+ }
+ },
"WELCOME_AREA1_DESCRIPTION" : {
"localizations" : {
"en" : {
@@ -8908,7 +9759,7 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Your involvement helps Imperial researchers understand how personalised digital coaching can improve physical activity and your overall health today and in the future."
+ "value" : "Your involvement helps Stanford researchers understand how personalised digital coaching can improve physical activity and your overall health today and in the future."
}
},
"es" : {
@@ -8958,7 +9809,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Participa completamente en línea, compartiendo de manera segura tus datos de actividad directamente desde su iPhone y Apple Watch."
+ "value" : "Participe completamente en línea, compartiendo de manera segura sus datos de actividad directamente desde su iPhone y su Apple Watch."
}
}
}
@@ -9081,6 +9932,16 @@
}
}
},
+ "Wood Anniversary" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aniversario de madera"
+ }
+ }
+ }
+ },
"Workout Preference" : {
"localizations" : {
"en-GB" : {
@@ -9108,109 +9969,2074 @@
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "My Heart Counts will send you personalized notifications to help you stay active. Tell us which workouts you prefer and when during the day you’d like to receive reminders. This helps us tailor your experience to your schedule and goals."
+ "value" : "My Heart Counts will send you personalised notifications to help you stay active. Tell us which workouts you prefer and when during the day you’d like to receive reminders. This helps us tailor your experience to your schedule and goals."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "My Heart Counts te enviará notificaciones personalizadas para ayudarte a mantenerte activo/a. Indícanos qué entrenamientos prefieres y cuándo durante el día desea recibir recordatorios. Esto nos ayuda a adaptar tu experiencia a tu horario y objetivos."
+ "value" : "My Heart Counts le enviará notificaciones personalizadas para ayudarle a mantenerse activo/a. Indíquenos qué entrenamientos prefiere y a qué hora del día desea recibir recordatorios. Esto nos ayuda a adaptar su experiencia a su horario y objetivos."
}
}
}
},
- "Wyoming" : {
+ "WORKOUT_TYPE_AMERICAN_FOOTBALL" : {
+ "extractionState" : "extracted_with_value",
"localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "American Football"
+ }
+ },
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Wyoming"
+ "value" : "American Football"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Wyoming"
+ "value" : "Fútbol americano"
}
}
}
},
- "Yes" : {
+ "WORKOUT_TYPE_ARCHERY" : {
+ "extractionState" : "extracted_with_value",
"localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Archery"
+ }
+ },
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yes"
+ "value" : "Archery"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sí"
+ "value" : "Tiro con arco"
}
}
}
},
- "Yes, Caribbean Hispanic, including Cuban and Puerto Rican" : {
+ "WORKOUT_TYPE_AUSTRALIAN_FOOTBALL" : {
+ "extractionState" : "extracted_with_value",
"localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Australian Football"
+ }
+ },
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yes, Caribbean Hispanic, including Cuban and Puerto Rican"
+ "value" : "Australian Football"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sí, Hispano del Caribe, incluyendo Cubano y Puertorriqueño"
+ "value" : "Fútbol australiano"
}
}
}
},
- "Yes, European Hispanic, including Spanish and Portuguese, Hispanic, Latina" : {
+ "WORKOUT_TYPE_BADMINTON" : {
+ "extractionState" : "extracted_with_value",
"localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Badminton"
+ }
+ },
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yes, European Hispanic, including Spanish and Portuguese, Hispanic, Latina"
+ "value" : "Badminton"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sí, Hispano Europeo, incluyendo Español y Portugués, Hispano, Latina"
+ "value" : "Bádminton"
}
}
}
},
- "Yes, Mexican, Mexican American, or Chicano" : {
+ "WORKOUT_TYPE_BARRE" : {
+ "extractionState" : "extracted_with_value",
"localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Barre"
+ }
+ },
"en-GB" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Yes, Mexican, Mexican American, or Chicano"
+ "value" : "Barre"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sí, Mexicano, Mexicano Americano, o Chicano"
+ "value" : "Barre"
}
}
}
},
- "Yes, other Hispanic, Latino" : {
+ "WORKOUT_TYPE_BASEBALL" : {
+ "extractionState" : "extracted_with_value",
"localizations" : {
- "en-GB" : {
+ "en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Yes, other Hispanic, Latino"
+ "state" : "new",
+ "value" : "Baseball"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Baseball"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Béisbol"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_BASKETBALL" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Basketball"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Basketball"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Baloncesto"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_BOWLING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Bowling"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bowling"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bolos"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_BOXING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Boxing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Boxing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Boxeo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CARDIO_DANCE" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cardio Dance"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cardio Dance"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Baile cardio"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CLIMBING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Climbing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Climbing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escalada"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_COOLDOWN" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cooldown"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cooldown"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enfriamiento"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CORE_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Core Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Core Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento del core"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CRICKET" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cricket"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cricket"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Críquet"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CROSS_COUNTRY_SKIING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cross Country Skiing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cross Country Skiing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esquí de fondo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CROSS_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cross Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cross Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento combinado"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CURLING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Curling"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Curling"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Curling"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_CYCLING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cycling"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cycling"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ciclismo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_DANCE" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Dance"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dance"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Baile"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_DANCE_INSPIRED_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Dance Inspired Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dance Inspired Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento inspirado en el baile"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_DISC_SPORTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Disc Sports"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Disc Sports"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deportes de disco"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_DOWNHILL_SKIING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Downhill Skiing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Downhill Skiing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esquí alpino"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_ELLIPTICAL" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Elliptical"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elliptical"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elíptica"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_EQUESTRIAN_SPORTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Equestrian Sports"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Equestrian Sports"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deportes ecuestres"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_FENCING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Fencing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fencing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esgrima"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_FISHING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Fishing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fishing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pesca"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_FITNESS_GAMING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Fitness Gaming"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fitness Gaming"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Videojuegos de fitness"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_FLEXIBILITY" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Flexibility"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Flexibility"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Flexibilidad"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_FUNCTIONAL_STRENGTH_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Functional Strength Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Functional Strength Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento de fuerza funcional"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_GOLF" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Golf"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Golf"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Golf"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_GYMNASTICS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Gymnastics"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gymnastics"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gimnasia"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_HAND_CYCLING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Hand Cycling"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hand Cycling"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ciclismo de brazos"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_HANDBALL" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Handball"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Handball"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Balonmano"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "High Intensity Interval Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "High Intensity Interval Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento de intervalos de alta intensidad"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_HIKING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Hiking"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hiking"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Senderismo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_HOCKEY" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Hockey"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hockey"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hockey"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_HUNTING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Hunting"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hunting"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Caza"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_JUMP_ROPE" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Jump Rope"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jump Rope"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saltar a la comba"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_KICKBOXING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Kickboxing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kickboxing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kickboxing"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_LACROSSE" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Lacrosse"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lacrosse"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lacrosse"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_MARTIAL_ARTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Martial Arts"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Martial Arts"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Artes marciales"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_MIND_AND_BODY" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Mind and Body"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mind and Body"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cuerpo y mente"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_MIXED_CARDIO" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Mixed Cardio"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mixed Cardio"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cardio combinado"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_MIXED_METABOLIC_CARDIO_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Mixed Metabolic Cardio Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mixed Metabolic Cardio Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento cardiometabólico combinado"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_OTHER" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Other"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Other"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Otro"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_PADDLE_SPORTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Paddle Sports"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paddle Sports"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deportes de remo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_PICKLEBALL" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Pickleball"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pickleball"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pickleball"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_PILATES" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Pilates"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pilates"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pilates"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_PLAY" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Play"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Play"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Juego"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_PREPARATION_AND_RECOVERY" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Preparation and Recovery"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preparation and Recovery"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preparación y recuperación"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_RACQUETBALL" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Racquetball"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racquetball"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Racquetball"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_ROWING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Rowing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rowing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Remo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_RUGBY" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Rugby"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rugby"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rugby"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_RUNNING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Running"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Running"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Correr"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SAILING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Sailing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sailing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vela"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SKATING_SPORTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Skating Sports"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skating Sports"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Patinaje"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SNOW_SPORTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Snow Sports"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Snow Sports"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deportes de nieve"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SNOWBOARDING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Snowboarding"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Snowboarding"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Snowboard"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SOCCER" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Soccer"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Football"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fútbol"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SOCIAL_DANCE" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Social Dance"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Social Dance"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Baile de salón"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SOFTBALL" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Softball"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Softball"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Softball"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SQUASH" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Squash"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Squash"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Squash"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_STAIR_CLIMBING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Stair Climbing"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stair Climbing"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Subir escaleras"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_STAIRS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Stairs"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stairs"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escaleras"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_STEP_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Step Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Step Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento de step"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SURFING_SPORTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Surfing Sports"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Surfing Sports"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Surf"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SWIM_BIKE_RUN" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Swim Bike Run"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Swim Bike Run"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Natación, ciclismo y carrera"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_SWIMMING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Swimming"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Swimming"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Natación"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_TABLE_TENNIS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Table Tennis"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Table Tennis"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tenis de mesa"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_TAI_CHI" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Tai Chi"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tai Chi"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taichí"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_TENNIS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Tennis"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tennis"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tenis"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_TRACK_AND_FIELD" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Track and Field"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Track and Field"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Atletismo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_TRADITIONAL_STRENGTH_TRAINING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Traditional Strength Training"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Traditional Strength Training"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento de fuerza tradicional"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_TRANSITION" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Transition"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transition"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transición"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_UNDERWATER_DIVING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Underwater Diving"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Underwater Diving"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buceo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_UNKNOWN" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Workout"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Workout"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamiento"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_VOLLEYBALL" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Volleyball"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Volleyball"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voleibol"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_WALKING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Walking"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Walking"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Caminar"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_WATER_FITNESS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Water Fitness"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Water Fitness"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fitness acuático"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_WATER_POLO" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Water Polo"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Water Polo"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Waterpolo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_WATER_SPORTS" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Water Sports"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Water Sports"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deportes acuáticos"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_WHEELCHAIR_RUN_PACE" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Wheelchair Run Pace"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wheelchair Run Pace"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Silla de ruedas a ritmo de carrera"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_WHEELCHAIR_WALK_PACE" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Wheelchair Walk Pace"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wheelchair Walk Pace"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Silla de ruedas a ritmo de paseo"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_WRESTLING" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Wrestling"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wrestling"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lucha"
+ }
+ }
+ }
+ },
+ "WORKOUT_TYPE_YOGA" : {
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Yoga"
+ }
+ },
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yoga"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yoga"
+ }
+ }
+ }
+ },
+ "Workouts" : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrenamientos"
+ }
+ }
+ }
+ },
+ "Wyoming" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wyoming"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wyoming"
+ }
+ }
+ }
+ },
+ "Yes" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yes"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sí"
+ }
+ }
+ }
+ },
+ "Yes, Caribbean Hispanic, including Cuban and Puerto Rican" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yes, Caribbean Hispanic, including Cuban and Puerto Rican"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sí, hispano del Caribe, incluyendo cubano y puertorriqueño"
+ }
+ }
+ }
+ },
+ "Yes, European Hispanic, including Spanish and Portuguese, Hispanic, Latina" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yes, European Hispanic, including Spanish and Portuguese, Hispanic, Latina"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sí, hispano europeo, incluyendo español y portugués, hispano, latina"
+ }
+ }
+ }
+ },
+ "Yes, Mexican, Mexican American, or Chicano" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yes, Mexican, Mexican American, or Chicano"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sí, mexicano, mexicano-americano o chicano"
+ }
+ }
+ }
+ },
+ "Yes, other Hispanic, Latino" : {
+ "localizations" : {
+ "en-GB" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yes, other Hispanic, Latino"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sí, otro Hispano, Latino"
+ "value" : "Sí, otro hispano, latino"
}
}
}
@@ -9226,7 +12052,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sí, Hispano Sudamericano"
+ "value" : "Sí, hispano sudamericano"
}
}
}
@@ -9274,7 +12100,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Puedes cambiar esto en la app Configuración de iOS, en Privacidad y seguridad → Salud → My Heart Counts"
+ "value" : "Puede cambiar esto en la app Configuración de iOS, en Privacidad y seguridad → Salud → My Heart Counts"
}
}
}
@@ -9290,7 +12116,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Puedes habilitar o deshabilitar sensores individuales en la app Configuración de iOS.\n"
+ "value" : "Puede habilitar o deshabilitar sensores individuales en la app Configuración de iOS.\n"
}
}
}
@@ -9306,7 +12132,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No has otorgado a My Heart Counts permiso para agregar datos a HealthKit para los siguientes tipos de datos:\n%@"
+ "value" : "No ha otorgado a My Heart Counts permiso para agregar datos a HealthKit para los siguientes tipos de datos:\n%@"
}
}
}
@@ -9322,7 +12148,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¡Estás listo/a!"
+ "value" : "¡Todo listo!"
}
}
}
@@ -9338,7 +12164,33 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Estás en la lista"
+ "value" : "Está en la lista"
+ }
+ }
+ }
+ },
+ "You've burned %@ active calories — the equivalent of %@ slices of pizza." : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "You've burned %1$@ active calories — the equivalent of %2$@ slices of pizza."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ha quemado %1$@ calorías activas, el equivalente a %2$@ porciones de pizza."
+ }
+ }
+ }
+ },
+ "You've spent about %@ full days asleep since enrolling. Rest is part of the work." : {
+ "localizations" : {
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ha pasado unos %1$@ días completos durmiendo desde que se inscribió. El descanso también forma parte del trabajo."
}
}
}
@@ -9354,7 +12206,39 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tu %@ está en progreso."
+ "value" : "Su %@ está en progreso."
+ }
+ }
+ }
+ },
+ "Your %@ steps cover about %@ — that's %@." : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your %1$@ steps cover about %2$@ — that's %3$@."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sus %1$@ pasos recorren aproximadamente %2$@ — eso equivale a %3$@."
+ }
+ }
+ }
+ },
+ "Your %@ steps cover roughly %@." : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your %1$@ steps cover roughly %2$@."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sus %1$@ pasos recorren aproximadamente %2$@."
}
}
}
@@ -9370,7 +12254,7 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tu Cuenta"
+ "value" : "Su cuenta"
}
}
}
@@ -9386,7 +12270,23 @@
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tu ECG se ha registrado correctamente."
+ "value" : "Su ECG se ha registrado correctamente"
+ }
+ }
+ }
+ },
+ "Your heart has beaten about %@ times since you enrolled — roughly %@ of an average lifetime." : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your heart has beaten about %1$@ times since you enrolled — roughly %2$@ of an average lifetime."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Su corazón ha latido unas %1$@ veces desde que se inscribió, aproximadamente el %2$@ de una vida promedio."
}
}
}
diff --git a/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTest.swift b/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTest.swift
index 41d7edf5..484996bc 100644
--- a/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTest.swift
+++ b/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTest.swift
@@ -99,10 +99,12 @@ final class TimedWalkingTest: Module, EnvironmentAccessible, Sendable {
static let enableLiveActivities: Bool = true
// swiftlint:disable attributes
+ @ObservationIgnored @Application(\.logger) private var logger
@ObservationIgnored @StandardActor private var standard: MyHeartCountsStandard
@ObservationIgnored @Dependency(WatchConnection.self) private var watchManager
@ObservationIgnored @Dependency(LocalStorage.self) private var localStorage
@ObservationIgnored @Dependency(Lifecycle.self) private var lifecycle
+ @ObservationIgnored @Dependency(AchievementsManager.self) private var achievements
// swiftlint:enable attributes
private let hapticEngine = try? CHHapticEngine()
@@ -242,7 +244,21 @@ final class TimedWalkingTest: Module, EnvironmentAccessible, Sendable {
}
result = try await stop(inProgressTest: result, isRecoveredTest: false)
if !discardResult {
- try? await standard.uploadHealthObservation(result)
+ do {
+ try await standard.uploadHealthObservation(result)
+ Task {
+ switch result.test {
+ case .sixMinuteWalkTest:
+ achievements.record(.complete6MinWalkTest, timestamp: result.endDate)
+ case .twelveMinuteRunTest:
+ achievements.record(.complete12MinRunTest, timestamp: result.endDate)
+ default:
+ break
+ }
+ }
+ } catch {
+ logger.error("Uploading TimedWalkTest failed: \(error)")
+ }
return result
} else {
return nil
diff --git a/MyHeartCounts/Utils/Account+Utils.swift b/MyHeartCounts/Utils/Account+Utils.swift
new file mode 100644
index 00000000..6ff981e7
--- /dev/null
+++ b/MyHeartCounts/Utils/Account+Utils.swift
@@ -0,0 +1,47 @@
+//
+// 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 Observation
+import SpeziAccount
+
+
+extension Account {
+ /// Whether the account details are present and fully loaded (not `isIncomplete`).
+ @MainActor private var detailsAreReady: Bool {
+ guard let details else {
+ return false
+ }
+ return !details.isIncomplete
+ }
+
+ /// Waits until the account details have been fully loaded.
+ ///
+ /// The purpose of this function is to have a client-side fix/workaround for https://github.com/SchmiedmayerLab/MyHeartCounts-iOS/issues/169
+ /// i.e., the issue where writes to the account details very early into the launch of the app, before they have been fully loaded by SpeziFirebase,
+ /// will somehow race with SpeziFirebase's account details loading and will leave `account.details` in an incomplete state (despite `AccountDetails.isIncomplete` being `false`).
+ /// If the first account details update waits until the details have been fully loaded, everything will work correctly.
+ ///
+ /// - Note: This function will only work correctly if **nothing** in the app has written account details before the initial load has completed.
+ @MainActor
+ func waitForAccountDetailsReady() async {
+ while !detailsAreReady {
+ if Task.isCancelled {
+ return
+ }
+ // The condition check above and the observation registration below run contiguously on the main actor
+ // (no `await` between them), so `details` cannot change in the gap and no update can be missed.
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ withObservationTracking {
+ _ = detailsAreReady
+ } onChange: {
+ continuation.resume()
+ }
+ }
+ }
+ }
+}
diff --git a/MyHeartCounts/Utils/AccountDetails+Diffing.swift b/MyHeartCounts/Utils/AccountDetails+Diffing.swift
new file mode 100644
index 00000000..e60ef232
--- /dev/null
+++ b/MyHeartCounts/Utils/AccountDetails+Diffing.swift
@@ -0,0 +1,63 @@
+//
+// 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 MyHeartCountsShared
+import Observation
+import SpeziAccount
+
+
+extension AccountDetails {
+ /// Creates a `String` intended for debugging, describing the changes that exist between some other value and these `AccountDetails`.
+ func debugDescOfDifference(from other: AccountDetails) -> String {
+ // we perform a JSON roundtrip in order to turn the AccountDetails into a `JSONObject` (ie a `[String: JSONValue]`) which we then run the diff on.
+ do {
+ var result: [String] = []
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+ let jsonOld = try decoder.decode(JSONObject.self, from: try encoder.encode(other))
+ let jsonNew = try decoder.decode(JSONObject.self, from: try encoder.encode(self))
+ let diff = jsonNew.difference(from: jsonOld)
+ for entry in diff.removed {
+ result.append("- \(entry)")
+ }
+ for entry in diff.insertd {
+ result.append("+ \(entry)")
+ }
+ for mutated in diff.mutated {
+ result.append("~ \(mutated)")
+ }
+ return result.joined(separator: "\n")
+ } catch {
+ return ""
+ }
+ }
+}
+
+
+extension Dictionary where Value: Equatable {
+ struct DictDifference {
+ var removed: [(Key, Value)] = []
+ var insertd: [(Key, Value)] = []
+ var mutated: [(key: Key, old: Value, new: Value)] = [] // swiftlint:disable:this large_tuple
+ }
+
+ func difference(from prev: Self) -> DictDifference {
+ var diff = DictDifference()
+ diff.removed = prev.filter { self[$0.key] == nil }
+ diff.insertd = self.filter { prev[$0.key] == nil }
+ diff.mutated = self.compactMap { key, value in
+ if let old = prev[key], old != value {
+ (key, old, value)
+ } else {
+ nil
+ }
+ }
+ return diff
+ }
+}
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)
+ }
+ }
}
}
diff --git a/MyHeartCounts/Utils/HKWorkoutActivityType+DisplayTitle.swift b/MyHeartCounts/Utils/HKWorkoutActivityType+DisplayTitle.swift
new file mode 100644
index 00000000..09ee336f
--- /dev/null
+++ b/MyHeartCounts/Utils/HKWorkoutActivityType+DisplayTitle.swift
@@ -0,0 +1,189 @@
+//
+// 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 HealthKit
+
+
+extension HKWorkoutActivityType {
+ /// User-displayable localized title for the workout type
+ var displayTitle: LocalizedStringResource {
+ switch self {
+ case .americanFootball:
+ LocalizedStringResource("WORKOUT_TYPE_AMERICAN_FOOTBALL", defaultValue: "American Football")
+ case .archery:
+ LocalizedStringResource("WORKOUT_TYPE_ARCHERY", defaultValue: "Archery")
+ case .australianFootball:
+ LocalizedStringResource("WORKOUT_TYPE_AUSTRALIAN_FOOTBALL", defaultValue: "Australian Football")
+ case .badminton:
+ LocalizedStringResource("WORKOUT_TYPE_BADMINTON", defaultValue: "Badminton")
+ case .baseball:
+ LocalizedStringResource("WORKOUT_TYPE_BASEBALL", defaultValue: "Baseball")
+ case .basketball:
+ LocalizedStringResource("WORKOUT_TYPE_BASKETBALL", defaultValue: "Basketball")
+ case .bowling:
+ LocalizedStringResource("WORKOUT_TYPE_BOWLING", defaultValue: "Bowling")
+ case .boxing:
+ LocalizedStringResource("WORKOUT_TYPE_BOXING", defaultValue: "Boxing")
+ case .climbing:
+ LocalizedStringResource("WORKOUT_TYPE_CLIMBING", defaultValue: "Climbing")
+ case .cricket:
+ LocalizedStringResource("WORKOUT_TYPE_CRICKET", defaultValue: "Cricket")
+ case .crossTraining:
+ LocalizedStringResource("WORKOUT_TYPE_CROSS_TRAINING", defaultValue: "Cross Training")
+ case .curling:
+ LocalizedStringResource("WORKOUT_TYPE_CURLING", defaultValue: "Curling")
+ case .cycling:
+ LocalizedStringResource("WORKOUT_TYPE_CYCLING", defaultValue: "Cycling")
+ case .elliptical:
+ LocalizedStringResource("WORKOUT_TYPE_ELLIPTICAL", defaultValue: "Elliptical")
+ case .equestrianSports:
+ LocalizedStringResource("WORKOUT_TYPE_EQUESTRIAN_SPORTS", defaultValue: "Equestrian Sports")
+ case .fencing:
+ LocalizedStringResource("WORKOUT_TYPE_FENCING", defaultValue: "Fencing")
+ case .fishing:
+ LocalizedStringResource("WORKOUT_TYPE_FISHING", defaultValue: "Fishing")
+ case .functionalStrengthTraining:
+ LocalizedStringResource("WORKOUT_TYPE_FUNCTIONAL_STRENGTH_TRAINING", defaultValue: "Functional Strength Training")
+ case .golf:
+ LocalizedStringResource("WORKOUT_TYPE_GOLF", defaultValue: "Golf")
+ case .gymnastics:
+ LocalizedStringResource("WORKOUT_TYPE_GYMNASTICS", defaultValue: "Gymnastics")
+ case .handball:
+ LocalizedStringResource("WORKOUT_TYPE_HANDBALL", defaultValue: "Handball")
+ case .hiking:
+ LocalizedStringResource("WORKOUT_TYPE_HIKING", defaultValue: "Hiking")
+ case .hockey:
+ LocalizedStringResource("WORKOUT_TYPE_HOCKEY", defaultValue: "Hockey")
+ case .hunting:
+ LocalizedStringResource("WORKOUT_TYPE_HUNTING", defaultValue: "Hunting")
+ case .lacrosse:
+ LocalizedStringResource("WORKOUT_TYPE_LACROSSE", defaultValue: "Lacrosse")
+ case .martialArts:
+ LocalizedStringResource("WORKOUT_TYPE_MARTIAL_ARTS", defaultValue: "Martial Arts")
+ case .mindAndBody:
+ LocalizedStringResource("WORKOUT_TYPE_MIND_AND_BODY", defaultValue: "Mind and Body")
+ case .paddleSports:
+ LocalizedStringResource("WORKOUT_TYPE_PADDLE_SPORTS", defaultValue: "Paddle Sports")
+ case .play:
+ LocalizedStringResource("WORKOUT_TYPE_PLAY", defaultValue: "Play")
+ case .preparationAndRecovery:
+ LocalizedStringResource("WORKOUT_TYPE_PREPARATION_AND_RECOVERY", defaultValue: "Preparation and Recovery")
+ case .racquetball:
+ LocalizedStringResource("WORKOUT_TYPE_RACQUETBALL", defaultValue: "Racquetball")
+ case .rowing:
+ LocalizedStringResource("WORKOUT_TYPE_ROWING", defaultValue: "Rowing")
+ case .rugby:
+ LocalizedStringResource("WORKOUT_TYPE_RUGBY", defaultValue: "Rugby")
+ case .running:
+ LocalizedStringResource("WORKOUT_TYPE_RUNNING", defaultValue: "Running")
+ case .sailing:
+ LocalizedStringResource("WORKOUT_TYPE_SAILING", defaultValue: "Sailing")
+ case .skatingSports:
+ LocalizedStringResource("WORKOUT_TYPE_SKATING_SPORTS", defaultValue: "Skating Sports")
+ case .snowSports:
+ LocalizedStringResource("WORKOUT_TYPE_SNOW_SPORTS", defaultValue: "Snow Sports")
+ case .soccer:
+ LocalizedStringResource("WORKOUT_TYPE_SOCCER", defaultValue: "Soccer")
+ case .softball:
+ LocalizedStringResource("WORKOUT_TYPE_SOFTBALL", defaultValue: "Softball")
+ case .squash:
+ LocalizedStringResource("WORKOUT_TYPE_SQUASH", defaultValue: "Squash")
+ case .stairClimbing:
+ LocalizedStringResource("WORKOUT_TYPE_STAIR_CLIMBING", defaultValue: "Stair Climbing")
+ case .surfingSports:
+ LocalizedStringResource("WORKOUT_TYPE_SURFING_SPORTS", defaultValue: "Surfing Sports")
+ case .swimming:
+ LocalizedStringResource("WORKOUT_TYPE_SWIMMING", defaultValue: "Swimming")
+ case .tableTennis:
+ LocalizedStringResource("WORKOUT_TYPE_TABLE_TENNIS", defaultValue: "Table Tennis")
+ case .tennis:
+ LocalizedStringResource("WORKOUT_TYPE_TENNIS", defaultValue: "Tennis")
+ case .trackAndField:
+ LocalizedStringResource("WORKOUT_TYPE_TRACK_AND_FIELD", defaultValue: "Track and Field")
+ case .traditionalStrengthTraining:
+ LocalizedStringResource("WORKOUT_TYPE_TRADITIONAL_STRENGTH_TRAINING", defaultValue: "Traditional Strength Training")
+ case .volleyball:
+ LocalizedStringResource("WORKOUT_TYPE_VOLLEYBALL", defaultValue: "Volleyball")
+ case .walking:
+ LocalizedStringResource("WORKOUT_TYPE_WALKING", defaultValue: "Walking")
+ case .waterFitness:
+ LocalizedStringResource("WORKOUT_TYPE_WATER_FITNESS", defaultValue: "Water Fitness")
+ case .waterPolo:
+ LocalizedStringResource("WORKOUT_TYPE_WATER_POLO", defaultValue: "Water Polo")
+ case .waterSports:
+ LocalizedStringResource("WORKOUT_TYPE_WATER_SPORTS", defaultValue: "Water Sports")
+ case .wrestling:
+ LocalizedStringResource("WORKOUT_TYPE_WRESTLING", defaultValue: "Wrestling")
+ case .yoga:
+ LocalizedStringResource("WORKOUT_TYPE_YOGA", defaultValue: "Yoga")
+ case .barre:
+ LocalizedStringResource("WORKOUT_TYPE_BARRE", defaultValue: "Barre")
+ case .coreTraining:
+ LocalizedStringResource("WORKOUT_TYPE_CORE_TRAINING", defaultValue: "Core Training")
+ case .crossCountrySkiing:
+ LocalizedStringResource("WORKOUT_TYPE_CROSS_COUNTRY_SKIING", defaultValue: "Cross Country Skiing")
+ case .downhillSkiing:
+ LocalizedStringResource("WORKOUT_TYPE_DOWNHILL_SKIING", defaultValue: "Downhill Skiing")
+ case .flexibility:
+ LocalizedStringResource("WORKOUT_TYPE_FLEXIBILITY", defaultValue: "Flexibility")
+ case .highIntensityIntervalTraining:
+ LocalizedStringResource("WORKOUT_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING", defaultValue: "High Intensity Interval Training")
+ case .jumpRope:
+ LocalizedStringResource("WORKOUT_TYPE_JUMP_ROPE", defaultValue: "Jump Rope")
+ case .kickboxing:
+ LocalizedStringResource("WORKOUT_TYPE_KICKBOXING", defaultValue: "Kickboxing")
+ case .pilates:
+ LocalizedStringResource("WORKOUT_TYPE_PILATES", defaultValue: "Pilates")
+ case .snowboarding:
+ LocalizedStringResource("WORKOUT_TYPE_SNOWBOARDING", defaultValue: "Snowboarding")
+ case .stairs:
+ LocalizedStringResource("WORKOUT_TYPE_STAIRS", defaultValue: "Stairs")
+ case .stepTraining:
+ LocalizedStringResource("WORKOUT_TYPE_STEP_TRAINING", defaultValue: "Step Training")
+ case .wheelchairWalkPace:
+ LocalizedStringResource("WORKOUT_TYPE_WHEELCHAIR_WALK_PACE", defaultValue: "Wheelchair Walk Pace")
+ case .wheelchairRunPace:
+ LocalizedStringResource("WORKOUT_TYPE_WHEELCHAIR_RUN_PACE", defaultValue: "Wheelchair Run Pace")
+ case .taiChi:
+ LocalizedStringResource("WORKOUT_TYPE_TAI_CHI", defaultValue: "Tai Chi")
+ case .mixedCardio:
+ LocalizedStringResource("WORKOUT_TYPE_MIXED_CARDIO", defaultValue: "Mixed Cardio")
+ case .handCycling:
+ LocalizedStringResource("WORKOUT_TYPE_HAND_CYCLING", defaultValue: "Hand Cycling")
+ case .discSports:
+ LocalizedStringResource("WORKOUT_TYPE_DISC_SPORTS", defaultValue: "Disc Sports")
+ case .fitnessGaming:
+ LocalizedStringResource("WORKOUT_TYPE_FITNESS_GAMING", defaultValue: "Fitness Gaming")
+ case .cardioDance:
+ LocalizedStringResource("WORKOUT_TYPE_CARDIO_DANCE", defaultValue: "Cardio Dance")
+ case .socialDance:
+ LocalizedStringResource("WORKOUT_TYPE_SOCIAL_DANCE", defaultValue: "Social Dance")
+ case .pickleball:
+ LocalizedStringResource("WORKOUT_TYPE_PICKLEBALL", defaultValue: "Pickleball")
+ case .cooldown:
+ LocalizedStringResource("WORKOUT_TYPE_COOLDOWN", defaultValue: "Cooldown")
+ case .swimBikeRun:
+ LocalizedStringResource("WORKOUT_TYPE_SWIM_BIKE_RUN", defaultValue: "Swim Bike Run")
+ case .transition:
+ LocalizedStringResource("WORKOUT_TYPE_TRANSITION", defaultValue: "Transition")
+ case .underwaterDiving:
+ LocalizedStringResource("WORKOUT_TYPE_UNDERWATER_DIVING", defaultValue: "Underwater Diving")
+ case .other:
+ LocalizedStringResource("WORKOUT_TYPE_OTHER", defaultValue: "Other")
+ case .dance:
+ LocalizedStringResource("WORKOUT_TYPE_DANCE", defaultValue: "Dance")
+ case .danceInspiredTraining:
+ LocalizedStringResource("WORKOUT_TYPE_DANCE_INSPIRED_TRAINING", defaultValue: "Dance Inspired Training")
+ case .mixedMetabolicCardioTraining:
+ LocalizedStringResource("WORKOUT_TYPE_MIXED_METABOLIC_CARDIO_TRAINING", defaultValue: "Mixed Metabolic Cardio Training")
+ @unknown default:
+ LocalizedStringResource("WORKOUT_TYPE_UNKNOWN", defaultValue: "Workout")
+ }
+ }
+}
diff --git a/MyHeartCounts/Utils/SwiftUI/Badge.swift b/MyHeartCounts/Utils/SwiftUI/Badge.swift
new file mode 100644
index 00000000..778fcfc3
--- /dev/null
+++ b/MyHeartCounts/Utils/SwiftUI/Badge.swift
@@ -0,0 +1,39 @@
+//
+// 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 SwiftUI
+
+
+struct Badge: View {
+ private let label: Label
+
+ var body: some View {
+ label
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.tint)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 4)
+ .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 6))
+ }
+
+ init(@ViewBuilder label: @MainActor () -> Label) {
+ self.label = label()
+ }
+
+ init(_ title: LocalizedStringResource) where Label == Text {
+ self.init {
+ Text(title)
+ }
+ }
+
+ init(_ title: LocalizedStringKey, bundle: Bundle) where Label == Text {
+ self.init {
+ Text(title, bundle: bundle)
+ }
+ }
+}
diff --git a/MyHeartCountsShared/Package.swift b/MyHeartCountsShared/Package.swift
index d521dd21..0eff3f71 100644
--- a/MyHeartCountsShared/Package.swift
+++ b/MyHeartCountsShared/Package.swift
@@ -23,7 +23,8 @@ packageDeps += [
.package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", from: "7.0.0"),
// not actually used but we need to force the version until we update SpeziStudy
.package(url: "https://github.com/StanfordSpezi/SpeziStorage.git", from: "2.1.4"),
- .package(url: "https://github.com/StanfordSpezi/SpeziScheduler.git", from: "1.2.20")
+ .package(url: "https://github.com/StanfordSpezi/SpeziScheduler.git", from: "1.2.20"),
+ .package(url: "https://github.com/apple/FHIRModels.git", .upToNextMinor(from: "0.8.0"))
]
#endif
diff --git a/MyHeartCountsShared/Sources/MyHeartCountsShared/Utils/JSONValue.swift b/MyHeartCountsShared/Sources/MyHeartCountsShared/Utils/JSONValue.swift
new file mode 100644
index 00000000..a5826ab1
--- /dev/null
+++ b/MyHeartCountsShared/Sources/MyHeartCountsShared/Utils/JSONValue.swift
@@ -0,0 +1,84 @@
+//
+// 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
+//
+
+// periphery:ignore:all - API
+
+
+/// A JSON Object, modeled as a Swift Dictionary.
+public typealias JSONObject = [String: JSONValue]
+
+
+/// A JSON value.
+public enum JSONValue: Hashable, Sendable {
+ case null
+ case bool(Bool)
+ case number(Double)
+ case string(String)
+ case object(JSONObject)
+ case array([JSONValue])
+}
+
+
+extension JSONValue: Codable {
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() {
+ self = .null
+ } else if let value = try? container.decode(Double.self) {
+ self = .number(value)
+ } else if let value = try? container.decode(String.self) {
+ self = .string(value)
+ } else if let value = try? container.decode(Bool.self) {
+ self = .bool(value)
+ } else if let value = try? container.decode([JSONValue].self) {
+ self = .array(value)
+ } else if let value = try? container.decode(JSONObject.self) {
+ self = .object(value)
+ } else {
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Failed to decode JSON value")
+ }
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .null:
+ try container.encodeNil()
+ case .bool(let value):
+ try container.encode(value)
+ case .string(let value):
+ try container.encode(value)
+ case .number(let value):
+ try container.encode(value)
+ case .array(let value):
+ try container.encode(value)
+ case .object(let value):
+ try container.encode(value)
+ }
+ }
+}
+
+
+extension JSONValue: CustomStringConvertible {
+ public var description: String {
+ switch self {
+ case .null:
+ "null"
+ case .number(let value):
+ value.description
+ case .bool(let value):
+ value.description
+ case .string(let value):
+ #""\#(value)""#
+ case .array(let value):
+ value.description
+ case .object(let value):
+ value.description
+ }
+ }
+}