From 182e27b873a00c4b38cfd499e595a37748ff4aa2 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Tue, 24 Feb 2026 15:41:38 +0100 Subject: [PATCH] Modernize codebase for iOS 17+ - Convert HealthKitManager to @Observable with async/await, replace deprecated HKWorkout initializer with HKWorkoutBuilder - Replace NavigationView with NavigationStack, use modern toolbar and .alert APIs - Fix fullScreenCover/EmptyView navigation hack with @Environment(\.dismiss) - Add pull-to-refresh, empty state, duration display, date+time pickers, inline validation - Expand activity types from 3 to 20, improve privacy descriptions --- WorkoutEditor.xcodeproj/project.pbxproj | 4 +- WorkoutEditor/HealthKitManager.swift | 106 +++++------ WorkoutEditor/Info.plist | 4 +- WorkoutEditor/WorkoutAddView.swift | 150 ++++++++------- WorkoutEditor/WorkoutEditView.swift | 240 ++++++++---------------- WorkoutEditor/WorkoutEditorApp.swift | 23 +-- WorkoutEditor/WorkoutListView.swift | 74 +++++--- 7 files changed, 265 insertions(+), 336 deletions(-) diff --git a/WorkoutEditor.xcodeproj/project.pbxproj b/WorkoutEditor.xcodeproj/project.pbxproj index 11c17d1..4ea8140 100644 --- a/WorkoutEditor.xcodeproj/project.pbxproj +++ b/WorkoutEditor.xcodeproj/project.pbxproj @@ -221,7 +221,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -275,7 +275,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/WorkoutEditor/HealthKitManager.swift b/WorkoutEditor/HealthKitManager.swift index 0940f98..f2b096a 100644 --- a/WorkoutEditor/HealthKitManager.swift +++ b/WorkoutEditor/HealthKitManager.swift @@ -1,91 +1,69 @@ -// -// HealthKitManager.swift -// WorkoutEditor -// -// Created by Vladyslav on 23.12.2023. -// - import Foundation import HealthKit -class HealthKitManager: ObservableObject { +@Observable +class HealthKitManager { static let shared = HealthKitManager() - @Published var loadedWorkouts: [HKWorkout]? + var loadedWorkouts: [HKWorkout] = [] private let healthStore = HKHealthStore() - - // Request authorization for specific health data types - func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) { + + func requestAuthorization() async throws { guard HKHealthStore.isHealthDataAvailable() else { - completion(false, nil) - return + throw HealthKitError.healthDataNotAvailable } let typesToRead: Set = [HKObjectType.workoutType()] - let typesToWrite: Set = [HKObjectType.workoutType()] - healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { (success, error) in - completion(success, error) - } + try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) } - func save(_ workout: HKWorkout, completion: @escaping (Bool, Error?) -> Void) { - self.healthStore.save(workout) { success, error in - completion(success, error) - } - } - - func loadWorkouts() { - let workoutType = HKObjectType.workoutType() - let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) - let query = HKSampleQuery(sampleType: workoutType, predicate: nil, limit: 100, sortDescriptors: [sortDescriptor]) { (query, samples, error) in - guard let workouts = samples as? [HKWorkout] else { - return - } - - // Store the loaded workouts and trigger an update - DispatchQueue.main.async { - self.loadedWorkouts = workouts + func loadWorkouts() async { + let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse) + let descriptor = HKSampleQueryDescriptor( + predicates: [.workout()], + sortDescriptors: [sortDescriptor], + limit: 100 + ) + + do { + let results = try await descriptor.result(for: healthStore) + await MainActor.run { + self.loadedWorkouts = results } + } catch { + print("Error loading workouts: \(error.localizedDescription)") } - - self.healthStore.execute(query) } - - func fetchWorkout(withUUID uuid: UUID, completion: @escaping (HKWorkout?, Error?) -> Void) { - guard HKHealthStore.isHealthDataAvailable() else { - completion(nil, HealthKitError.healthDataNotAvailable) - return - } - let workoutType = HKObjectType.workoutType() - let predicate = HKQuery.predicateForObject(with: uuid) + func saveWorkout(activityType: HKWorkoutActivityType, start: Date, end: Date, metadata: [String: Any]? = nil) async throws { + let configuration = HKWorkoutConfiguration() + configuration.activityType = activityType - let query = HKSampleQuery( - sampleType: workoutType, - predicate: predicate, - limit: HKObjectQueryNoLimit, - sortDescriptors: nil - ) { (query, samples, error) in - guard let workouts = samples as? [HKWorkout], let workout = workouts.first else { - completion(nil, error) - return - } - completion(workout, nil) + let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: nil) + try await builder.beginCollection(at: start) + try await builder.endCollection(at: end) + + if let metadata = metadata { + try await builder.addMetadata(metadata) } - healthStore.execute(query) + try await builder.finishWorkout() } - - func deleteWorkout(_ workout: HKWorkout, completion: @escaping (Bool, Error?) -> Void) { - healthStore.delete(workout) { success, error in - completion(success, error) - } + + func deleteWorkout(_ workout: HKWorkout) async throws { + try await healthStore.delete(workout) } } -enum HealthKitError: Error { +enum HealthKitError: LocalizedError { case healthDataNotAvailable - // Add more error cases as needed + + var errorDescription: String? { + switch self { + case .healthDataNotAvailable: + return "Health data is not available on this device." + } + } } diff --git a/WorkoutEditor/Info.plist b/WorkoutEditor/Info.plist index f713856..6ce989e 100644 --- a/WorkoutEditor/Info.plist +++ b/WorkoutEditor/Info.plist @@ -5,8 +5,8 @@ UIBackgroundModes NSHealthShareUsageDescription - We need to read data + Workout Editor needs to read your workouts so you can view and edit them. NSHealthUpdateUsageDescription - We need to write data + Workout Editor needs to save changes when you edit or trim your workouts. diff --git a/WorkoutEditor/WorkoutAddView.swift b/WorkoutEditor/WorkoutAddView.swift index 614ab14..f053c53 100644 --- a/WorkoutEditor/WorkoutAddView.swift +++ b/WorkoutEditor/WorkoutAddView.swift @@ -1,95 +1,115 @@ -// -// ContentView.swift -// WorkoutEditor -// -// Created by Vladyslav on 22.12.2023. -// - import SwiftUI import HealthKit struct WorkoutAddView: View { + @Environment(\.dismiss) private var dismiss + @State private var selectedActivityType: HKWorkoutActivityType = .running @State private var startTime = Date() @State private var endTime = Date() + @State private var errorMessage: String? + + private var isValid: Bool { + endTime > startTime + } - var activityTypes: [HKWorkoutActivityType] = [ - .running, - .cycling, - .swimming, - // Add more activity types as needed - ] - @State private var isSaveSuccessful: Bool = false var body: some View { - NavigationView { - Form() { - Section(header: Text("New Workout Details")) { - DatePicker("Start Time", selection: $startTime, displayedComponents: .hourAndMinute) - .datePickerStyle(GraphicalDatePickerStyle()) - - DatePicker("End Time", selection: $endTime, displayedComponents: .hourAndMinute) - .datePickerStyle(GraphicalDatePickerStyle()) - - Picker("Activity Type", selection: $selectedActivityType) { - ForEach(activityTypes, id: \.self) { activityType in - Text(activityType.activityTypeDescription).tag(activityType) - } + Form { + Section("New Workout Details") { + DatePicker("Start", selection: $startTime) + DatePicker("End", selection: $endTime) + + Picker("Activity Type", selection: $selectedActivityType) { + ForEach(HKWorkoutActivityType.commonTypes, id: \.self) { type in + Text(type.activityTypeDescription).tag(type) } } - + } + + if !isValid { Section { - Button(action: saveWorkout) { - Text("Save Workout") - } - .background( - EmptyView() - .fullScreenCover(isPresented: $isSaveSuccessful, content: { - WorkoutListView() - }) - ) + Label("End time must be after start time.", systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + + Section { + Button("Save Workout") { + saveWorkout() + } + .disabled(!isValid) + } + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "xmark.circle") + .foregroundStyle(.red) } } - .navigationTitle("Add New Workout") } + .navigationTitle("Add Workout") + .navigationBarTitleDisplayMode(.inline) } private func saveWorkout() { - let timezoneIdentifier = TimeZone.current.identifier - let metadata = [HKMetadataKeyTimeZone: timezoneIdentifier] - let workout = HKWorkout(activityType: selectedActivityType, start: startTime, end: endTime, workoutEvents: nil, totalEnergyBurned: nil, totalDistance: nil, metadata: metadata) - - HealthKitManager.shared.save(workout) { success, error in - if success { - // Workout saved successfully - print("Workout saved successfully") - isSaveSuccessful = true - } else { - // Handle error - print("Error saving workout: \(error?.localizedDescription ?? "Unknown error")") + Task { + do { + let metadata = [HKMetadataKeyTimeZone: TimeZone.current.identifier] + try await HealthKitManager.shared.saveWorkout( + activityType: selectedActivityType, + start: startTime, + end: endTime, + metadata: metadata + ) + await HealthKitManager.shared.loadWorkouts() + dismiss() + } catch { + errorMessage = error.localizedDescription } } } } -struct WorkoutAddView_Previews: PreviewProvider { - static var previews: some View { - WorkoutAddView() - } -} +// MARK: - Activity Type Helpers -// Define an extension to get the name of the activity type extension HKWorkoutActivityType { + static let commonTypes: [HKWorkoutActivityType] = [ + .running, .walking, .hiking, .cycling, .swimming, + .yoga, .functionalStrengthTraining, .traditionalStrengthTraining, + .crossTraining, .elliptical, .rowing, .stairClimbing, + .highIntensityIntervalTraining, .socialDance, .cooldown, + .coreTraining, .pilates, .kickboxing, .boxing, .climbing + ] + var activityTypeDescription: String { switch self { - case .running: - return "Running" - case .cycling: - return "Cycling" - case .swimming: - return "Swimming" - // Add more cases as needed - default: - return "Other" + case .running: "Running" + case .walking: "Walking" + case .hiking: "Hiking" + case .cycling: "Cycling" + case .swimming: "Swimming" + case .yoga: "Yoga" + case .functionalStrengthTraining: "Functional Strength" + case .traditionalStrengthTraining: "Strength Training" + case .crossTraining: "Cross Training" + case .elliptical: "Elliptical" + case .rowing: "Rowing" + case .stairClimbing: "Stair Climbing" + case .highIntensityIntervalTraining: "HIIT" + case .socialDance: "Dance" + case .cooldown: "Cooldown" + case .coreTraining: "Core Training" + case .pilates: "Pilates" + case .kickboxing: "Kickboxing" + case .boxing: "Boxing" + case .climbing: "Climbing" + default: "Other" } } } + +#Preview { + NavigationStack { + WorkoutAddView() + } +} diff --git a/WorkoutEditor/WorkoutEditView.swift b/WorkoutEditor/WorkoutEditView.swift index 5088153..fd3d978 100644 --- a/WorkoutEditor/WorkoutEditView.swift +++ b/WorkoutEditor/WorkoutEditView.swift @@ -1,195 +1,115 @@ -// -// WorkoutEditView.swift -// WorkoutEditor -// -// Created by Vladyslav on 23.12.2023. -// - import SwiftUI import HealthKit struct WorkoutEditView: View { - var workout: HKWorkout - + let workout: HKWorkout + @Environment(\.dismiss) private var dismiss + @State private var editedStartTime: Date @State private var editedEndTime: Date @State private var editedActivityType: HKWorkoutActivityType - + @State private var showSaveAlert = false + @State private var showDeleteAlert = false + @State private var errorMessage: String? + init(workout: HKWorkout) { self.workout = workout _editedStartTime = State(initialValue: workout.startDate) _editedEndTime = State(initialValue: workout.endDate) _editedActivityType = State(initialValue: workout.workoutActivityType) } - - var activityTypes: [HKWorkoutActivityType] = [ - .running, - .cycling, - .swimming, - // Add more activity types as needed - ] - - @State private var isSaveSuccessful: Bool = false - @State private var presentingRemoveAlert = false - @State private var presentingUpdateAlert = false - + + private var isValid: Bool { + editedEndTime > editedStartTime + } + var body: some View { - NavigationView { - Form() { - Section(header: Text("Workout Details")) { - DatePicker("Start Time", selection: $editedStartTime, displayedComponents: .hourAndMinute) - .datePickerStyle(GraphicalDatePickerStyle()) - - DatePicker("End Time", selection: $editedEndTime, displayedComponents: .hourAndMinute) - .datePickerStyle(GraphicalDatePickerStyle()) - - Picker("Activity Type", selection: $editedActivityType) { - ForEach(activityTypes, id: \.self) { activityType in - Text(activityType.activityTypeDescription).tag(activityType) - } + Form { + Section("Workout Details") { + DatePicker("Start", selection: $editedStartTime) + DatePicker("End", selection: $editedEndTime) + + Picker("Activity Type", selection: $editedActivityType) { + ForEach(HKWorkoutActivityType.commonTypes, id: \.self) { type in + Text(type.activityTypeDescription).tag(type) } } - + } + + if !isValid { Section { - Button(action: saveChanges) { - Text("Save Workout") - } - .alert(isPresented: $presentingUpdateAlert) { - Alert( - title: Text("Save Changes"), - message: Text("Are you sure you want to save these changes to the workout?"), - primaryButton: .default( - Text("Save"), - action: { - // Perform the update here - confirmSaveChanges() - } - ), - secondaryButton: .cancel() - ) - } - .background( - EmptyView() - .fullScreenCover(isPresented: $isSaveSuccessful, content: { - WorkoutListView() - }) - ) - Button(action: removeWorkout) { - Text("Remove Workout") - .foregroundColor(.red) - } - .alert(isPresented: - $presentingRemoveAlert) { - Alert( - title: Text("Remove Workout"), - message: Text("Are you sure you want to remove this workout? This action cannot be undone."), - primaryButton: .destructive( - Text("Remove"), - action: { - // Perform the deletion here - confirmRemoveWorkout() - } - ), - secondaryButton: .cancel() - ) - } - .background( - EmptyView() - .fullScreenCover(isPresented: $isSaveSuccessful, content: { - WorkoutListView() - }) - ) + Label("End time must be after start time.", systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) } } - .navigationTitle("Workout \(formattedDate(workout.startDate))") - } - } - - private func formattedDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "dd-MM-yyyy" - return formatter.string(from: date) - } - - private func removeWorkout() { - presentingRemoveAlert.toggle() - } - - private func confirmRemoveWorkout() { - HealthKitManager.shared.fetchWorkout(withUUID: workout.uuid) { originalWorkout, error in - guard let originalWorkout = originalWorkout else { - return + + Section { + Button("Save Changes") { + showSaveAlert = true + } + .disabled(!isValid) + .alert("Save Changes", isPresented: $showSaveAlert) { + Button("Save") { saveChanges() } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to save these changes?") + } + + Button("Delete Workout", role: .destructive) { + showDeleteAlert = true + } + .alert("Delete Workout", isPresented: $showDeleteAlert) { + Button("Delete", role: .destructive) { deleteWorkout() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This action cannot be undone.") + } } - - // Now, delete the original workout - HealthKitManager.shared.deleteWorkout(originalWorkout) { deleteSuccess, deleteError in - if deleteSuccess { - // Original workout deleted successfully - print("Original workout deleted successfully") - isSaveSuccessful = true - } else { - // Handle deletion error - print("Error deleting original workout: \(deleteError?.localizedDescription ?? "Unknown error")") + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "xmark.circle") + .foregroundStyle(.red) } } } + .navigationTitle(workout.workoutActivityType.activityTypeDescription) + .navigationBarTitleDisplayMode(.inline) } - - private func saveChanges() { - presentingUpdateAlert.toggle() - } - - private func confirmSaveChanges() { - guard editedEndTime > editedStartTime else { - // Show an alert or some feedback to the user that the end time must be after the start time. - return - } - // Fetch the original workout to update - HealthKitManager.shared.fetchWorkout(withUUID: workout.uuid) { originalWorkout, error in - guard let originalWorkout = originalWorkout else { - // Handle error or show an alert that the original workout couldn't be fetched - return + private func saveChanges() { + Task { + do { + try await HealthKitManager.shared.saveWorkout( + activityType: editedActivityType, + start: editedStartTime, + end: editedEndTime, + metadata: workout.metadata as [String: Any]? + ) + try await HealthKitManager.shared.deleteWorkout(workout) + await HealthKitManager.shared.loadWorkouts() + dismiss() + } catch { + errorMessage = error.localizedDescription } + } + } - // Update the original workout with the edited values - let updatedWorkout = HKWorkout( - activityType: editedActivityType, - start: editedStartTime, - end: editedEndTime, - workoutEvents: originalWorkout.workoutEvents, - totalEnergyBurned: originalWorkout.totalEnergyBurned, - totalDistance: originalWorkout.totalDistance, - metadata: originalWorkout.metadata - ) - - // Save the updated workout - HealthKitManager.shared.save(updatedWorkout) { success, error in - if success { - // Changes saved successfully - print("Changes saved successfully") - // Now, delete the original workout - HealthKitManager.shared.deleteWorkout(originalWorkout) { deleteSuccess, deleteError in - if deleteSuccess { - // Original workout deleted successfully - print("Original workout deleted successfully") - } else { - // Handle deletion error - print("Error deleting original workout: \(deleteError?.localizedDescription ?? "Unknown error")") - } - } - isSaveSuccessful = true - } else { - // Handle error - print("Error saving changes: \(error?.localizedDescription ?? "Unknown error")") - } + private func deleteWorkout() { + Task { + do { + try await HealthKitManager.shared.deleteWorkout(workout) + await HealthKitManager.shared.loadWorkouts() + dismiss() + } catch { + errorMessage = error.localizedDescription } } } } -struct WorkoutEditView_Previews: PreviewProvider { - static var previews: some View { +#Preview { + NavigationStack { WorkoutEditView(workout: HKWorkout(activityType: .running, start: Date(), end: Date())) } } diff --git a/WorkoutEditor/WorkoutEditorApp.swift b/WorkoutEditor/WorkoutEditorApp.swift index e6daea6..e673a7a 100644 --- a/WorkoutEditor/WorkoutEditorApp.swift +++ b/WorkoutEditor/WorkoutEditorApp.swift @@ -1,10 +1,3 @@ -// -// WorkoutEditorApp.swift -// WorkoutEditor -// -// Created by Vladyslav on 22.12.2023. -// - import SwiftUI @main @@ -12,16 +5,12 @@ struct WorkoutEditorApp: App { var body: some Scene { WindowGroup { WorkoutListView() - .onAppear() { - HealthKitManager.shared.requestAuthorization { success, error in - if success { - // Proceed with HealthKit-related functionality - print("Authorization requested successfully") - HealthKitManager.shared.loadWorkouts() - } else { - // Handle authorization error - print("Error requesting HealthKit authorization: \(error?.localizedDescription ?? "Unknown error")") - } + .task { + do { + try await HealthKitManager.shared.requestAuthorization() + await HealthKitManager.shared.loadWorkouts() + } catch { + print("HealthKit authorization error: \(error.localizedDescription)") } } } diff --git a/WorkoutEditor/WorkoutListView.swift b/WorkoutEditor/WorkoutListView.swift index a873c0c..c91db9d 100644 --- a/WorkoutEditor/WorkoutListView.swift +++ b/WorkoutEditor/WorkoutListView.swift @@ -1,42 +1,64 @@ -// -// WorkoutListView.swift -// WorkoutEditor -// -// Created by Vladyslav on 23.12.2023. -// - import SwiftUI import HealthKit struct WorkoutListView: View { - @StateObject private var healthKitManager = HealthKitManager.shared + var manager = HealthKitManager.shared + var body: some View { - NavigationView { - List(healthKitManager.loadedWorkouts ?? [], id: \.uuid) { workout in - NavigationLink(destination: WorkoutEditView(workout: workout)) { - // Display date in "DD-MM-YYYY" format along with activity type - Text("\(formattedDate(workout.startDate)) - \(workout.workoutActivityType.activityTypeDescription)") + NavigationStack { + List(manager.loadedWorkouts, id: \.uuid) { workout in + NavigationLink(value: workout) { + VStack(alignment: .leading, spacing: 4) { + Text(workout.workoutActivityType.activityTypeDescription) + .font(.headline) + HStack { + Text(workout.startDate.formatted(date: .abbreviated, time: .shortened)) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Text(workout.formattedDuration) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) } } - .onAppear() { - healthKitManager.loadWorkouts() + .navigationDestination(for: HKWorkout.self) { workout in + WorkoutEditView(workout: workout) } .navigationTitle("Workouts") - .navigationBarItems(trailing: NavigationLink(destination: WorkoutAddView()) { - Image(systemName: "plus") - }) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: "add") { + Image(systemName: "plus") + } + } + } + .navigationDestination(for: String.self) { _ in + WorkoutAddView() + } + .refreshable { + await manager.loadWorkouts() + } + .overlay { + if manager.loadedWorkouts.isEmpty { + ContentUnavailableView("No Workouts", systemImage: "figure.run", description: Text("Your workouts will appear here.")) + } + } } } +} - private func formattedDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "dd-MM-yyyy" - return formatter.string(from: date) +extension HKWorkout { + var formattedDuration: String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + return formatter.string(from: duration) ?? "" } } -struct WorkoutListView_Previews: PreviewProvider { - static var previews: some View { - WorkoutListView() - } +#Preview { + WorkoutListView() } -- 2.51.2