582 lines
24 KiB
Swift
582 lines
24 KiB
Swift
//
|
|
// HealthManager.swift
|
|
// FitnessApp
|
|
//
|
|
// Created by Matt Bruce on 12/20/24.
|
|
//
|
|
import SwiftUI
|
|
import Foundation
|
|
import HealthKit
|
|
|
|
extension Date {
|
|
static var startOfDay: Date {
|
|
let calendar = Calendar.current
|
|
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 {
|
|
static let shared = HealthManager()
|
|
private let healthStore = HKHealthStore()
|
|
|
|
private init() {
|
|
Task {
|
|
do {
|
|
try await requestHealthKitAccess()
|
|
} 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 {
|
|
let readTypes: Set<HKObjectType> = [
|
|
HKQuantityType(.activeEnergyBurned),
|
|
HKQuantityType(.appleExerciseTime),
|
|
HKCategoryType(.appleStandHour),
|
|
HKQuantityType(.stepCount),
|
|
HKQuantityType.workoutType()
|
|
]
|
|
try await healthStore.requestAuthorization(toShare: [], read: readTypes)
|
|
}
|
|
|
|
/// Ensures HealthKit data is available on this device
|
|
private func ensureHealthDataAvailable() throws {
|
|
guard HKHealthStore.isHealthDataAvailable() else {
|
|
throw HealthManagerError.healthDataUnavailable
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Handling
|
|
extension HealthManager {
|
|
enum HealthManagerError: Error {
|
|
case healthDataUnavailable
|
|
case noDataFound(String)
|
|
|
|
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 {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
|
|
if let quantity = results?.sumQuantity() {
|
|
let value = quantity.doubleValue(for: unit)
|
|
completion(.success(value))
|
|
} else {
|
|
completion(.failure(HealthManagerError.noDataFound(quantityType.identifier)))
|
|
}
|
|
}
|
|
healthStore.execute(query)
|
|
}
|
|
|
|
/// Executes a sample query and processes the result
|
|
private func executeSampleQuery(
|
|
sampleType: HKSampleType,
|
|
predicate: NSPredicate,
|
|
completion: @escaping (Result<[HKSample], Error>) -> Void
|
|
) {
|
|
let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, results, error in
|
|
if let error = error {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
|
|
guard let samples = results else {
|
|
completion(.failure(HealthManagerError.noDataFound(sampleType.identifier)))
|
|
return
|
|
}
|
|
|
|
completion(.success(samples))
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|