diff --git a/WorkoutEditor/HealthKitManager.swift b/WorkoutEditor/HealthKitManager.swift index dfaa09c..bef792d 100644 --- a/WorkoutEditor/HealthKitManager.swift +++ b/WorkoutEditor/HealthKitManager.swift @@ -42,7 +42,9 @@ class HealthKitManager { static let shared = HealthKitManager() var loadedWorkouts: [HKWorkout] = [] + var canLoadMore = false private let healthStore = HKHealthStore() + private let pageSize = 50 func requestAuthorization() async throws { guard HKHealthStore.isHealthDataAvailable() else { @@ -69,19 +71,46 @@ class HealthKitManager { let descriptor = HKSampleQueryDescriptor( predicates: [.workout()], sortDescriptors: [sortDescriptor], - limit: 100 + limit: pageSize ) do { let results = try await descriptor.result(for: healthStore) await MainActor.run { self.loadedWorkouts = results + self.canLoadMore = results.count == pageSize } } catch { print("Error loading workouts: \(error.localizedDescription)") } } + func loadMoreWorkouts() async { + guard canLoadMore, let oldest = loadedWorkouts.last else { return } + + let predicate = HKQuery.predicateForSamples( + withStart: nil, + end: oldest.startDate, + options: .strictEndDate + ) + let sortDescriptor = SortDescriptor(\HKWorkout.startDate, order: .reverse) + let descriptor = HKSampleQueryDescriptor( + predicates: [.workout(predicate)], + sortDescriptors: [sortDescriptor], + limit: pageSize + ) + + do { + let results = try await descriptor.result(for: healthStore) + await MainActor.run { + self.loadedWorkouts.append(contentsOf: results) + self.canLoadMore = results.count == pageSize + } + } catch { + print("Error loading more workouts: \(error.localizedDescription)") + } + } + func fetchIntensitySamples(for workout: HKWorkout) async -> (samples: [IntensitySample], metric: IntensityMetric) { let metrics: [IntensityMetric] = [.heartRate, .activeEnergy, .distance] diff --git a/WorkoutEditor/WorkoutEditorApp.swift b/WorkoutEditor/WorkoutEditorApp.swift index b69d1ce..d486bd9 100644 --- a/WorkoutEditor/WorkoutEditorApp.swift +++ b/WorkoutEditor/WorkoutEditorApp.swift @@ -15,13 +15,12 @@ struct WorkoutEditorApp: App { await HealthKitManager.shared.loadWorkouts() logger.notice("Loaded \(HealthKitManager.shared.loadedWorkouts.count) workouts") #if DEBUG - // Reset sample data - for workout in HealthKitManager.shared.loadedWorkouts { - try await HealthKitManager.shared.deleteWorkout(workout) + if HealthKitManager.shared.loadedWorkouts.isEmpty { + logger.notice("No workouts found, inserting samples...") + try await HealthKitManager.shared.insertSampleWorkouts() + await HealthKitManager.shared.loadWorkouts() + logger.notice("After insert: \(HealthKitManager.shared.loadedWorkouts.count) workouts") } - try await HealthKitManager.shared.insertSampleWorkouts() - await HealthKitManager.shared.loadWorkouts() - logger.notice("Reset samples: \(HealthKitManager.shared.loadedWorkouts.count) workouts") #endif } catch { logger.error("HealthKit error: \(error)") diff --git a/WorkoutEditor/WorkoutListView.swift b/WorkoutEditor/WorkoutListView.swift index fea54d8..5a95385 100644 --- a/WorkoutEditor/WorkoutListView.swift +++ b/WorkoutEditor/WorkoutListView.swift @@ -4,27 +4,76 @@ import HealthKit struct WorkoutListView: View { var manager = HealthKitManager.shared @Environment(\.scenePhase) private var scenePhase + @State private var searchText = "" + + private var filteredWorkouts: [HKWorkout] { + if searchText.isEmpty { + return manager.loadedWorkouts + } + return manager.loadedWorkouts.filter { + $0.workoutActivityType.activityTypeDescription + .localizedCaseInsensitiveContains(searchText) + } + } + + private var groupedWorkouts: [(String, [HKWorkout])] { + let calendar = Calendar.current + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + + let grouped: [DateComponents: [HKWorkout]] = Dictionary(grouping: filteredWorkouts) { workout in + calendar.dateComponents([.year, .month], from: workout.startDate) + } + + let sorted: [(DateComponents, [HKWorkout])] = grouped.sorted { lhs, rhs in + let lDate = calendar.date(from: lhs.key) ?? .distantPast + let rDate = calendar.date(from: rhs.key) ?? .distantPast + return lDate > rDate + } + + return sorted.map { comps, workouts in + let date = calendar.date(from: comps) ?? Date() + let label = formatter.string(from: date) + return (label, workouts) + } + } var body: some View { 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) + List { + ForEach(groupedWorkouts, id: \.0) { section in + Section(section.0) { + ForEach(section.1, 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) + } } } - .padding(.vertical, 2) + } + + if manager.canLoadMore && searchText.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + .onAppear { + Task { await manager.loadMoreWorkouts() } + } } } + .searchable(text: $searchText, prompt: "Search by activity type") .navigationDestination(for: HKWorkout.self) { workout in WorkoutEditView(workout: workout) }