From cb67e404c89b62ecf301f7c93caf58249439dc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 22:21:43 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20[#194]=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EC=B2=AB=20=EA=B8=B0=EB=A1=9D=20=EB=B0=8F=20=EC=9E=90=EC=84=B8?= =?UTF-8?q?=20=EA=B0=80=EC=9D=B4=EB=93=9C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutExerciseGuideScreen.swift | 118 ++++++++++-- .../WorkoutSessionView+Actions.swift | 6 +- .../WorkoutSession/WorkoutSessionView.swift | 18 +- .../WorkoutSessionViewModel.swift | 90 +++++++++ .../Networking/ExerciseReadAPIService.swift | 8 +- .../WorkoutSessionGuidanceAPITests.swift | 171 ++++++++++++++++++ 6 files changed, 387 insertions(+), 24 deletions(-) create mode 100644 GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift index 666bd6b..4541c85 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift @@ -1,9 +1,30 @@ +import AVKit import SwiftUI struct WorkoutExerciseGuideScreen: View { let exercise: WorkoutSessionExercise + let guide: ExerciseGuideResponse? + let guideError: String? + let isLoadingGuide: Bool + let onRetryGuide: () -> Void let onClose: () -> Void + init( + exercise: WorkoutSessionExercise, + guide: ExerciseGuideResponse? = nil, + guideError: String? = nil, + isLoadingGuide: Bool = false, + onRetryGuide: @escaping () -> Void = {}, + onClose: @escaping () -> Void + ) { + self.exercise = exercise + self.guide = guide + self.guideError = guideError + self.isLoadingGuide = isLoadingGuide + self.onRetryGuide = onRetryGuide + self.onClose = onClose + } + var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 19) { @@ -19,21 +40,29 @@ struct WorkoutExerciseGuideScreen: View { private var videoHero: some View { ZStack { - Image(exercise.imageAssetName) - .resizable() - .scaledToFill() - .frame(maxWidth: .infinity) - .frame(height: 300) - .clipped() + if let mediaURL = validMediaURL { + WorkoutGuideVideoPlayer(url: mediaURL) + .frame(maxWidth: .infinity) + .frame(height: 300) + .clipped() + .accessibilityIdentifier("workout.guide.video-player") + } else { + Image(exercise.imageAssetName) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity) + .frame(height: 300) + .clipped() - Text("영상 준비 중") - .font(GymupTypography.uiBold) - .foregroundStyle(GymupColors.primaryText) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(GymupColors.canvas.opacity(0.85), in: Capsule()) - .accessibilityLabel("자세 영상은 준비 중이에요. 아래 자세 설명을 확인해 주세요.") - .accessibilityIdentifier("workout.guide.video-unavailable") + Text("영상 준비 중") + .font(GymupTypography.uiBold) + .foregroundStyle(GymupColors.primaryText) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(GymupColors.canvas.opacity(0.85), in: Capsule()) + .accessibilityLabel("자세 영상은 준비 중이에요. 아래 자세 설명을 확인해 주세요.") + .accessibilityIdentifier("workout.guide.video-unavailable") + } } .frame(height: 300) .overlay(alignment: .topTrailing) { @@ -63,6 +92,16 @@ struct WorkoutExerciseGuideScreen: View { .foregroundStyle(GymupColors.secondaryText) } + if let guideError { + WorkoutSessionRecordRetryBanner( + message: guideError, + isLoading: isLoadingGuide, + retryAccessibilityLabel: "자세 가이드 다시 시도", + retryAccessibilityIdentifier: "workout.guide.retry", + onRetry: onRetryGuide + ) + } + muscleChips keySteps commonMistakes @@ -74,7 +113,7 @@ struct WorkoutExerciseGuideScreen: View { private var muscleChips: some View { HStack(spacing: 8) { - ForEach(Array(exercise.guideMuscleGroups.enumerated()), id: \.offset) { index, muscle in + ForEach(Array(displayedMuscles.enumerated()), id: \.offset) { index, muscle in Text(muscle) .font(GymupTypography.uiMedium) .foregroundStyle(index == 0 ? .white : GymupColors.primaryText) @@ -96,7 +135,7 @@ struct WorkoutExerciseGuideScreen: View { private var keySteps: some View { VStack(spacing: 0) { - ForEach(Array(exercise.formSteps.prefix(3).enumerated()), id: \.offset) { index, step in + ForEach(Array(displayedKeyPoints.prefix(3).enumerated()), id: \.offset) { index, step in HStack(alignment: .center, spacing: 14) { Text(String(format: "%02d", index + 1)) .font(.custom("BarlowCondensed-ExtraBold", size: 18)) @@ -122,7 +161,7 @@ struct WorkoutExerciseGuideScreen: View { .tracking(0.16) .foregroundStyle(GymupColors.critical) - ForEach(Array(exercise.commonMistakes.prefix(2).enumerated()), id: \.offset) { _, mistake in + ForEach(Array(displayedCommonMistakes.prefix(2).enumerated()), id: \.offset) { _, mistake in HStack(spacing: 10) { Image("R3IconClose") .resizable() @@ -149,4 +188,49 @@ struct WorkoutExerciseGuideScreen: View { .accessibilityIdentifier("workout.guide.confirm") .background(GymupColors.canvas) } + + private var validMediaURL: URL? { + guard let value = guide?.mediaUrl, + let url = URL(string: value), + url.scheme?.lowercased() == "https", + url.host != nil else { return nil } + return url + } + + private var displayedMuscles: [String] { + guide?.targetMuscles.isEmpty == false ? guide?.targetMuscles ?? [] : exercise.guideMuscleGroups + } + + private var displayedKeyPoints: [String] { + guide?.keyPoints.isEmpty == false ? guide?.keyPoints ?? [] : exercise.formSteps + } + + private var displayedCommonMistakes: [String] { + guide?.commonMistakes.isEmpty == false ? guide?.commonMistakes ?? [] : exercise.commonMistakes + } +} + +private struct WorkoutGuideVideoPlayer: View { + let url: URL + @State private var player: AVPlayer? + + var body: some View { + Group { + if let player { + VideoPlayer(player: player) + } else { + GymupColors.canvas + .overlay { ProgressView().tint(GymupColors.primaryText) } + } + } + .onAppear { + guard player == nil else { return } + player = AVPlayer(url: url) + } + .onChange(of: url) { _, newURL in + player?.pause() + player = AVPlayer(url: newURL) + } + .onDisappear { player?.pause() } + } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift index fdb798c..7ad6527 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -3,7 +3,9 @@ import UIKit extension WorkoutSessionView { func loadPreviousRecord(for exerciseID: String) async { - await viewModel.loadPreviousRecord(for: exerciseID) + async let previousRecord: Void = viewModel.loadPreviousRecord(for: exerciseID) + async let firstTimeStatus: Void = viewModel.loadFirstTimeStatus(for: exerciseID) + _ = await (previousRecord, firstTimeStatus) guard viewModel.currentExercise?.id == exerciseID else { return } showFirstExercisePromptIfNeeded() } @@ -227,7 +229,7 @@ extension WorkoutSessionView { func showFirstExercisePromptIfNeeded() { guard let exercise = viewModel.currentExercise, - !exercise.hasPreviousPerformance, + viewModel.shouldShowFirstExercisePrompt(for: exercise), !promptedExerciseIDs.contains(exercise.id) else { return } promptedExerciseIDs.insert(exercise.id) isShowingFirstExercisePrompt = true diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 657c1ad..11d0fb2 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -28,11 +28,13 @@ struct WorkoutSessionView: View { @MainActor init(routineID: String, historyStore: HistoryStore) { + let exerciseReadService = Self.makePreviousRecordService() _viewModel = State( initialValue: WorkoutSessionViewModel( routineID: routineID, historyStore: historyStore, - previousRecordService: Self.makePreviousRecordService() + previousRecordService: exerciseReadService, + exerciseGuidanceService: exerciseReadService ) ) self.historyStore = historyStore @@ -45,12 +47,14 @@ struct WorkoutSessionView: View { serverWorkoutID: String? = nil, startedAt: Date = .now ) { + let exerciseReadService = Self.makePreviousRecordService() _viewModel = State( initialValue: WorkoutSessionViewModel( plan: plan, startedAt: startedAt, historyStore: historyStore, - previousRecordService: Self.makePreviousRecordService(), + previousRecordService: exerciseReadService, + exerciseGuidanceService: exerciseReadService, serverWorkoutID: serverWorkoutID, progressService: serverWorkoutID == nil ? nil : Self.makeProgressService(), nextSetService: serverWorkoutID == nil ? nil : Self.makeWorkoutReadService() @@ -142,6 +146,10 @@ struct WorkoutSessionView: View { if let exercise = viewModel.currentExercise { WorkoutExerciseGuideScreen( exercise: exercise, + guide: viewModel.exerciseGuide(for: exercise.id), + guideError: viewModel.exerciseGuideError(for: exercise.id), + isLoadingGuide: viewModel.isLoadingExerciseGuide(for: exercise.id), + onRetryGuide: { Task { await viewModel.loadExerciseGuide(for: exercise.id) } }, onClose: { isShowingCurrentGuide = false } ) } @@ -198,14 +206,16 @@ struct WorkoutSessionView: View { } .onChange(of: viewModel.currentExerciseIndex) { _, _ in isShowingCurrentGuide = false - showFirstExercisePromptIfNeeded() + } + .onChange(of: isShowingCurrentGuide) { _, isShowing in + guard isShowing, let exerciseID = viewModel.currentExercise?.id else { return } + Task { await viewModel.loadExerciseGuide(for: exerciseID) } } .onChange(of: viewModel.finishedAt) { _, finishedAt in guard finishedAt != nil else { return } settings.cancelRestNotification(sessionID: liveActivitySessionID) Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } } - .task { showFirstExercisePromptIfNeeded() } .task(id: "\(viewModel.hasStarted)-\(viewModel.currentExercise?.id ?? "")") { guard viewModel.hasStarted, let exerciseID = viewModel.currentExercise?.id else { return } await loadPreviousRecord(for: exerciseID) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index eaa1359..24915fe 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -13,10 +13,17 @@ final class WorkoutSessionViewModel { private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? private let previousRecordService: ExerciseReadAPIService? + private let exerciseGuidanceService: ExerciseGuidanceReading? let nextSetService: WorkoutNextSetReading? let progressService: WorkoutSessionProgressing? private(set) var previousRecordErrors: [String: String] = [:] private(set) var loadingPreviousRecordExerciseIDs: Set = [] + private(set) var firstTimeStatuses: [String: ExerciseFirstTimeStatusResponse] = [:] + private(set) var firstTimeStatusErrors: [String: String] = [:] + private(set) var loadingFirstTimeStatusExerciseIDs: Set = [] + private(set) var exerciseGuides: [String: ExerciseGuideResponse] = [:] + private(set) var exerciseGuideErrors: [String: String] = [:] + private(set) var loadingExerciseGuideIDs: Set = [] private(set) var nextSetPreview: NextWorkoutSetResponse? private(set) var nextSetPreviewError: String? private(set) var isLoadingNextSetPreview = false @@ -44,6 +51,7 @@ final class WorkoutSessionViewModel { historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, + exerciseGuidanceService: ExerciseGuidanceReading? = nil, progressService: WorkoutSessionProgressing? = nil, nextSetService: WorkoutNextSetReading? = nil ) { @@ -53,6 +61,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService + self.exerciseGuidanceService = exerciseGuidanceService self.progressService = progressService self.nextSetService = nextSetService serverWorkoutID = nil @@ -65,6 +74,7 @@ final class WorkoutSessionViewModel { historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, + exerciseGuidanceService: ExerciseGuidanceReading? = nil, serverWorkoutID: String? = nil, progressService: WorkoutSessionProgressing? = nil, nextSetService: WorkoutNextSetReading? = nil @@ -75,6 +85,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService + self.exerciseGuidanceService = exerciseGuidanceService self.serverWorkoutID = serverWorkoutID self.progressService = progressService self.nextSetService = nextSetService @@ -273,6 +284,85 @@ final class WorkoutSessionViewModel { } } +extension WorkoutSessionViewModel { + func shouldShowFirstExercisePrompt(for exercise: WorkoutSessionExercise) -> Bool { + guard let status = firstTimeStatuses[exercise.id], status.exerciseId == exercise.id else { + return !exercise.hasPreviousPerformance + } + return status.isFirstTime && status.guideAvailable + } + + func loadFirstTimeStatus(for exerciseID: String) async { + guard let exerciseGuidanceService, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingFirstTimeStatusExerciseIDs.contains(exerciseID) else { return } + + loadingFirstTimeStatusExerciseIDs.insert(exerciseID) + defer { loadingFirstTimeStatusExerciseIDs.remove(exerciseID) } + + do { + let response = try await exerciseGuidanceService.firstTimeStatus(exerciseId: exerciseID) + guard response.exerciseId == exerciseID else { + throw APIClientError.invalidResponse + } + + firstTimeStatuses[exerciseID] = response + firstTimeStatusErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + firstTimeStatusErrors[exerciseID] = Self.exerciseGuidanceErrorMessage( + for: error, + fallback: "첫 기록 상태를 불러오지 못했어요." + ) + } + } + + func exerciseGuide(for exerciseID: String) -> ExerciseGuideResponse? { + exerciseGuides[exerciseID] + } + + func exerciseGuideError(for exerciseID: String) -> String? { + exerciseGuideErrors[exerciseID] + } + + func isLoadingExerciseGuide(for exerciseID: String) -> Bool { + loadingExerciseGuideIDs.contains(exerciseID) + } + + func loadExerciseGuide(for exerciseID: String) async { + guard let exerciseGuidanceService, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingExerciseGuideIDs.contains(exerciseID) else { return } + + loadingExerciseGuideIDs.insert(exerciseID) + defer { loadingExerciseGuideIDs.remove(exerciseID) } + + do { + let response = try await exerciseGuidanceService.guide(exerciseId: exerciseID) + guard response.exerciseId == exerciseID else { + throw APIClientError.invalidResponse + } + + exerciseGuides[exerciseID] = response + exerciseGuideErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + exerciseGuideErrors[exerciseID] = Self.exerciseGuidanceErrorMessage( + for: error, + fallback: "자세 가이드를 불러오지 못했어요." + ) + } + } + + private static func exerciseGuidanceErrorMessage(for error: Error, fallback: String) -> String { + if let apiError = error as? APIClientError, + case let .server(_, detail) = apiError { + return detail.message + } + return fallback + } +} + extension WorkoutSessionViewModel { func previousRecordError(for exerciseID: String) -> String? { previousRecordErrors[exerciseID] diff --git a/GYMUP/Sources/Networking/ExerciseReadAPIService.swift b/GYMUP/Sources/Networking/ExerciseReadAPIService.swift index 7765350..748687a 100644 --- a/GYMUP/Sources/Networking/ExerciseReadAPIService.swift +++ b/GYMUP/Sources/Networking/ExerciseReadAPIService.swift @@ -31,7 +31,13 @@ struct PreviousExerciseRecordResponse: Codable, Equatable { let performedAt: String } -struct ExerciseReadAPIService { +@MainActor +protocol ExerciseGuidanceReading { + func firstTimeStatus(exerciseId: String) async throws -> ExerciseFirstTimeStatusResponse + func guide(exerciseId: String) async throws -> ExerciseGuideResponse +} + +struct ExerciseReadAPIService: ExerciseGuidanceReading { private let client: APIClient init(client: APIClient) { diff --git a/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift b/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift new file mode 100644 index 0000000..55d80f2 --- /dev/null +++ b/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift @@ -0,0 +1,171 @@ +import XCTest +@testable import GYMUP + +final class WorkoutSessionGuidanceAPITests: XCTestCase { + @MainActor + func testFirstTimeStatusShowsPromptForFirstTimeExercise() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusResponse = firstTimeStatus(exerciseID: "bench-press", isFirstTime: true) + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.first) + + XCTAssertTrue(exercise.hasPreviousPerformance) + await viewModel.loadFirstTimeStatus(for: exercise.id) + + XCTAssertEqual(service.firstTimeStatusRequests, [exercise.id]) + XCTAssertTrue(viewModel.shouldShowFirstExercisePrompt(for: exercise)) + } + + @MainActor + func testNonFirstTimeStatusHidesPromptDespiteMissingLocalRecord() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusResponse = firstTimeStatus(exerciseID: "incline-press", isFirstTime: false) + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) + + XCTAssertFalse(exercise.hasPreviousPerformance) + await viewModel.loadFirstTimeStatus(for: exercise.id) + + XCTAssertFalse(viewModel.shouldShowFirstExercisePrompt(for: exercise)) + XCTAssertNil(viewModel.firstTimeStatusErrors[exercise.id]) + } + + @MainActor + func testPromptIsHiddenWhenGuideIsUnavailable() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusResponse = firstTimeStatus( + exerciseID: "incline-press", + isFirstTime: true, + guideAvailable: false + ) + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) + + await viewModel.loadFirstTimeStatus(for: exercise.id) + + XCTAssertFalse(viewModel.shouldShowFirstExercisePrompt(for: exercise)) + } + + @MainActor + func testFirstTimeStatusErrorFallsBackToLocalHistory() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusError = serverError(message: "상태 조회 실패") + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) + + await viewModel.loadFirstTimeStatus(for: exercise.id) + + XCTAssertNil(viewModel.firstTimeStatuses[exercise.id]) + XCTAssertEqual(viewModel.firstTimeStatusErrors[exercise.id], "상태 조회 실패") + XCTAssertTrue(viewModel.shouldShowFirstExercisePrompt(for: exercise)) + } + + @MainActor + func testGuideResponseProvidesServerContent() async throws { + let service = MockExerciseGuidanceService() + service.guideResponse = guide(exerciseID: "bench-press") + let viewModel = makeViewModel(service: service) + + await viewModel.loadExerciseGuide(for: "bench-press") + + XCTAssertEqual(service.guideRequests, ["bench-press"]) + XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.targetMuscles, ["chest"]) + XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) + XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.commonMistakes, ["bounce"]) + XCTAssertNil(viewModel.exerciseGuideError(for: "bench-press")) + } + + @MainActor + func testGuideErrorKeepsLocalGuideAvailableAndRetryLoadsServerGuide() async throws { + let service = MockExerciseGuidanceService() + service.nextGuideError = serverError(message: "가이드 조회 실패") + let viewModel = makeViewModel(service: service) + + await viewModel.loadExerciseGuide(for: "bench-press") + + XCTAssertNil(viewModel.exerciseGuide(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuideError(for: "bench-press"), "가이드 조회 실패") + XCTAssertFalse(try XCTUnwrap(viewModel.plan.exercises.first).formSteps.isEmpty) + + service.guideResponse = guide(exerciseID: "bench-press") + await viewModel.loadExerciseGuide(for: "bench-press") + + XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) + XCTAssertNil(viewModel.exerciseGuideError(for: "bench-press")) + XCTAssertEqual(service.guideRequests, ["bench-press", "bench-press"]) + } + + @MainActor + func testGuideResponseWithDifferentExerciseIDIsRejected() async { + let service = MockExerciseGuidanceService() + service.guideResponse = guide(exerciseID: "incline-press") + let viewModel = makeViewModel(service: service) + + await viewModel.loadExerciseGuide(for: "bench-press") + + XCTAssertNil(viewModel.exerciseGuide(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuideError(for: "bench-press"), "자세 가이드를 불러오지 못했어요.") + } + + @MainActor + private func makeViewModel(service: MockExerciseGuidanceService) -> WorkoutSessionViewModel { + WorkoutSessionViewModel( + routineID: "upper-a", + goalSettings: nil, + exerciseGuidanceService: service + ) + } + + private func firstTimeStatus( + exerciseID: String, + isFirstTime: Bool, + guideAvailable: Bool = true + ) -> ExerciseFirstTimeStatusResponse { + ExerciseFirstTimeStatusResponse( + exerciseId: exerciseID, + isFirstTime: isFirstTime, + guideAvailable: guideAvailable + ) + } + + private func guide(exerciseID: String) -> ExerciseGuideResponse { + ExerciseGuideResponse( + exerciseId: exerciseID, + mediaUrl: "https://cdn.example.test/guide.mp4", + targetMuscles: ["chest"], + keyPoints: ["brace"], + commonMistakes: ["bounce"] + ) + } + + private func serverError(message: String) -> APIClientError { + .server(statusCode: 503, detail: ServerErrorDetail(code: "TEMPORARY_FAILURE", message: message)) + } +} + +@MainActor +private final class MockExerciseGuidanceService: ExerciseGuidanceReading { + var firstTimeStatusResponse: ExerciseFirstTimeStatusResponse? + var guideResponse: ExerciseGuideResponse? + var firstTimeStatusError: Error? + var nextGuideError: Error? + private(set) var firstTimeStatusRequests: [String] = [] + private(set) var guideRequests: [String] = [] + + func firstTimeStatus(exerciseId: String) async throws -> ExerciseFirstTimeStatusResponse { + firstTimeStatusRequests.append(exerciseId) + if let firstTimeStatusError { throw firstTimeStatusError } + guard let firstTimeStatusResponse else { throw APIClientError.invalidResponse } + return firstTimeStatusResponse + } + + func guide(exerciseId: String) async throws -> ExerciseGuideResponse { + guideRequests.append(exerciseId) + if let nextGuideError { + self.nextGuideError = nil + throw nextGuideError + } + guard let guideResponse else { throw APIClientError.invalidResponse } + return guideResponse + } +} From 725470136638151cf5c9ad7b8b3e586858735415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 22:39:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20[#194]=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20API=20=EC=83=81=ED=83=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutExerciseGuidanceViewModel.swift | 96 +++++++++++++++++++ .../WorkoutSessionView+Actions.swift | 7 +- .../WorkoutSession/WorkoutSessionView.swift | 14 ++- .../WorkoutSessionViewModel.swift | 91 +----------------- .../WorkoutSessionGuidanceAPITests.swift | 50 +++++----- 5 files changed, 138 insertions(+), 120 deletions(-) create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuidanceViewModel.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuidanceViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuidanceViewModel.swift new file mode 100644 index 0000000..d250d79 --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuidanceViewModel.swift @@ -0,0 +1,96 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class WorkoutExerciseGuidanceViewModel { + private let service: ExerciseGuidanceReading? + + private(set) var firstTimeStatuses: [String: ExerciseFirstTimeStatusResponse] = [:] + private(set) var firstTimeStatusErrors: [String: String] = [:] + private(set) var loadingFirstTimeStatusExerciseIDs: Set = [] + private(set) var exerciseGuides: [String: ExerciseGuideResponse] = [:] + private(set) var exerciseGuideErrors: [String: String] = [:] + private(set) var loadingExerciseGuideIDs: Set = [] + + init(service: ExerciseGuidanceReading?) { + self.service = service + } + + func shouldShowFirstExercisePrompt(for exercise: WorkoutSessionExercise) -> Bool { + guard let status = firstTimeStatuses[exercise.id], status.exerciseId == exercise.id else { + return !exercise.hasPreviousPerformance + } + return status.isFirstTime && status.guideAvailable + } + + func loadFirstTimeStatus(for exerciseID: String, in plan: WorkoutSessionPlan) async { + guard let service, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingFirstTimeStatusExerciseIDs.contains(exerciseID) else { return } + + loadingFirstTimeStatusExerciseIDs.insert(exerciseID) + defer { loadingFirstTimeStatusExerciseIDs.remove(exerciseID) } + + do { + let response = try await service.firstTimeStatus(exerciseId: exerciseID) + guard response.exerciseId == exerciseID else { + throw APIClientError.invalidResponse + } + + firstTimeStatuses[exerciseID] = response + firstTimeStatusErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + firstTimeStatusErrors[exerciseID] = Self.errorMessage( + for: error, + fallback: "첫 기록 상태를 불러오지 못했어요." + ) + } + } + + func exerciseGuide(for exerciseID: String) -> ExerciseGuideResponse? { + exerciseGuides[exerciseID] + } + + func exerciseGuideError(for exerciseID: String) -> String? { + exerciseGuideErrors[exerciseID] + } + + func isLoadingExerciseGuide(for exerciseID: String) -> Bool { + loadingExerciseGuideIDs.contains(exerciseID) + } + + func loadExerciseGuide(for exerciseID: String, in plan: WorkoutSessionPlan) async { + guard let service, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingExerciseGuideIDs.contains(exerciseID) else { return } + + loadingExerciseGuideIDs.insert(exerciseID) + defer { loadingExerciseGuideIDs.remove(exerciseID) } + + do { + let response = try await service.guide(exerciseId: exerciseID) + guard response.exerciseId == exerciseID else { + throw APIClientError.invalidResponse + } + + exerciseGuides[exerciseID] = response + exerciseGuideErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + exerciseGuideErrors[exerciseID] = Self.errorMessage( + for: error, + fallback: "자세 가이드를 불러오지 못했어요." + ) + } + } + + private static func errorMessage(for error: Error, fallback: String) -> String { + if let apiError = error as? APIClientError, + case let .server(_, detail) = apiError { + return detail.message + } + return fallback + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift index 7ad6527..4a83f2c 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -4,7 +4,10 @@ import UIKit extension WorkoutSessionView { func loadPreviousRecord(for exerciseID: String) async { async let previousRecord: Void = viewModel.loadPreviousRecord(for: exerciseID) - async let firstTimeStatus: Void = viewModel.loadFirstTimeStatus(for: exerciseID) + async let firstTimeStatus: Void = viewModel.exerciseGuidance.loadFirstTimeStatus( + for: exerciseID, + in: viewModel.plan + ) _ = await (previousRecord, firstTimeStatus) guard viewModel.currentExercise?.id == exerciseID else { return } showFirstExercisePromptIfNeeded() @@ -229,7 +232,7 @@ extension WorkoutSessionView { func showFirstExercisePromptIfNeeded() { guard let exercise = viewModel.currentExercise, - viewModel.shouldShowFirstExercisePrompt(for: exercise), + viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise), !promptedExerciseIDs.contains(exercise.id) else { return } promptedExerciseIDs.insert(exercise.id) isShowingFirstExercisePrompt = true diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 11d0fb2..dfb6841 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -146,10 +146,14 @@ struct WorkoutSessionView: View { if let exercise = viewModel.currentExercise { WorkoutExerciseGuideScreen( exercise: exercise, - guide: viewModel.exerciseGuide(for: exercise.id), - guideError: viewModel.exerciseGuideError(for: exercise.id), - isLoadingGuide: viewModel.isLoadingExerciseGuide(for: exercise.id), - onRetryGuide: { Task { await viewModel.loadExerciseGuide(for: exercise.id) } }, + guide: viewModel.exerciseGuidance.exerciseGuide(for: exercise.id), + guideError: viewModel.exerciseGuidance.exerciseGuideError(for: exercise.id), + isLoadingGuide: viewModel.exerciseGuidance.isLoadingExerciseGuide(for: exercise.id), + onRetryGuide: { + Task { + await viewModel.exerciseGuidance.loadExerciseGuide(for: exercise.id, in: viewModel.plan) + } + }, onClose: { isShowingCurrentGuide = false } ) } @@ -209,7 +213,7 @@ struct WorkoutSessionView: View { } .onChange(of: isShowingCurrentGuide) { _, isShowing in guard isShowing, let exerciseID = viewModel.currentExercise?.id else { return } - Task { await viewModel.loadExerciseGuide(for: exerciseID) } + Task { await viewModel.exerciseGuidance.loadExerciseGuide(for: exerciseID, in: viewModel.plan) } } .onChange(of: viewModel.finishedAt) { _, finishedAt in guard finishedAt != nil else { return } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index 24915fe..d220a3a 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -13,17 +13,11 @@ final class WorkoutSessionViewModel { private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? private let previousRecordService: ExerciseReadAPIService? - private let exerciseGuidanceService: ExerciseGuidanceReading? + let exerciseGuidance: WorkoutExerciseGuidanceViewModel let nextSetService: WorkoutNextSetReading? let progressService: WorkoutSessionProgressing? private(set) var previousRecordErrors: [String: String] = [:] private(set) var loadingPreviousRecordExerciseIDs: Set = [] - private(set) var firstTimeStatuses: [String: ExerciseFirstTimeStatusResponse] = [:] - private(set) var firstTimeStatusErrors: [String: String] = [:] - private(set) var loadingFirstTimeStatusExerciseIDs: Set = [] - private(set) var exerciseGuides: [String: ExerciseGuideResponse] = [:] - private(set) var exerciseGuideErrors: [String: String] = [:] - private(set) var loadingExerciseGuideIDs: Set = [] private(set) var nextSetPreview: NextWorkoutSetResponse? private(set) var nextSetPreviewError: String? private(set) var isLoadingNextSetPreview = false @@ -61,7 +55,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService - self.exerciseGuidanceService = exerciseGuidanceService + exerciseGuidance = WorkoutExerciseGuidanceViewModel(service: exerciseGuidanceService) self.progressService = progressService self.nextSetService = nextSetService serverWorkoutID = nil @@ -85,7 +79,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService - self.exerciseGuidanceService = exerciseGuidanceService + exerciseGuidance = WorkoutExerciseGuidanceViewModel(service: exerciseGuidanceService) self.serverWorkoutID = serverWorkoutID self.progressService = progressService self.nextSetService = nextSetService @@ -284,85 +278,6 @@ final class WorkoutSessionViewModel { } } -extension WorkoutSessionViewModel { - func shouldShowFirstExercisePrompt(for exercise: WorkoutSessionExercise) -> Bool { - guard let status = firstTimeStatuses[exercise.id], status.exerciseId == exercise.id else { - return !exercise.hasPreviousPerformance - } - return status.isFirstTime && status.guideAvailable - } - - func loadFirstTimeStatus(for exerciseID: String) async { - guard let exerciseGuidanceService, - plan.exercises.contains(where: { $0.id == exerciseID }), - !loadingFirstTimeStatusExerciseIDs.contains(exerciseID) else { return } - - loadingFirstTimeStatusExerciseIDs.insert(exerciseID) - defer { loadingFirstTimeStatusExerciseIDs.remove(exerciseID) } - - do { - let response = try await exerciseGuidanceService.firstTimeStatus(exerciseId: exerciseID) - guard response.exerciseId == exerciseID else { - throw APIClientError.invalidResponse - } - - firstTimeStatuses[exerciseID] = response - firstTimeStatusErrors.removeValue(forKey: exerciseID) - } catch { - guard !Task.isCancelled else { return } - firstTimeStatusErrors[exerciseID] = Self.exerciseGuidanceErrorMessage( - for: error, - fallback: "첫 기록 상태를 불러오지 못했어요." - ) - } - } - - func exerciseGuide(for exerciseID: String) -> ExerciseGuideResponse? { - exerciseGuides[exerciseID] - } - - func exerciseGuideError(for exerciseID: String) -> String? { - exerciseGuideErrors[exerciseID] - } - - func isLoadingExerciseGuide(for exerciseID: String) -> Bool { - loadingExerciseGuideIDs.contains(exerciseID) - } - - func loadExerciseGuide(for exerciseID: String) async { - guard let exerciseGuidanceService, - plan.exercises.contains(where: { $0.id == exerciseID }), - !loadingExerciseGuideIDs.contains(exerciseID) else { return } - - loadingExerciseGuideIDs.insert(exerciseID) - defer { loadingExerciseGuideIDs.remove(exerciseID) } - - do { - let response = try await exerciseGuidanceService.guide(exerciseId: exerciseID) - guard response.exerciseId == exerciseID else { - throw APIClientError.invalidResponse - } - - exerciseGuides[exerciseID] = response - exerciseGuideErrors.removeValue(forKey: exerciseID) - } catch { - guard !Task.isCancelled else { return } - exerciseGuideErrors[exerciseID] = Self.exerciseGuidanceErrorMessage( - for: error, - fallback: "자세 가이드를 불러오지 못했어요." - ) - } - } - - private static func exerciseGuidanceErrorMessage(for error: Error, fallback: String) -> String { - if let apiError = error as? APIClientError, - case let .server(_, detail) = apiError { - return detail.message - } - return fallback - } -} - extension WorkoutSessionViewModel { func previousRecordError(for exerciseID: String) -> String? { previousRecordErrors[exerciseID] diff --git a/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift b/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift index 55d80f2..9588ff5 100644 --- a/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift +++ b/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift @@ -10,10 +10,10 @@ final class WorkoutSessionGuidanceAPITests: XCTestCase { let exercise = try XCTUnwrap(viewModel.plan.exercises.first) XCTAssertTrue(exercise.hasPreviousPerformance) - await viewModel.loadFirstTimeStatus(for: exercise.id) + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) XCTAssertEqual(service.firstTimeStatusRequests, [exercise.id]) - XCTAssertTrue(viewModel.shouldShowFirstExercisePrompt(for: exercise)) + XCTAssertTrue(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) } @MainActor @@ -24,10 +24,10 @@ final class WorkoutSessionGuidanceAPITests: XCTestCase { let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) XCTAssertFalse(exercise.hasPreviousPerformance) - await viewModel.loadFirstTimeStatus(for: exercise.id) + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) - XCTAssertFalse(viewModel.shouldShowFirstExercisePrompt(for: exercise)) - XCTAssertNil(viewModel.firstTimeStatusErrors[exercise.id]) + XCTAssertFalse(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) + XCTAssertNil(viewModel.exerciseGuidance.firstTimeStatusErrors[exercise.id]) } @MainActor @@ -41,9 +41,9 @@ final class WorkoutSessionGuidanceAPITests: XCTestCase { let viewModel = makeViewModel(service: service) let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) - await viewModel.loadFirstTimeStatus(for: exercise.id) + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) - XCTAssertFalse(viewModel.shouldShowFirstExercisePrompt(for: exercise)) + XCTAssertFalse(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) } @MainActor @@ -53,11 +53,11 @@ final class WorkoutSessionGuidanceAPITests: XCTestCase { let viewModel = makeViewModel(service: service) let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) - await viewModel.loadFirstTimeStatus(for: exercise.id) + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) - XCTAssertNil(viewModel.firstTimeStatuses[exercise.id]) - XCTAssertEqual(viewModel.firstTimeStatusErrors[exercise.id], "상태 조회 실패") - XCTAssertTrue(viewModel.shouldShowFirstExercisePrompt(for: exercise)) + XCTAssertNil(viewModel.exerciseGuidance.firstTimeStatuses[exercise.id]) + XCTAssertEqual(viewModel.exerciseGuidance.firstTimeStatusErrors[exercise.id], "상태 조회 실패") + XCTAssertTrue(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) } @MainActor @@ -66,13 +66,13 @@ final class WorkoutSessionGuidanceAPITests: XCTestCase { service.guideResponse = guide(exerciseID: "bench-press") let viewModel = makeViewModel(service: service) - await viewModel.loadExerciseGuide(for: "bench-press") + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) XCTAssertEqual(service.guideRequests, ["bench-press"]) - XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.targetMuscles, ["chest"]) - XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) - XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.commonMistakes, ["bounce"]) - XCTAssertNil(viewModel.exerciseGuideError(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.targetMuscles, ["chest"]) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.commonMistakes, ["bounce"]) + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press")) } @MainActor @@ -81,17 +81,17 @@ final class WorkoutSessionGuidanceAPITests: XCTestCase { service.nextGuideError = serverError(message: "가이드 조회 실패") let viewModel = makeViewModel(service: service) - await viewModel.loadExerciseGuide(for: "bench-press") + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) - XCTAssertNil(viewModel.exerciseGuide(for: "bench-press")) - XCTAssertEqual(viewModel.exerciseGuideError(for: "bench-press"), "가이드 조회 실패") + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press"), "가이드 조회 실패") XCTAssertFalse(try XCTUnwrap(viewModel.plan.exercises.first).formSteps.isEmpty) service.guideResponse = guide(exerciseID: "bench-press") - await viewModel.loadExerciseGuide(for: "bench-press") + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) - XCTAssertEqual(viewModel.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) - XCTAssertNil(viewModel.exerciseGuideError(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press")) XCTAssertEqual(service.guideRequests, ["bench-press", "bench-press"]) } @@ -101,10 +101,10 @@ final class WorkoutSessionGuidanceAPITests: XCTestCase { service.guideResponse = guide(exerciseID: "incline-press") let viewModel = makeViewModel(service: service) - await viewModel.loadExerciseGuide(for: "bench-press") + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) - XCTAssertNil(viewModel.exerciseGuide(for: "bench-press")) - XCTAssertEqual(viewModel.exerciseGuideError(for: "bench-press"), "자세 가이드를 불러오지 못했어요.") + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press"), "자세 가이드를 불러오지 못했어요.") } @MainActor From d757ef5e53ca911a8d2c0d7b722ea0ffaf95534a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 22:48:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20[#194]=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GYMUP/Sources/Networking/ExerciseReadAPIService.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/GYMUP/Sources/Networking/ExerciseReadAPIService.swift b/GYMUP/Sources/Networking/ExerciseReadAPIService.swift index 748687a..565deac 100644 --- a/GYMUP/Sources/Networking/ExerciseReadAPIService.swift +++ b/GYMUP/Sources/Networking/ExerciseReadAPIService.swift @@ -31,9 +31,11 @@ struct PreviousExerciseRecordResponse: Codable, Equatable { let performedAt: String } -@MainActor protocol ExerciseGuidanceReading { + @MainActor func firstTimeStatus(exerciseId: String) async throws -> ExerciseFirstTimeStatusResponse + + @MainActor func guide(exerciseId: String) async throws -> ExerciseGuideResponse } @@ -44,6 +46,7 @@ struct ExerciseReadAPIService: ExerciseGuidanceReading { self.client = client } + @MainActor func firstTimeStatus(exerciseId: String) async throws -> ExerciseFirstTimeStatusResponse { let endpoint = APIEndpoint( method: .get, @@ -53,6 +56,7 @@ struct ExerciseReadAPIService: ExerciseGuidanceReading { return try await client.send(endpoint, as: ExerciseFirstTimeStatusResponse.self) } + @MainActor func guide(exerciseId: String) async throws -> ExerciseGuideResponse { let endpoint = APIEndpoint( method: .get,