diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift index 4e804b8..2ea3bf8 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift @@ -3,8 +3,24 @@ import SwiftUI struct WorkoutSessionRecordRetryBanner: View { let message: String let isLoading: Bool + let retryAccessibilityLabel: String + let retryAccessibilityIdentifier: String let onRetry: () -> Void + init( + message: String, + isLoading: Bool, + retryAccessibilityLabel: String = "이전 운동 기록 다시 시도", + retryAccessibilityIdentifier: String = "workout.previous-record.retry", + onRetry: @escaping () -> Void + ) { + self.message = message + self.isLoading = isLoading + self.retryAccessibilityLabel = retryAccessibilityLabel + self.retryAccessibilityIdentifier = retryAccessibilityIdentifier + self.onRetry = onRetry + } + var body: some View { HStack(spacing: GymupSpacing.small) { Image(systemName: "exclamationmark.circle") @@ -34,8 +50,8 @@ struct WorkoutSessionRecordRetryBanner: View { } .buttonStyle(.plain) .disabled(isLoading) - .accessibilityLabel("이전 운동 기록 다시 시도") - .accessibilityIdentifier("workout.previous-record.retry") + .accessibilityLabel(retryAccessibilityLabel) + .accessibilityIdentifier(retryAccessibilityIdentifier) } .padding(.horizontal, GymupSpacing.medium) .padding(.vertical, GymupSpacing.xSmall) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift index 2fbed53..176aecd 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift @@ -11,6 +11,10 @@ struct WorkoutSessionRestTimerScreen: View { let previousRecordError: String? let isLoadingPreviousRecord: Bool let onRetryPreviousRecord: () -> Void + let nextSetPreview: NextWorkoutSetResponse? + let nextSetPreviewError: String? + let isLoadingNextSetPreview: Bool + let onRetryNextSetPreview: () -> Void @State private var isPlateCalculatorPresented = false private var nextSet: WorkoutSessionSet { exercise.sets[nextSetIndex] } @@ -38,6 +42,16 @@ struct WorkoutSessionRestTimerScreen: View { ) .padding(.top, 12) } + if let nextSetPreviewError { + WorkoutSessionRecordRetryBanner( + message: nextSetPreviewError, + isLoading: isLoadingNextSetPreview, + retryAccessibilityLabel: "다음 세트 정보 다시 시도", + retryAccessibilityIdentifier: "workout.next-set.retry", + onRetry: onRetryNextSetPreview + ) + .padding(.top, 12) + } Spacer(minLength: 64) plateCalculatorHint } @@ -163,17 +177,17 @@ struct WorkoutSessionRestTimerScreen: View { .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 0) { - Text("NEXT · SET \(nextSetIndex + 1) / \(exercise.sets.count)") + Text("NEXT · SET \(nextSetPreview?.setIndex ?? nextSetIndex + 1) / \(exercise.sets.count)") .font(GymupTypography.label) .tracking(0.16) .foregroundStyle(GymupColors.secondaryText) - Text("\(nextSet.weightKilograms.formatted(.number.precision(.fractionLength(0...1)))) KG × " - + "\(nextSet.repetitions)\(exercise.measure == .seconds ? "초" : "")") + Text("\((nextSetPreview?.weight ?? nextSet.weightKilograms).formatted(.number.precision(.fractionLength(0...1)))) KG × " + + "\(nextSetPreview?.reps ?? nextSet.repetitions)\(exercise.measure == .seconds ? "초" : "")") .font(GymupTypography.display) .foregroundStyle(GymupColors.primaryText) .lineLimit(1) .minimumScaleFactor(0.75) - Text("\(exercise.name) · 이전 \(exercise.previousPerformance(for: nextSetIndex) ?? "—")") + Text("\(nextSetPreview?.exerciseName ?? exercise.name) · 이전 \(exercise.previousPerformance(for: nextSetIndex) ?? "—")") .font(GymupTypography.uiCaption) .foregroundStyle(GymupColors.secondaryText) .lineLimit(1) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift index 50950c0..fdb798c 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -60,6 +60,7 @@ extension WorkoutSessionView { UIImpactFeedbackGenerator(style: .light).impactOccurred() } syncRestActivity() + refreshNextSetPreviewIfServerResting() if viewModel.currentExerciseIndex == viewModel.plan.exercises.count - 1, viewModel.currentExercise?.isComplete == true { showWorkoutProcessing() @@ -78,6 +79,7 @@ extension WorkoutSessionView { completeSetSideEffects() } else if let failedAction, case .startRest = failedAction { syncRestActivity() + refreshNextSetPreviewIfServerResting() } else if let failedAction, case .skipRest = failedAction { settings.cancelRestNotification(sessionID: liveActivitySessionID) await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) @@ -85,6 +87,7 @@ extension WorkoutSessionView { syncRestActivity() } else if let failedAction, case .refreshRest = failedAction { syncRestActivity() + refreshNextSetPreviewIfServerResting() } } } @@ -114,6 +117,11 @@ extension WorkoutSessionView { } } + private func refreshNextSetPreviewIfServerResting() { + guard viewModel.isServerManagedRest else { return } + Task { await viewModel.loadNextSetPreview() } + } + func refreshRestOnForeground() { guard viewModel.isServerManagedRest else { return } if viewModel.refreshClock(at: Date()) { @@ -128,6 +136,7 @@ extension WorkoutSessionView { return } syncRestActivity() + await viewModel.loadNextSetPreview() } } @@ -237,6 +246,14 @@ extension WorkoutSessionView { DebugOnboardingNetworking.makeWorkoutSessionService() #else nil +#endif + } + + static func makeWorkoutReadService() -> WorkoutNextSetReading? { +#if DEBUG + DebugOnboardingNetworking.makeWorkoutReadService() +#else + nil #endif } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift index 604550c..ed5ebca 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift @@ -36,7 +36,11 @@ extension WorkoutSessionView { onSkip: skipCurrentRest, previousRecordError: viewModel.previousRecordError(for: exercise.id), isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), - onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } + onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } }, + nextSetPreview: viewModel.nextSetPreview, + nextSetPreviewError: viewModel.nextSetPreviewError, + isLoadingNextSetPreview: viewModel.isLoadingNextSetPreview, + onRetryNextSetPreview: { Task { await viewModel.loadNextSetPreview() } } ) } else if let exercise = viewModel.currentExercise { WorkoutTrainModeScreen( diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 5befc8e..657c1ad 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -52,7 +52,8 @@ struct WorkoutSessionView: View { historyStore: historyStore, previousRecordService: Self.makePreviousRecordService(), serverWorkoutID: serverWorkoutID, - progressService: serverWorkoutID == nil ? nil : Self.makeProgressService() + progressService: serverWorkoutID == nil ? nil : Self.makeProgressService(), + nextSetService: serverWorkoutID == nil ? nil : Self.makeWorkoutReadService() ) ) self.historyStore = historyStore diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index 0d00302..eaa1359 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -13,9 +13,13 @@ final class WorkoutSessionViewModel { private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? private let previousRecordService: ExerciseReadAPIService? + let nextSetService: WorkoutNextSetReading? let progressService: WorkoutSessionProgressing? private(set) var previousRecordErrors: [String: String] = [:] private(set) var loadingPreviousRecordExerciseIDs: Set = [] + private(set) var nextSetPreview: NextWorkoutSetResponse? + private(set) var nextSetPreviewError: String? + private(set) var isLoadingNextSetPreview = false var isSubmittingWorkoutAction = false var serverActionErrorMessage: String? var failedServerAction: FailedWorkoutServerAction? @@ -40,7 +44,8 @@ final class WorkoutSessionViewModel { historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, - progressService: WorkoutSessionProgressing? = nil + progressService: WorkoutSessionProgressing? = nil, + nextSetService: WorkoutNextSetReading? = nil ) { plan = .sample(for: routineID) self.startedAt = startsImmediately ? startedAt : nil @@ -49,6 +54,7 @@ final class WorkoutSessionViewModel { self.goalSettings = goalSettings self.previousRecordService = previousRecordService self.progressService = progressService + self.nextSetService = nextSetService serverWorkoutID = nil } @@ -60,7 +66,8 @@ final class WorkoutSessionViewModel { goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, serverWorkoutID: String? = nil, - progressService: WorkoutSessionProgressing? = nil + progressService: WorkoutSessionProgressing? = nil, + nextSetService: WorkoutNextSetReading? = nil ) { self.plan = plan self.startedAt = startsImmediately ? startedAt : nil @@ -70,6 +77,7 @@ final class WorkoutSessionViewModel { self.previousRecordService = previousRecordService self.serverWorkoutID = serverWorkoutID self.progressService = progressService + self.nextSetService = nextSetService } var isPaused: Bool { pausedAt != nil } @@ -305,3 +313,63 @@ extension WorkoutSessionViewModel { } } } + +extension WorkoutSessionViewModel { + func loadNextSetPreview() async { + guard !isLoadingNextSetPreview, + isServerManagedRest, + let workoutID = serverWorkoutID, + let nextSetService, + let exercise = currentExercise, + exercise.measure == .repetitions, + let localSetIndex = currentSetIndex else { return } + + let expectedSetIndex = localSetIndex + 1 + isLoadingNextSetPreview = true + nextSetPreview = nil + nextSetPreviewError = nil + defer { isLoadingNextSetPreview = false } + + do { + let response = try await nextSetService.nextSet(workoutId: workoutID) + guard isCurrentNextSetRequest( + workoutID: workoutID, + exerciseID: exercise.id, + setIndex: localSetIndex + ) else { return } + guard response.exerciseId == exercise.id, + response.setIndex == expectedSetIndex, + !response.exerciseName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + response.weight.isFinite, + response.weight >= 0, + response.reps >= 0 else { + throw APIClientError.invalidResponse + } + + nextSetPreview = response + } catch { + guard !Task.isCancelled, + isCurrentNextSetRequest( + workoutID: workoutID, + exerciseID: exercise.id, + setIndex: localSetIndex + ) else { return } + nextSetPreviewError = Self.nextSetPreviewMessage(for: error) + } + } + + private func isCurrentNextSetRequest(workoutID: String, exerciseID: String, setIndex: Int) -> Bool { + serverWorkoutID == workoutID && + isServerManagedRest && + currentExercise?.id == exerciseID && + currentSetIndex == setIndex + } + + private static func nextSetPreviewMessage(for error: Error) -> String { + if let apiError = error as? APIClientError, + case let .server(_, detail) = apiError { + return detail.message + } + return "다음 세트 정보를 불러오지 못했어요." + } +} diff --git a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift index d4136ac..634ac83 100644 --- a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift +++ b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift @@ -179,5 +179,17 @@ enum DebugOnboardingNetworking { let client = APIClient(environment: environment, tokenProvider: tokenProvider) return WorkoutSessionAPIService(client: client) } + + static func makeWorkoutReadService() -> WorkoutReadAPIService? { + guard let baseURL = URL(string: "http://localhost:8000"), + let environment = try? APIEnvironment(baseURL: baseURL) + else { + return nil + } + + let tokenProvider = DevelopmentBearerTokenProvider(environment: environment) + let client = APIClient(environment: environment, tokenProvider: tokenProvider) + return WorkoutReadAPIService(client: client) + } } #endif diff --git a/GYMUP/Sources/Networking/WorkoutReadAPIService.swift b/GYMUP/Sources/Networking/WorkoutReadAPIService.swift index a6c2c84..75c23d1 100644 --- a/GYMUP/Sources/Networking/WorkoutReadAPIService.swift +++ b/GYMUP/Sources/Networking/WorkoutReadAPIService.swift @@ -41,7 +41,11 @@ struct NextWorkoutSetResponse: Codable, Equatable { let restEndsAt: String } -struct WorkoutReadAPIService { +protocol WorkoutNextSetReading { + func nextSet(workoutId: String) async throws -> NextWorkoutSetResponse +} + +struct WorkoutReadAPIService: WorkoutNextSetReading { private let client: APIClient init(client: APIClient) { diff --git a/GYMUP/Tests/WorkoutSessionNextSetPreviewTests.swift b/GYMUP/Tests/WorkoutSessionNextSetPreviewTests.swift new file mode 100644 index 0000000..953a462 --- /dev/null +++ b/GYMUP/Tests/WorkoutSessionNextSetPreviewTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import GYMUP + +final class WorkoutSessionNextSetPreviewTests: XCTestCase { + @MainActor + func testPreviewUsesValidatedServerValuesAndKeepsLocalPlanUnchanged() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetResponse = NextWorkoutSetResponse( + exerciseId: "exercise-1", + exerciseName: "Bench Press", + setIndex: 2, + weight: 47.5, + reps: 6, + restEndsAt: "not-used-by-preview" + ) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertEqual(service.nextSetRequests, ["workout-1"]) + XCTAssertEqual(viewModel.nextSetPreview?.exerciseName, "Bench Press") + XCTAssertEqual(viewModel.nextSetPreview?.setIndex, 2) + XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) + XCTAssertEqual(viewModel.nextSetPreview?.reps, 6) + XCTAssertNil(viewModel.nextSetPreviewError) + XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) + XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) + XCTAssertEqual(viewModel.currentSetIndex, 1) + } + + @MainActor + func testPreviewFailureKeepsLocalFallbackAndRetryLoadsServerValues() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetError = serverError(message: "서버 미리보기 오류") + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "서버 미리보기 오류") + XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) + XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) + + service.nextSetResponse = nextSetResponse() + await viewModel.loadNextSetPreview() + + XCTAssertEqual(service.nextSetRequests, ["workout-1", "workout-1"]) + XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) + XCTAssertNil(viewModel.nextSetPreviewError) + } + + @MainActor + func testPreviewRejectsExerciseOrSetIndexMismatch() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetResponse = nextSetResponse(setIndex: 3) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") + XCTAssertEqual(viewModel.currentSetIndex, 1) + + service.nextSetResponse = nextSetResponse(exerciseID: "exercise-2") + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") + XCTAssertEqual(service.nextSetRequests.count, 2) + } + + @MainActor + private func makeViewModel( + service: MockWorkoutSessionRestService, + start: Date + ) -> WorkoutSessionViewModel { + let currentExercise = WorkoutSessionExercise( + id: "exercise-1", + name: "벤치 프레스", + target: "가슴", + restDurationSeconds: 75, + formSteps: [], + beginnerTip: "", + sets: [ + WorkoutSessionSet(weightKilograms: 40, repetitions: 8), + WorkoutSessionSet(weightKilograms: 42.5, repetitions: 8) + ] + ) + let nextExercise = WorkoutSessionExercise( + id: "exercise-2", + name: "로우", + target: "등", + restDurationSeconds: 60, + formSteps: [], + beginnerTip: "", + sets: [WorkoutSessionSet(weightKilograms: 30, repetitions: 10)] + ) + let plan = WorkoutSessionPlan( + id: "routine-1", + name: "루틴", + title: "루틴", + estimatedDurationMinutes: 20, + exercises: [currentExercise, nextExercise] + ) + return WorkoutSessionViewModel( + plan: plan, + startedAt: start, + serverWorkoutID: "workout-1", + progressService: service, + nextSetService: service + ) + } + + private func nextSetResponse( + exerciseID: String = "exercise-1", + setIndex: Int = 2 + ) -> NextWorkoutSetResponse { + NextWorkoutSetResponse( + exerciseId: exerciseID, + exerciseName: "Bench Press", + setIndex: setIndex, + weight: 47.5, + reps: 6, + restEndsAt: "" + ) + } + + private func serverError(message: String) -> APIClientError { + .server( + statusCode: 503, + detail: ServerErrorDetail(code: "TEMPORARY_FAILURE", message: message) + ) + } +} diff --git a/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift b/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift index 0b88881..98583fd 100644 --- a/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift +++ b/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift @@ -2,7 +2,7 @@ import Foundation @testable import GYMUP @MainActor -final class MockWorkoutSessionRestService: WorkoutSessionProgressing { +final class MockWorkoutSessionRestService: WorkoutSessionProgressing, WorkoutNextSetReading { struct SetRequest { let workoutId: String let request: CreateWorkoutSetRequest @@ -17,6 +17,7 @@ final class MockWorkoutSessionRestService: WorkoutSessionProgressing { var setRequests: [SetRequest] = [] var restRequests: [RestRequest] = [] var currentRestRequests: [String] = [] + var nextSetRequests: [String] = [] var skipRequests: [String] = [] var extendRequests: [(String, Int)] = [] var nextRestStartError: Error? @@ -26,6 +27,8 @@ final class MockWorkoutSessionRestService: WorkoutSessionProgressing { var restStartCommitsBeforeThrow = false var currentRestResponse: CurrentRestResponse? var nextExtendResponse: ExtendCurrentRestResponse? + var nextSetResponse: NextWorkoutSetResponse? + var nextSetError: Error? private let startDate: Date init(startDate: Date) { @@ -86,6 +89,16 @@ final class MockWorkoutSessionRestService: WorkoutSessionProgressing { return currentRestResponse } + func nextSet(workoutId: String) async throws -> NextWorkoutSetResponse { + nextSetRequests.append(workoutId) + if let error = nextSetError { + nextSetError = nil + throw error + } + guard let nextSetResponse else { throw APIClientError.invalidResponse } + return nextSetResponse + } + func skipCurrentRest(workoutId: String) async throws -> SkipCurrentRestResponse { skipRequests.append(workoutId) if let error = nextSkipError { diff --git a/GYMUP/Tests/WorkoutSessionServerRestTests.swift b/GYMUP/Tests/WorkoutSessionServerRestTests.swift index 749d033..4f6589d 100644 --- a/GYMUP/Tests/WorkoutSessionServerRestTests.swift +++ b/GYMUP/Tests/WorkoutSessionServerRestTests.swift @@ -225,6 +225,11 @@ final class WorkoutSessionServerRestTests: XCTestCase { XCTAssertFalse(viewModel.isServerManagedRest) XCTAssertTrue(service.setRequests.isEmpty) XCTAssertTrue(service.restRequests.isEmpty) + + await viewModel.loadNextSetPreview() + + XCTAssertTrue(service.nextSetRequests.isEmpty) + XCTAssertNil(viewModel.nextSetPreviewError) } @MainActor @@ -269,7 +274,8 @@ final class WorkoutSessionServerRestTests: XCTestCase { plan: plan, startedAt: start, serverWorkoutID: "workout-1", - progressService: service + progressService: service, + nextSetService: service ) }