updated
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
0c5e3dae74
commit
ca2476be6f
@ -5,9 +5,10 @@
|
|||||||
// Created by Matt Bruce on 12/20/24.
|
// Created by Matt Bruce on 12/20/24.
|
||||||
//
|
//
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct Activity {
|
struct Activity {
|
||||||
let id: Int
|
let id = UUID()
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
let image: String
|
let image: String
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Workout {
|
struct Workout {
|
||||||
let id: Int
|
let id = UUID()
|
||||||
let title: String
|
let title: String
|
||||||
let image: String
|
let image: String
|
||||||
let tintColor: Color
|
let tintColor: Color
|
||||||
|
|||||||
@ -17,19 +17,19 @@ class HomeViewModel {
|
|||||||
var exercise: Int = 0
|
var exercise: Int = 0
|
||||||
var stand: Int = 0
|
var stand: Int = 0
|
||||||
|
|
||||||
var mockActivities = [
|
// var mockActivities = [
|
||||||
Activity(id: 0, title: "Today Steps", subtitle: "10,000 steps", image: "figure.walk", tintColor: .green, amount: "9,812"),
|
// Activity(id: 0, title: "Today Steps", subtitle: "10,000 steps", image: "figure.walk", tintColor: .green, amount: "9,812"),
|
||||||
Activity(id: 1, title: "Today Steps", subtitle: "1,000 steps", image: "figure.walk", tintColor: .blue, amount: "812"),
|
// Activity(id: 1, title: "Today Steps", subtitle: "1,000 steps", image: "figure.walk", tintColor: .blue, amount: "812"),
|
||||||
Activity(id: 2, title: "Today Steps", subtitle: "12,000 steps", image: "figure.walk", tintColor: .purple, amount: "9,0000"),
|
// Activity(id: 2, title: "Today Steps", subtitle: "12,000 steps", image: "figure.walk", tintColor: .purple, amount: "9,0000"),
|
||||||
Activity(id: 3, title: "Today Steps", subtitle: "50,000 steps", image: "figure.run", tintColor: .red, amount: "104,812")
|
// Activity(id: 3, title: "Today Steps", subtitle: "50,000 steps", image: "figure.run", tintColor: .red, amount: "104,812")
|
||||||
]
|
// ]
|
||||||
|
//
|
||||||
var mockWorkouts = [
|
// var mockWorkouts = [
|
||||||
Workout(id: 0, title: "Running", image: "figure.run", tintColor: .green, duration: "1 hrs", date: "Aug 3", calories: "100 kcal"),
|
// Workout(id: 0, title: "Running", image: "figure.run", tintColor: .green, duration: "1 hrs", date: "Aug 3", calories: "100 kcal"),
|
||||||
Workout(id: 1, title: "Strength Training", image: "figure.run", tintColor: .purple, duration: "1.5 hrs", date: "Aug 3", calories: "130 kcal"),
|
// Workout(id: 1, title: "Strength Training", image: "figure.run", tintColor: .purple, duration: "1.5 hrs", date: "Aug 3", calories: "130 kcal"),
|
||||||
Workout(id: 2, title: "Walking", image: "figure.run", tintColor: .blue, duration: ".5 hrs", date: "Aug 3", calories: "250 kcal"),
|
// Workout(id: 2, title: "Walking", image: "figure.run", tintColor: .blue, duration: ".5 hrs", date: "Aug 3", calories: "250 kcal"),
|
||||||
Workout(id: 3, title: "Bike", image: "figure.run", tintColor: .red, duration: "2 hrs", date: "Aug 29", calories: "500 kcal")
|
// Workout(id: 3, title: "Bike", image: "figure.run", tintColor: .red, duration: "2 hrs", date: "Aug 29", calories: "500 kcal")
|
||||||
]
|
// ]
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
Task {
|
Task {
|
||||||
@ -38,6 +38,9 @@ class HomeViewModel {
|
|||||||
fetchTodayStandHours()
|
fetchTodayStandHours()
|
||||||
fetchTodayCaloriesBurned()
|
fetchTodayCaloriesBurned()
|
||||||
fetchTodayExerciseTime()
|
fetchTodayExerciseTime()
|
||||||
|
fetchTodaySteps()
|
||||||
|
fetchCurrentWeekActivities()
|
||||||
|
fetchCurrentMonthActivities()
|
||||||
} catch {
|
} catch {
|
||||||
print(error.localizedDescription)
|
print(error.localizedDescription)
|
||||||
}
|
}
|
||||||
@ -48,7 +51,9 @@ class HomeViewModel {
|
|||||||
switch result {
|
switch result {
|
||||||
case .success(let calories):
|
case .success(let calories):
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
let activity = Activity(title: "Calories Burned", subtitle: "today", image: "flame", tintColor: .red, amount: calories.formattedNumberString())
|
||||||
self.calories = Int(calories)
|
self.calories = Int(calories)
|
||||||
|
self.activities.append(activity)
|
||||||
}
|
}
|
||||||
case .failure(let failure):
|
case .failure(let failure):
|
||||||
print(failure.localizedDescription)
|
print(failure.localizedDescription)
|
||||||
@ -81,5 +86,44 @@ class HomeViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchTodaySteps() {
|
||||||
|
healthManager.fetchTodaySteps() { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let steps):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.activities.append(steps)
|
||||||
|
}
|
||||||
|
case .failure(let failure):
|
||||||
|
print(failure.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentWeekActivities() {
|
||||||
|
healthManager.fetchCurrentWeekWorkoutStats { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let activities):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.activities.append(contentsOf: activities)
|
||||||
|
}
|
||||||
|
case .failure(let failure):
|
||||||
|
print(failure.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentMonthActivities() {
|
||||||
|
healthManager.fetchWorkoutsForMonth(month: Date()) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let activities):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.workouts.append(contentsOf: activities)
|
||||||
|
}
|
||||||
|
case .failure(let failure):
|
||||||
|
print(failure.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,8 +37,7 @@ struct ActivityCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ActivityCard(activity: .init(id: 1,
|
ActivityCard(activity: .init(title: "Test",
|
||||||
title: "Test",
|
|
||||||
subtitle: "Goal 10,000",
|
subtitle: "Goal 10,000",
|
||||||
image: "walk",
|
image: "walk",
|
||||||
tintColor: .green,
|
tintColor: .green,
|
||||||
|
|||||||
@ -87,13 +87,13 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
if !viewModel.activities.isEmpty {
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(spacing: 20), count: 2)) {
|
LazyVGrid(columns: Array(repeating: GridItem(spacing: 20), count: 2)) {
|
||||||
ForEach(viewModel.mockActivities , id: \.id) { activity in
|
ForEach(viewModel.activities , id: \.id) { activity in
|
||||||
ActivityCard(activity: activity)
|
ActivityCard(activity: activity)
|
||||||
}
|
}
|
||||||
}.padding(.horizontal)
|
}.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
//Recent Workouts
|
//Recent Workouts
|
||||||
HStack {
|
HStack {
|
||||||
@ -114,7 +114,7 @@ struct HomeView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
LazyVStack{
|
LazyVStack{
|
||||||
ForEach(viewModel.mockWorkouts, id: \.id) { workout in
|
ForEach(viewModel.workouts, id: \.id) { workout in
|
||||||
WorkoutCard(workout: workout)
|
WorkoutCard(workout: workout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,5 +39,5 @@ struct WorkoutCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
WorkoutCard(workout: .init(id: 0, title: "Running", image: "figure.run", tintColor: .green, duration: "1 hour", date: "Aug 3", calories: "100 kcal"))
|
WorkoutCard(workout: .init(title: "Running", image: "figure.run", tintColor: .green, duration: "1 hour", date: "Aug 3", calories: "100 kcal"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Created by Matt Bruce on 12/20/24.
|
// Created by Matt Bruce on 12/20/24.
|
||||||
//
|
//
|
||||||
|
import SwiftUI
|
||||||
import Foundation
|
import Foundation
|
||||||
import HealthKit
|
import HealthKit
|
||||||
|
|
||||||
@ -13,110 +13,569 @@ extension Date {
|
|||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
return calendar.startOfDay(for: Date())
|
return calendar.startOfDay(for: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var startOfWeek: Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())
|
||||||
|
components.weekday = 2
|
||||||
|
return calendar.date(from: components) ?? Date()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Double {
|
||||||
|
func formattedNumberString() -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
formatter.maximumFractionDigits = 0
|
||||||
|
return formatter.string(from: NSNumber(value: self)) ?? "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A centralized manager for accessing HealthKit data
|
||||||
class HealthManager {
|
class HealthManager {
|
||||||
|
|
||||||
static let shared = HealthManager()
|
static let shared = HealthManager()
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
let healthStore = HKHealthStore()
|
private init() {
|
||||||
|
|
||||||
private init () {
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await requestHealthKitAccess()
|
try await requestHealthKitAccess()
|
||||||
} catch {
|
} catch {
|
||||||
|
print("Failed to request HealthKit access: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HealthKit Authorization
|
||||||
|
extension HealthManager {
|
||||||
|
/// Requests HealthKit access for necessary data types
|
||||||
func requestHealthKitAccess() async throws {
|
func requestHealthKitAccess() async throws {
|
||||||
let calories = HKQuantityType(.activeEnergyBurned)
|
let readTypes: Set<HKObjectType> = [
|
||||||
let exercise = HKQuantityType(.appleExerciseTime)
|
HKQuantityType(.activeEnergyBurned),
|
||||||
let stand = HKCategoryType(.appleStandHour)
|
HKQuantityType(.appleExerciseTime),
|
||||||
|
HKCategoryType(.appleStandHour),
|
||||||
let healthTypes: Set = [calories, exercise, stand]
|
HKQuantityType(.stepCount),
|
||||||
|
HKQuantityType.workoutType()
|
||||||
try await healthStore.requestAuthorization(toShare: [], read: healthTypes)
|
]
|
||||||
|
try await healthStore.requestAuthorization(toShare: [], read: readTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchTodayCaloriesBurned(completion: @escaping(Result<Double, Error>) -> Void) {
|
/// Ensures HealthKit data is available on this device
|
||||||
|
private func ensureHealthDataAvailable() throws {
|
||||||
guard HKHealthStore.isHealthDataAvailable() else {
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
completion(.failure(NSError(domain: "HealthManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Health data unavailable"])))
|
throw HealthManagerError.healthDataUnavailable
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let calories = HKQuantityType(.activeEnergyBurned)
|
// MARK: - Error Handling
|
||||||
let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date())
|
extension HealthManager {
|
||||||
|
enum HealthManagerError: Error {
|
||||||
|
case healthDataUnavailable
|
||||||
|
case noDataFound(String)
|
||||||
|
|
||||||
let query = HKStatisticsQuery(quantityType: calories, quantitySamplePredicate: predicate) { _, results, error in
|
var localizedDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .healthDataUnavailable:
|
||||||
|
return "Health data is unavailable on this device."
|
||||||
|
case .noDataFound(let dataType):
|
||||||
|
return "No \(dataType) data found for today."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Query Execution
|
||||||
|
extension HealthManager {
|
||||||
|
/// Executes a statistics query and processes the result
|
||||||
|
private func executeStatisticsQuery(
|
||||||
|
quantityType: HKQuantityType,
|
||||||
|
predicate: NSPredicate,
|
||||||
|
unit: HKUnit,
|
||||||
|
completion: @escaping (Result<Double, Error>) -> Void
|
||||||
|
) {
|
||||||
|
let query = HKStatisticsQuery(quantityType: quantityType, quantitySamplePredicate: predicate) { _, results, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let quantity = results?.sumQuantity() {
|
if let quantity = results?.sumQuantity() {
|
||||||
let caloriesBurned = quantity.doubleValue(for: .kilocalorie())
|
let value = quantity.doubleValue(for: unit)
|
||||||
completion(.success(caloriesBurned))
|
completion(.success(value))
|
||||||
} else {
|
} else {
|
||||||
completion(.failure(NSError(domain: "HealthManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "No data found for today's calories"])))
|
completion(.failure(HealthManagerError.noDataFound(quantityType.identifier)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
healthStore.execute(query)
|
healthStore.execute(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchTodayExerciseTime(completion: @escaping(Result<Double, Error>) -> Void) {
|
/// Executes a sample query and processes the result
|
||||||
guard HKHealthStore.isHealthDataAvailable() else {
|
private func executeSampleQuery(
|
||||||
completion(.failure(NSError(domain: "HealthManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Health data unavailable"])))
|
sampleType: HKSampleType,
|
||||||
return
|
predicate: NSPredicate,
|
||||||
}
|
completion: @escaping (Result<[HKSample], Error>) -> Void
|
||||||
|
) {
|
||||||
let exercise = HKQuantityType(.appleExerciseTime)
|
let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, results, error in
|
||||||
let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date())
|
|
||||||
|
|
||||||
let query = HKStatisticsQuery(quantityType: exercise, quantitySamplePredicate: predicate) { _, results, error in
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let quantity = results?.sumQuantity() {
|
guard let samples = results else {
|
||||||
let exerciseTime = quantity.doubleValue(for: .minute())
|
completion(.failure(HealthManagerError.noDataFound(sampleType.identifier)))
|
||||||
completion(.success(exerciseTime))
|
|
||||||
} else {
|
|
||||||
completion(.failure(NSError(domain: "HealthManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "No exercise time data found for today."])))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
healthStore.execute(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchTodayStandHours(completion: @escaping(Result<Double, Error>) -> Void) {
|
|
||||||
guard HKHealthStore.isHealthDataAvailable() else {
|
|
||||||
completion(.failure(NSError(domain: "HealthManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Health data unavailable"])))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let stand = HKCategoryType(.appleStandHour)
|
|
||||||
let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date())
|
|
||||||
|
|
||||||
let query = HKSampleQuery(sampleType: stand, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, results, error in
|
|
||||||
if let error = error {
|
|
||||||
completion(.failure(error))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let samples = results as? [HKCategorySample], !samples.isEmpty else {
|
completion(.success(samples))
|
||||||
completion(.failure(NSError(domain: "HealthManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "No stand data found for today."])))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count stand hours
|
|
||||||
let standHours = samples.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }.count
|
|
||||||
completion(.success(Double(standHours)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
healthStore.execute(query)
|
healthStore.execute(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch Methods
|
||||||
|
extension HealthManager {
|
||||||
|
/// Fetches the active calories burned today
|
||||||
|
func fetchTodayCaloriesBurned(completion: @escaping (Result<Double, Error>) -> Void) {
|
||||||
|
do {
|
||||||
|
try ensureHealthDataAvailable()
|
||||||
|
let quantityType = HKQuantityType(.activeEnergyBurned)
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date())
|
||||||
|
executeStatisticsQuery(quantityType: quantityType, predicate: predicate, unit: .kilocalorie(), completion: completion)
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the exercise time today
|
||||||
|
func fetchTodayExerciseTime(completion: @escaping (Result<Double, Error>) -> Void) {
|
||||||
|
do {
|
||||||
|
try ensureHealthDataAvailable()
|
||||||
|
let quantityType = HKQuantityType(.appleExerciseTime)
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date())
|
||||||
|
executeStatisticsQuery(quantityType: quantityType, predicate: predicate, unit: .minute(), completion: completion)
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the number of stand hours today
|
||||||
|
func fetchTodayStandHours(completion: @escaping (Result<Double, Error>) -> Void) {
|
||||||
|
do {
|
||||||
|
try ensureHealthDataAvailable()
|
||||||
|
let categoryType = HKCategoryType(.appleStandHour)
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date())
|
||||||
|
executeSampleQuery(sampleType: categoryType, predicate: predicate) { result in
|
||||||
|
switch result {
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
case .success(let samples):
|
||||||
|
// Ensure samples are cast to HKCategorySample
|
||||||
|
guard let categorySamples = samples as? [HKCategorySample] else {
|
||||||
|
completion(.failure(HealthManagerError.noDataFound("stand hours")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count stand hours
|
||||||
|
let standHours = categorySamples.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }.count
|
||||||
|
completion(.success(Double(standHours)))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTodaySteps(completion: @escaping (Result<Activity, Error>) -> Void) {
|
||||||
|
do {
|
||||||
|
try ensureHealthDataAvailable()
|
||||||
|
let quantityType = HKQuantityType(.stepCount)
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date(), options: .strictStartDate)
|
||||||
|
executeStatisticsQuery(quantityType: quantityType, predicate: predicate, unit: .count()) { result in
|
||||||
|
switch result {
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
case .success(let steps):
|
||||||
|
let activity = Activity(title: "Today Steps", subtitle: "Goal: 800", image: "figure.walk", tintColor: .green, amount: steps.formattedNumberString())
|
||||||
|
completion(.success(activity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentWeekWorkoutStats(completion: @escaping (Result<[Activity], Error>) -> Void) {
|
||||||
|
do {
|
||||||
|
try ensureHealthDataAvailable()
|
||||||
|
|
||||||
|
let workoutType = HKObjectType.workoutType()
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: .startOfWeek, end: Date())
|
||||||
|
|
||||||
|
executeSampleQuery(sampleType: workoutType, predicate: predicate) { result in
|
||||||
|
switch result {
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
|
||||||
|
case .success(let samples):
|
||||||
|
guard let workouts = samples as? [HKWorkout] else {
|
||||||
|
completion(.failure(HealthManagerError.noDataFound("workout stats for the current week")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group and aggregate workouts by activity type
|
||||||
|
var aggregatedWorkouts: [HKWorkoutActivityType: (duration: Double, calories: Double)] = [:]
|
||||||
|
|
||||||
|
for workout in workouts {
|
||||||
|
let activityType = workout.workoutActivityType
|
||||||
|
let duration = workout.duration / 60 // Convert seconds to minutes
|
||||||
|
let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0.0
|
||||||
|
|
||||||
|
if aggregatedWorkouts[activityType] == nil {
|
||||||
|
aggregatedWorkouts[activityType] = (duration: 0.0, calories: 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregatedWorkouts[activityType]?.duration += duration
|
||||||
|
aggregatedWorkouts[activityType]?.calories += calories
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert aggregated results into Activity objects
|
||||||
|
let activities: [Activity] = aggregatedWorkouts.map { activityType, totals in
|
||||||
|
let durationString = "\(totals.duration.formattedNumberString()) min"
|
||||||
|
let caloriesString = "\(totals.calories.formattedNumberString()) kcal"
|
||||||
|
|
||||||
|
return Activity(
|
||||||
|
title: activityType.readableName,
|
||||||
|
subtitle: "This week: \(durationString)",
|
||||||
|
image: activityType.image,
|
||||||
|
tintColor: activityType.tintColor,
|
||||||
|
amount: caloriesString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(.success(activities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWorkoutsForMonth(month: Date, completion: @escaping (Result<[Workout], Error>) -> Void) {
|
||||||
|
do {
|
||||||
|
try ensureHealthDataAvailable()
|
||||||
|
|
||||||
|
let workoutType = HKObjectType.workoutType()
|
||||||
|
|
||||||
|
// Calculate the start and end dates for the specified month
|
||||||
|
let calendar = Calendar.current
|
||||||
|
guard let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)),
|
||||||
|
let endOfMonth = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: startOfMonth) else {
|
||||||
|
completion(.failure(HealthManagerError.noDataFound("Invalid month date range.")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: startOfMonth, end: endOfMonth)
|
||||||
|
|
||||||
|
executeSampleQuery(sampleType: workoutType, predicate: predicate) { result in
|
||||||
|
switch result {
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
|
||||||
|
case .success(let samples):
|
||||||
|
guard let workouts = samples as? [HKWorkout] else {
|
||||||
|
completion(.failure(HealthManagerError.noDataFound("workouts for the specified month.")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert workouts into your Workout model (if needed)
|
||||||
|
let mappedWorkouts: [Workout] = workouts.map { workout in
|
||||||
|
Workout(title: workout.workoutActivityType.readableName,
|
||||||
|
image: workout.workoutActivityType.image,
|
||||||
|
tintColor: workout.workoutActivityType.tintColor,
|
||||||
|
duration: "\((workout.duration / 60).formattedNumberString())",
|
||||||
|
date: workout.startDate.formatted(date: .abbreviated, time: .omitted),
|
||||||
|
calories: "\((workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0.0).formattedNumberString())")
|
||||||
|
}
|
||||||
|
completion(.success(mappedWorkouts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HKWorkoutActivityType {
|
||||||
|
var readableName: String {
|
||||||
|
switch self {
|
||||||
|
case .americanFootball: return "American Football"
|
||||||
|
case .archery: return "Archery"
|
||||||
|
case .australianFootball: return "Australian Football"
|
||||||
|
case .badminton: return "Badminton"
|
||||||
|
case .baseball: return "Baseball"
|
||||||
|
case .basketball: return "Basketball"
|
||||||
|
case .bowling: return "Bowling"
|
||||||
|
case .boxing: return "Boxing"
|
||||||
|
case .climbing: return "Climbing"
|
||||||
|
case .cricket: return "Cricket"
|
||||||
|
case .crossTraining: return "Cross Training"
|
||||||
|
case .curling: return "Curling"
|
||||||
|
case .cycling: return "Cycling"
|
||||||
|
case .dance: return "Dance"
|
||||||
|
case .elliptical: return "Elliptical"
|
||||||
|
case .equestrianSports: return "Equestrian Sports"
|
||||||
|
case .fencing: return "Fencing"
|
||||||
|
case .fishing: return "Fishing"
|
||||||
|
case .functionalStrengthTraining: return "Functional Strength Training"
|
||||||
|
case .golf: return "Golf"
|
||||||
|
case .gymnastics: return "Gymnastics"
|
||||||
|
case .handball: return "Handball"
|
||||||
|
case .hiking: return "Hiking"
|
||||||
|
case .hockey: return "Hockey"
|
||||||
|
case .lacrosse: return "Lacrosse"
|
||||||
|
case .martialArts: return "Martial Arts"
|
||||||
|
case .mindAndBody: return "Mind and Body"
|
||||||
|
case .paddleSports: return "Paddle Sports"
|
||||||
|
case .play: return "Play"
|
||||||
|
case .preparationAndRecovery: return "Preparation and Recovery"
|
||||||
|
case .racquetball: return "Racquetball"
|
||||||
|
case .rowing: return "Rowing"
|
||||||
|
case .rugby: return "Rugby"
|
||||||
|
case .running: return "Running"
|
||||||
|
case .sailing: return "Sailing"
|
||||||
|
case .skatingSports: return "Skating Sports"
|
||||||
|
case .snowSports: return "Snow Sports"
|
||||||
|
case .soccer: return "Soccer"
|
||||||
|
case .softball: return "Softball"
|
||||||
|
case .squash: return "Squash"
|
||||||
|
case .stairClimbing: return "Stair Climbing"
|
||||||
|
case .surfingSports: return "Surfing Sports"
|
||||||
|
case .swimming: return "Swimming"
|
||||||
|
case .tableTennis: return "Table Tennis"
|
||||||
|
case .tennis: return "Tennis"
|
||||||
|
case .trackAndField: return "Track and Field"
|
||||||
|
case .traditionalStrengthTraining: return "Traditional Strength Training"
|
||||||
|
case .volleyball: return "Volleyball"
|
||||||
|
case .walking: return "Walking"
|
||||||
|
case .waterFitness: return "Water Fitness"
|
||||||
|
case .waterPolo: return "Water Polo"
|
||||||
|
case .waterSports: return "Water Sports"
|
||||||
|
case .wrestling: return "Wrestling"
|
||||||
|
case .yoga: return "Yoga"
|
||||||
|
case .barre: return "Barre"
|
||||||
|
case .coreTraining: return "Core Training"
|
||||||
|
case .crossCountrySkiing: return "Cross Country Skiing"
|
||||||
|
case .downhillSkiing: return "Downhill Skiing"
|
||||||
|
case .flexibility: return "Flexibility"
|
||||||
|
case .highIntensityIntervalTraining: return "High-Intensity Interval Training (HIIT)"
|
||||||
|
case .jumpRope: return "Jump Rope"
|
||||||
|
case .kickboxing: return "Kickboxing"
|
||||||
|
case .pilates: return "Pilates"
|
||||||
|
case .snowboarding: return "Snowboarding"
|
||||||
|
case .stairs: return "Stairs"
|
||||||
|
case .stepTraining: return "Step Training"
|
||||||
|
case .wheelchairWalkPace: return "Wheelchair Walk Pace"
|
||||||
|
case .wheelchairRunPace: return "Wheelchair Run Pace"
|
||||||
|
case .taiChi: return "Tai Chi"
|
||||||
|
case .mixedCardio: return "Mixed Cardio"
|
||||||
|
case .handCycling: return "Hand Cycling"
|
||||||
|
case .discSports: return "Disc Sports"
|
||||||
|
case .fitnessGaming: return "Fitness Gaming"
|
||||||
|
case .danceInspiredTraining: return "Dance-Inspired Training"
|
||||||
|
case .hunting: return "Hunting"
|
||||||
|
case .mixedMetabolicCardioTraining: return "Mixed Metabolic Cardio Training"
|
||||||
|
case .cardioDance: return "Cardio Dance"
|
||||||
|
case .socialDance: return "Social Dance"
|
||||||
|
case .pickleball: return "Pickleball"
|
||||||
|
case .cooldown: return "Cooldown"
|
||||||
|
case .swimBikeRun: return "Swim Bike Run"
|
||||||
|
case .transition: return "Transition"
|
||||||
|
case .underwaterDiving: return "Underwater Diving"
|
||||||
|
case .other: return "Other"
|
||||||
|
@unknown default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var image: String {
|
||||||
|
switch self {
|
||||||
|
case .americanFootball: return "sportscourt.fill"
|
||||||
|
case .archery: return "scope"
|
||||||
|
case .australianFootball: return "sportscourt"
|
||||||
|
case .badminton: return "figure.badminton"
|
||||||
|
case .baseball: return "baseball.fill"
|
||||||
|
case .basketball: return "sportscourt"
|
||||||
|
case .bowling: return "bowl"
|
||||||
|
case .boxing: return "boxing.glove.fill"
|
||||||
|
case .climbing: return "figure.climbing"
|
||||||
|
case .cricket: return "figure.cricket"
|
||||||
|
case .crossTraining: return "figure.cross.training"
|
||||||
|
case .curling: return "curling.stone"
|
||||||
|
case .cycling: return "bicycle"
|
||||||
|
case .dance: return "figure.dance"
|
||||||
|
case .elliptical: return "figure.elliptical"
|
||||||
|
case .equestrianSports: return "figure.equestrian.sports"
|
||||||
|
case .fencing: return "swords"
|
||||||
|
case .fishing: return "fish"
|
||||||
|
case .functionalStrengthTraining: return "figure.strengthtraining.functional"
|
||||||
|
case .golf: return "flag"
|
||||||
|
case .gymnastics: return "figure.gymnastics"
|
||||||
|
case .handball: return "sportscourt"
|
||||||
|
case .hiking: return "figure.hiking"
|
||||||
|
case .hockey: return "figure.hockey"
|
||||||
|
case .lacrosse: return "figure.lacrosse"
|
||||||
|
case .martialArts: return "figure.martial.arts"
|
||||||
|
case .mindAndBody: return "figure.mind.and.body"
|
||||||
|
case .paddleSports: return "figure.paddle.sports"
|
||||||
|
case .play: return "figure.play"
|
||||||
|
case .preparationAndRecovery: return "figure.preparation.recovery"
|
||||||
|
case .racquetball: return "figure.racquetball"
|
||||||
|
case .rowing: return "figure.rowing"
|
||||||
|
case .rugby: return "figure.rugby"
|
||||||
|
case .running: return "figure.run"
|
||||||
|
case .sailing: return "sailboat.fill"
|
||||||
|
case .skatingSports: return "figure.skating"
|
||||||
|
case .snowSports: return "snowflake"
|
||||||
|
case .soccer: return "soccerball"
|
||||||
|
case .softball: return "softball.fill"
|
||||||
|
case .squash: return "figure.squash"
|
||||||
|
case .stairClimbing: return "figure.stair.climbing"
|
||||||
|
case .surfingSports: return "figure.surfing"
|
||||||
|
case .swimming: return "figure.swimming"
|
||||||
|
case .tableTennis: return "figure.table.tennis"
|
||||||
|
case .tennis: return "tennis.racket"
|
||||||
|
case .trackAndField: return "sportscourt"
|
||||||
|
case .traditionalStrengthTraining: return "figure.strengthtraining.traditional"
|
||||||
|
case .volleyball: return "figure.volleyball"
|
||||||
|
case .walking: return "figure.walk"
|
||||||
|
case .waterFitness: return "figure.water.fitness"
|
||||||
|
case .waterPolo: return "figure.water.polo"
|
||||||
|
case .waterSports: return "figure.water.sports"
|
||||||
|
case .wrestling: return "figure.wrestling"
|
||||||
|
case .yoga: return "figure.yoga"
|
||||||
|
case .barre: return "figure.barre"
|
||||||
|
case .coreTraining: return "figure.core.training"
|
||||||
|
case .crossCountrySkiing: return "figure.cross.country.skiing"
|
||||||
|
case .downhillSkiing: return "figure.downhill.skiing"
|
||||||
|
case .flexibility: return "figure.flexibility"
|
||||||
|
case .highIntensityIntervalTraining: return "figure.highintensity.intervaltraining"
|
||||||
|
case .jumpRope: return "figure.jump.rope"
|
||||||
|
case .kickboxing: return "figure.kickboxing"
|
||||||
|
case .pilates: return "figure.pilates"
|
||||||
|
case .snowboarding: return "figure.snowboarding"
|
||||||
|
case .stairs: return "figure.stairs"
|
||||||
|
case .stepTraining: return "figure.step.training"
|
||||||
|
case .wheelchairWalkPace: return "figure.wheelchair.walk"
|
||||||
|
case .wheelchairRunPace: return "figure.wheelchair.run"
|
||||||
|
case .taiChi: return "figure.taichi"
|
||||||
|
case .mixedCardio: return "figure.mixed.cardio"
|
||||||
|
case .handCycling: return "figure.hand.cycling"
|
||||||
|
case .discSports: return "figure.disc.sports"
|
||||||
|
case .fitnessGaming: return "figure.fitness.gaming"
|
||||||
|
case .danceInspiredTraining: return "figure.dance"
|
||||||
|
case .hunting: return "figure.hunting"
|
||||||
|
case .mixedMetabolicCardioTraining: return "figure.mixed.cardio"
|
||||||
|
case .cardioDance: return "figure.dance"
|
||||||
|
case .socialDance: return "figure.dance"
|
||||||
|
case .pickleball: return "figure.pickleball"
|
||||||
|
case .cooldown: return "figure.cooldown"
|
||||||
|
case .swimBikeRun: return "figure.swim.bike.run"
|
||||||
|
case .transition: return "figure.transition"
|
||||||
|
case .underwaterDiving: return "figure.underwater.diving"
|
||||||
|
case .other: return "ellipsis.circle"
|
||||||
|
@unknown default: return "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tintColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .americanFootball: return .brown
|
||||||
|
case .archery: return .blue
|
||||||
|
case .australianFootball: return .orange
|
||||||
|
case .badminton: return .green
|
||||||
|
case .baseball: return .blue
|
||||||
|
case .basketball: return .orange
|
||||||
|
case .bowling: return .purple
|
||||||
|
case .boxing: return .red
|
||||||
|
case .climbing: return .brown
|
||||||
|
case .cricket: return .green
|
||||||
|
case .crossTraining: return .yellow
|
||||||
|
case .curling: return .cyan
|
||||||
|
case .cycling: return .teal
|
||||||
|
case .dance: return .pink
|
||||||
|
case .elliptical: return .gray
|
||||||
|
case .equestrianSports: return .brown
|
||||||
|
case .fencing: return .gray
|
||||||
|
case .fishing: return .blue
|
||||||
|
case .functionalStrengthTraining: return .indigo
|
||||||
|
case .golf: return .green
|
||||||
|
case .gymnastics: return .purple
|
||||||
|
case .handball: return .red
|
||||||
|
case .hiking: return .green
|
||||||
|
case .hockey: return .indigo
|
||||||
|
case .lacrosse: return .pink
|
||||||
|
case .martialArts: return .red
|
||||||
|
case .mindAndBody: return .purple
|
||||||
|
case .paddleSports: return .blue
|
||||||
|
case .play: return .yellow
|
||||||
|
case .preparationAndRecovery: return .gray
|
||||||
|
case .racquetball: return .teal
|
||||||
|
case .rowing: return .blue
|
||||||
|
case .rugby: return .brown
|
||||||
|
case .running: return .red
|
||||||
|
case .sailing: return .blue
|
||||||
|
case .skatingSports: return .cyan
|
||||||
|
case .snowSports: return .white
|
||||||
|
case .soccer: return .green
|
||||||
|
case .softball: return .orange
|
||||||
|
case .squash: return .purple
|
||||||
|
case .stairClimbing: return .gray
|
||||||
|
case .surfingSports: return .blue
|
||||||
|
case .swimming: return .cyan
|
||||||
|
case .tableTennis: return .orange
|
||||||
|
case .tennis: return .green
|
||||||
|
case .trackAndField: return .yellow
|
||||||
|
case .traditionalStrengthTraining: return .gray
|
||||||
|
case .volleyball: return .orange
|
||||||
|
case .walking: return .green
|
||||||
|
case .waterFitness: return .blue
|
||||||
|
case .waterPolo: return .blue
|
||||||
|
case .waterSports: return .cyan
|
||||||
|
case .wrestling: return .red
|
||||||
|
case .yoga: return .purple
|
||||||
|
case .barre: return .pink
|
||||||
|
case .coreTraining: return .gray
|
||||||
|
case .crossCountrySkiing: return .cyan
|
||||||
|
case .downhillSkiing: return .white
|
||||||
|
case .flexibility: return .purple
|
||||||
|
case .highIntensityIntervalTraining: return .red
|
||||||
|
case .jumpRope: return .orange
|
||||||
|
case .kickboxing: return .red
|
||||||
|
case .pilates: return .purple
|
||||||
|
case .snowboarding: return .cyan
|
||||||
|
case .stairs: return .gray
|
||||||
|
case .stepTraining: return .orange
|
||||||
|
case .wheelchairWalkPace: return .green
|
||||||
|
case .wheelchairRunPace: return .red
|
||||||
|
case .taiChi: return .blue
|
||||||
|
case .mixedCardio: return .yellow
|
||||||
|
case .handCycling: return .teal
|
||||||
|
case .discSports: return .orange
|
||||||
|
case .fitnessGaming: return .purple
|
||||||
|
case .danceInspiredTraining: return .pink
|
||||||
|
case .hunting: return .brown
|
||||||
|
case .mixedMetabolicCardioTraining: return .yellow
|
||||||
|
case .cardioDance: return .pink
|
||||||
|
case .socialDance: return .pink
|
||||||
|
case .pickleball: return .green
|
||||||
|
case .cooldown: return .gray
|
||||||
|
case .swimBikeRun: return .blue
|
||||||
|
case .transition: return .gray
|
||||||
|
case .underwaterDiving: return .cyan
|
||||||
|
case .other: return .gray
|
||||||
|
@unknown default: return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user