From f5a2a1803aa9e4872ad141a8764a7a672940c98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 13:50:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[#188]=20=EC=84=B8=ED=8A=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EA=B3=BC=20=EC=9A=B4=EB=8F=99=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutSessionView+Actions.swift | 53 +++- .../WorkoutSessionView+Content.swift | 36 ++- .../WorkoutSession/WorkoutSessionView.swift | 30 ++- .../WorkoutSessionViewModel+Progress.swift | 246 ++++++++++++++++++ .../WorkoutSessionViewModel.swift | 87 +++---- .../WorkoutTrainModeComponents.swift | 14 +- .../WorkoutTrainModeScreen.swift | 2 + .../Networking/WorkoutSessionAPIService.swift | 14 + .../WorkoutSessionAPIServiceTests.swift | 23 +- .../WorkoutSessionServerProgressTests.swift | 199 ++++++++++++++ .../Tests/WorkoutSessionViewModelTests.swift | 1 + 11 files changed, 636 insertions(+), 69 deletions(-) create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift create mode 100644 GYMUP/Tests/WorkoutSessionServerProgressTests.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift index 1523456..1be3386 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -32,11 +32,25 @@ extension WorkoutSessionView { func completeCurrentSet(at date: Date) { guard let currentSetIndex = viewModel.currentSetIndex else { return } - viewModel.toggleSetCompletion( - exerciseIndex: viewModel.currentExerciseIndex, - setIndex: currentSetIndex, - at: date - ) + Task { + await completeSet( + exerciseIndex: viewModel.currentExerciseIndex, + setIndex: currentSetIndex, + at: date + ) + } + } + + func completeSet(exerciseIndex: Int, setIndex: Int, at date: Date) async { + let didComplete = await viewModel.completeSet(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date) + guard didComplete else { + isShowingServerActionError = viewModel.serverActionErrorMessage != nil + return + } + completeSetSideEffects() + } + + private func completeSetSideEffects() { if settings.preferences.hapticsEnabled { UIImpactFeedbackGenerator(style: .light).impactOccurred() } @@ -47,6 +61,27 @@ extension WorkoutSessionView { } } + func retryFailedServerAction() { + let failedAction = viewModel.failedServerAction + Task { + let didRetry = await viewModel.retryFailedServerAction() + guard didRetry else { + isShowingServerActionError = viewModel.serverActionErrorMessage != nil + return + } + if let failedAction, case .recordSet = failedAction { + completeSetSideEffects() + } + } + } + + func advanceToNextExercise() { + Task { + let didAdvance = await viewModel.advanceToNextExercise() + isShowingServerActionError = !didAdvance && viewModel.serverActionErrorMessage != nil + } + } + func formattedDuration(_ seconds: Int) -> String { String(format: "%02d:%02d", seconds / 60, seconds % 60) } @@ -133,6 +168,14 @@ extension WorkoutSessionView { DebugOnboardingNetworking.makeExerciseReadService() #else nil +#endif + } + + static func makeProgressService() -> WorkoutSessionProgressing? { +#if DEBUG + DebugOnboardingNetworking.makeWorkoutSessionService() +#else + nil #endif } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift index c03f2b8..3f5416a 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift @@ -20,7 +20,7 @@ extension WorkoutSessionView { nextExercise: viewModel.plan.exercises[viewModel.currentExerciseIndex + 1], nextExerciseIndex: viewModel.currentExerciseIndex + 1, totalExerciseCount: viewModel.plan.exercises.count, - onContinue: { _ = viewModel.moveToNextExercise() }, + onContinue: advanceToNextExercise, onFinish: { isConfirmingFinish = true } ) } else if viewModel.isResting, @@ -64,12 +64,26 @@ extension WorkoutSessionView { } }, onToggleSet: { setIndex in - viewModel.toggleSetCompletion( - exerciseIndex: viewModel.currentExerciseIndex, - setIndex: setIndex, - at: Date() - ) - syncRestActivity() + if viewModel.serverWorkoutID == nil { + viewModel.toggleSetCompletion( + exerciseIndex: viewModel.currentExerciseIndex, + setIndex: setIndex, + at: Date() + ) + syncRestActivity() + } else { + Task { + await completeSet( + exerciseIndex: viewModel.currentExerciseIndex, + setIndex: setIndex, + at: Date() + ) + } + } + }, + canToggleSet: { setIndex in + viewModel.serverWorkoutID == nil || + (setIndex == viewModel.currentSetIndex && !viewModel.isSubmittingWorkoutAction) }, onAdjustWeight: viewModel.adjustCurrentWeight, onInputWeight: { @@ -82,14 +96,18 @@ extension WorkoutSessionView { viewModel.skipRest() syncRestActivity() }, - onNextExercise: { _ = viewModel.moveToNextExercise() }, + onNextExercise: advanceToNextExercise, onFinish: { isConfirmingFinish = true }, previousRecordError: viewModel.previousRecordError(for: exercise.id), isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } ) } else { - GymupEmptyState(symbol: "dumbbell", title: "운동을 찾을 수 없어요", message: "루틴으로 돌아가 운동을 확인해 주세요.") + GymupEmptyState( + symbol: "dumbbell", + title: "운동을 찾을 수 없어요", + message: "루틴으로 돌아가 운동을 확인해 주세요." + ) } } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 6e20598..38c29f1 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -18,6 +18,7 @@ struct WorkoutSessionView: View { @State var isShowingFirstExercisePrompt = false @State var promptedExerciseIDs: Set = [] @State var isShowingWorkoutProcessing = false + @State var isShowingServerActionError = false @State var isShowingWeightInput = false @State var weightInputSetIndex: Int? @State var liveActivitySessionID = UUID().uuidString @@ -49,7 +50,8 @@ struct WorkoutSessionView: View { startedAt: startedAt, historyStore: historyStore, previousRecordService: Self.makePreviousRecordService(), - serverWorkoutID: serverWorkoutID + serverWorkoutID: serverWorkoutID, + progressService: serverWorkoutID == nil ? nil : Self.makeProgressService() ) ) self.historyStore = historyStore @@ -159,10 +161,30 @@ struct WorkoutSessionView: View { .onReceive(clock) { date in Task { @MainActor in await handleRestTick(at: date) } } - .alert("휴식 시간이 끝났어요", isPresented: $isShowingRestFinishedAlert) { - Button("다음 세트 진행", role: .cancel) {} + .alert( + isShowingServerActionError ? "서버 요청을 처리하지 못했어요" : "휴식 시간이 끝났어요", + isPresented: Binding( + get: { isShowingRestFinishedAlert || isShowingServerActionError }, + set: { isPresented in + if !isPresented { + isShowingRestFinishedAlert = false + isShowingServerActionError = false + } + } + ) + ) { + if isShowingServerActionError { + Button("재시도") { retryFailedServerAction() } + Button("취소", role: .cancel) { viewModel.dismissServerActionError() } + } else { + Button("다음 세트 진행", role: .cancel) {} + } } message: { - Text("준비가 되면 다음 세트를 시작해 주세요.") + if isShowingServerActionError { + Text(viewModel.serverActionErrorMessage ?? "다시 시도해 주세요.") + } else { + Text("준비가 되면 다음 세트를 시작해 주세요.") + } } .onChange(of: viewModel.currentExerciseIndex) { _, _ in isShowingCurrentGuide = false diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift new file mode 100644 index 0000000..38c1e16 --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift @@ -0,0 +1,246 @@ +import Foundation + +extension WorkoutSessionViewModel { + var currentExercise: WorkoutSessionExercise? { + guard plan.exercises.indices.contains(currentExerciseIndex) else { return nil } + return plan.exercises[currentExerciseIndex] + } + + var currentSetIndex: Int? { + currentExercise?.sets.firstIndex(where: { !$0.isCompleted }) + } + + func adjustCurrentWeight(by increment: Double) { + guard let currentSetIndex, let currentExercise else { return } + let weight = currentExercise.sets[currentSetIndex].weightKilograms + increment + updateWeight(weight, exerciseIndex: currentExerciseIndex, setIndex: currentSetIndex) + } + + func adjustCurrentRepetitions(by increment: Int) { + guard let currentSetIndex, let currentExercise else { return } + let repetitions = currentExercise.sets[currentSetIndex].repetitions + increment + updateRepetitions(repetitions, exerciseIndex: currentExerciseIndex, setIndex: currentSetIndex) + } + + @discardableResult + func completeSet(exerciseIndex: Int, setIndex: Int, at date: Date = Date()) async -> Bool { + guard canCompleteSet(exerciseIndex: exerciseIndex, setIndex: setIndex) else { return false } + guard let serverWorkoutID else { + toggleSetCompletion(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date) + return true + } + guard currentExerciseIndex == exerciseIndex, + currentSetIndex == setIndex, + !isSubmittingWorkoutAction else { return false } + + let exercise = plan.exercises[exerciseIndex] + if exercise.measure == .seconds { + markSetCompleted(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date) + return true + } + return await submitServerSet( + exerciseIndex: exerciseIndex, + setIndex: setIndex, + exercise: exercise, + workoutID: serverWorkoutID, + completedAt: date + ) + } + + @discardableResult + func advanceToNextExercise() async -> Bool { + guard let serverWorkoutID else { return moveToNextExerciseLocally() } + guard hasStarted, !isPaused, !isFinished, + let currentExercise, + plan.exercises.indices.contains(currentExerciseIndex + 1) else { return false } + guard currentExercise.isComplete else { + notice = "현재 운동의 세트를 모두 완료한 뒤 다음으로 이동할 수 있어요." + return false + } + guard !isSubmittingWorkoutAction else { return false } + guard let progressService else { + failServerAction(.advanceExercise, error: APIClientError.invalidBaseURL) + return false + } + + let request = pendingExerciseAdvance ?? AdvanceWorkoutExerciseRequest( + completedExerciseId: currentExercise.id + ) + guard request.completedExerciseId == currentExercise.id else { return false } + pendingExerciseAdvance = request + isSubmittingWorkoutAction = true + clearServerActionError() + defer { isSubmittingWorkoutAction = false } + + do { + let response = try await progressService.advanceExercise(workoutId: serverWorkoutID, request: request) + guard response.workoutId == serverWorkoutID else { + throw APIClientError.invalidResponse + } + guard moveToNextExerciseLocally() else { throw APIClientError.invalidResponse } + pendingExerciseAdvance = nil + return true + } catch { + failServerAction(.advanceExercise, error: error) + return false + } + } + + @discardableResult + func retryFailedServerAction() async -> Bool { + guard let failedServerAction else { return false } + switch failedServerAction { + case let .recordSet(exerciseIndex, setIndex): + return await completeSet(exerciseIndex: exerciseIndex, setIndex: setIndex) + case .advanceExercise: + return await advanceToNextExercise() + } + } + + func dismissServerActionError() { + clearServerActionError() + } + + private func canCompleteSet(exerciseIndex: Int, setIndex: Int) -> Bool { + guard hasStarted, !isPaused, !isFinished, + plan.exercises.indices.contains(exerciseIndex), + plan.exercises[exerciseIndex].sets.indices.contains(setIndex) else { return false } + return !plan.exercises[exerciseIndex].sets[setIndex].isCompleted + } + + private func submitServerSet( + exerciseIndex: Int, + setIndex: Int, + exercise: WorkoutSessionExercise, + workoutID: String, + completedAt date: Date + ) async -> Bool { + guard let progressService else { + failServerAction( + .recordSet(exerciseIndex: exerciseIndex, setIndex: setIndex), + error: APIClientError.invalidBaseURL + ) + return false + } + guard let pendingSetSubmission = makePendingSetSubmission( + exerciseIndex: exerciseIndex, + setIndex: setIndex, + exercise: exercise, + date: date + ) else { return false } + + isSubmittingWorkoutAction = true + clearServerActionError() + defer { isSubmittingWorkoutAction = false } + do { + let response = try await progressService.createSet( + workoutId: workoutID, + request: pendingSetSubmission.request, + idempotencyKey: pendingSetSubmission.idempotencyKey + ) + try validateCreatedSet(response, workoutID: workoutID, exercise: exercise, setIndex: setIndex) + markSetCompleted(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date) + self.pendingSetSubmission = nil + return true + } catch { + failServerAction(.recordSet(exerciseIndex: exerciseIndex, setIndex: setIndex), error: error) + return false + } + } + + private func makePendingSetSubmission( + exerciseIndex: Int, + setIndex: Int, + exercise: WorkoutSessionExercise, + date: Date + ) -> PendingWorkoutSetSubmission? { + if let pendingSetSubmission { + guard pendingSetSubmission.exerciseIndex == exerciseIndex, + pendingSetSubmission.setIndex == setIndex else { return nil } + return pendingSetSubmission + } + let set = exercise.sets[setIndex] + let submission = PendingWorkoutSetSubmission( + exerciseIndex: exerciseIndex, + setIndex: setIndex, + request: CreateWorkoutSetRequest( + exerciseId: exercise.id, + setIndex: setIndex + 1, + weight: set.weightKilograms, + reps: set.repetitions, + completedAt: Self.iso8601String(from: date) + ), + idempotencyKey: UUID().uuidString + ) + pendingSetSubmission = submission + return submission + } + + private func validateCreatedSet( + _ response: CreateWorkoutSetResponse, + workoutID: String, + exercise: WorkoutSessionExercise, + setIndex: Int + ) throws { + guard response.workoutId == workoutID, + response.exerciseId == exercise.id, + response.setIndex == setIndex + 1 else { + throw APIClientError.invalidResponse + } + } + + func isPendingServerSet(exerciseIndex: Int, setIndex: Int) -> Bool { + pendingSetSubmission?.exerciseIndex == exerciseIndex && pendingSetSubmission?.setIndex == setIndex + } + + func failServerAction(_ action: FailedWorkoutServerAction, error: Error) { + failedServerAction = action + if let apiError = error as? APIClientError, + case let .server(_, detail) = apiError { + serverActionErrorMessage = detail.message + } else { + serverActionErrorMessage = error.localizedDescription + } + } + + func clearServerActionError() { + serverActionErrorMessage = nil + failedServerAction = nil + } + + private static func iso8601String(from date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + private func moveToNextExerciseLocally() -> Bool { + guard hasStarted, !isPaused, !isFinished, let currentExercise else { return false } + guard currentExercise.isComplete else { + notice = "현재 운동의 세트를 모두 완료한 뒤 다음으로 이동할 수 있어요." + return false + } + guard plan.exercises.indices.contains(currentExerciseIndex + 1) else { return false } + currentExerciseIndex += 1 + restEndsAt = nil + activeRestDurationSeconds = 0 + notice = nil + return true + } + + @discardableResult + func moveToNextExercise() -> Bool { + guard serverWorkoutID == nil else { return false } + return moveToNextExerciseLocally() + } +} + +enum FailedWorkoutServerAction: Equatable { + case recordSet(exerciseIndex: Int, setIndex: Int) + case advanceExercise +} + +struct PendingWorkoutSetSubmission { + let exerciseIndex: Int + let setIndex: Int + let request: CreateWorkoutSetRequest + let idempotencyKey: String +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index a2620ba..ae419e8 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -5,22 +5,31 @@ import Observation @MainActor final class WorkoutSessionViewModel { private(set) var plan: WorkoutSessionPlan - private(set) var currentExerciseIndex = 0 + var currentExerciseIndex = 0 private(set) var now: Date private(set) var finishedAt: Date? - private(set) var notice: String? + var notice: String? private(set) var serverWorkoutID: String? private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? private let previousRecordService: ExerciseReadAPIService? + let progressService: WorkoutSessionProgressing? private(set) var previousRecordErrors: [String: String] = [:] private(set) var loadingPreviousRecordExerciseIDs: Set = [] + var isSubmittingWorkoutAction = false + var serverActionErrorMessage: String? + var failedServerAction: FailedWorkoutServerAction? + + var pendingSetSubmission: PendingWorkoutSetSubmission? + var pendingExerciseAdvance: AdvanceWorkoutExerciseRequest? + + var hasPendingServerSetSubmission: Bool { pendingSetSubmission != nil } private(set) var startedAt: Date? private var pausedAt: Date? private var accumulatedPauseSeconds: TimeInterval = 0 - private var restEndsAt: Date? - private(set) var activeRestDurationSeconds = 0 + var restEndsAt: Date? + var activeRestDurationSeconds = 0 init( routineID: String, @@ -28,7 +37,8 @@ final class WorkoutSessionViewModel { startsImmediately: Bool = true, historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), - previousRecordService: ExerciseReadAPIService? = nil + previousRecordService: ExerciseReadAPIService? = nil, + progressService: WorkoutSessionProgressing? = nil ) { plan = .sample(for: routineID) self.startedAt = startsImmediately ? startedAt : nil @@ -36,6 +46,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService + self.progressService = progressService serverWorkoutID = nil } @@ -46,7 +57,8 @@ final class WorkoutSessionViewModel { historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, - serverWorkoutID: String? = nil + serverWorkoutID: String? = nil, + progressService: WorkoutSessionProgressing? = nil ) { self.plan = plan self.startedAt = startsImmediately ? startedAt : nil @@ -55,15 +67,7 @@ final class WorkoutSessionViewModel { self.goalSettings = goalSettings self.previousRecordService = previousRecordService self.serverWorkoutID = serverWorkoutID - } - - var currentExercise: WorkoutSessionExercise? { - guard plan.exercises.indices.contains(currentExerciseIndex) else { return nil } - return plan.exercises[currentExerciseIndex] - } - - var currentSetIndex: Int? { - currentExercise?.sets.firstIndex(where: { !$0.isCompleted }) + self.progressService = progressService } var isPaused: Bool { pausedAt != nil } @@ -124,6 +128,8 @@ final class WorkoutSessionViewModel { guard hasStarted, !isPaused, !isFinished, + !isSubmittingWorkoutAction, + !isPendingServerSet(exerciseIndex: exerciseIndex, setIndex: setIndex), plan.exercises.indices.contains(exerciseIndex), plan.exercises[exerciseIndex].sets.indices.contains(setIndex) else { return } plan.exercises[exerciseIndex].sets[setIndex].weightKilograms = min(max(weight, 0), 999) @@ -134,28 +140,20 @@ final class WorkoutSessionViewModel { guard hasStarted, !isPaused, !isFinished, + !isSubmittingWorkoutAction, + !isPendingServerSet(exerciseIndex: exerciseIndex, setIndex: setIndex), plan.exercises.indices.contains(exerciseIndex), plan.exercises[exerciseIndex].sets.indices.contains(setIndex) else { return } plan.exercises[exerciseIndex].sets[setIndex].repetitions = min(max(repetitions, 0), 999) notice = nil } - func adjustCurrentWeight(by increment: Double) { - guard let currentSetIndex, let currentExercise else { return } - let weight = currentExercise.sets[currentSetIndex].weightKilograms + increment - updateWeight(weight, exerciseIndex: currentExerciseIndex, setIndex: currentSetIndex) - } - - func adjustCurrentRepetitions(by increment: Int) { - guard let currentSetIndex, let currentExercise else { return } - let repetitions = currentExercise.sets[currentSetIndex].repetitions + increment - updateRepetitions(repetitions, exerciseIndex: currentExerciseIndex, setIndex: currentSetIndex) - } - func addSet(to exerciseIndex: Int) { guard hasStarted, !isPaused, !isFinished, + !isSubmittingWorkoutAction, + pendingSetSubmission == nil, plan.exercises.indices.contains(exerciseIndex) else { return } let lastSet = plan.exercises[exerciseIndex].sets.last let newSet = WorkoutSessionSet( @@ -170,19 +168,27 @@ final class WorkoutSessionViewModel { guard hasStarted, !isPaused, !isFinished, + serverWorkoutID == nil, plan.exercises.indices.contains(exerciseIndex), plan.exercises[exerciseIndex].sets.indices.contains(setIndex) else { return } - let wasCompleted = plan.exercises[exerciseIndex].sets[setIndex].isCompleted - plan.exercises[exerciseIndex].sets[setIndex].isCompleted.toggle() - notice = nil - - guard !wasCompleted else { + if plan.exercises[exerciseIndex].sets[setIndex].isCompleted { + plan.exercises[exerciseIndex].sets[setIndex].isCompleted = false restEndsAt = nil activeRestDurationSeconds = 0 return } + markSetCompleted(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date) + } + + func markSetCompleted(exerciseIndex: Int, setIndex: Int, at date: Date?) { + guard plan.exercises.indices.contains(exerciseIndex), + plan.exercises[exerciseIndex].sets.indices.contains(setIndex), + !plan.exercises[exerciseIndex].sets[setIndex].isCompleted else { return } + plan.exercises[exerciseIndex].sets[setIndex].isCompleted = true + notice = nil + let hasMoreSets = plan.exercises[exerciseIndex].sets.contains { !$0.isCompleted } if hasMoreSets, let date { now = date @@ -197,23 +203,6 @@ final class WorkoutSessionViewModel { } } - @discardableResult - func moveToNextExercise() -> Bool { - guard hasStarted, !isPaused, !isFinished else { return false } - guard let currentExercise else { return false } - guard currentExercise.isComplete else { - notice = "현재 운동의 세트를 모두 완료한 뒤 다음으로 이동할 수 있어요." - return false - } - guard plan.exercises.indices.contains(currentExerciseIndex + 1) else { return false } - - currentExerciseIndex += 1 - restEndsAt = nil - activeRestDurationSeconds = 0 - notice = nil - return true - } - func skipRest() { restEndsAt = nil activeRestDurationSeconds = 0 diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift index cbafda4..a5da183 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift @@ -41,6 +41,7 @@ struct WorkoutSessionSetTable: View { let exercise: WorkoutSessionExercise let currentSetIndex: Int? let onToggleSet: (Int) -> Void + let canToggleSet: (Int) -> Bool let onAdjustWeight: (Double) -> Void let onInputWeight: () -> Void let onAdjustRepetitions: (Int) -> Void @@ -103,6 +104,7 @@ struct WorkoutSessionSetTable: View { private func setRow(_ set: WorkoutSessionSet, index: Int) -> some View { let isCurrent = currentSetIndex == index + let isToggleEnabled = canToggleSet(index) let valueColor = set.isCompleted || isCurrent ? GymupColors.primaryText : GymupColors.tertiaryText return HStack(spacing: 8) { Text(String(index + 1)) @@ -130,7 +132,17 @@ struct WorkoutSessionSetTable: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(set.isCompleted ? "세트 \(index + 1) 완료 취소" : "세트 \(index + 1) 완료") + .disabled(!isToggleEnabled) + .accessibilityLabel( + set.isCompleted && isToggleEnabled + ? "세트 \(index + 1) 완료 취소" + : "세트 \(index + 1) 완료" + ) + .accessibilityHint( + isToggleEnabled ? "" : ( + set.isCompleted ? "서버에 기록된 세트는 취소할 수 없어요." : "현재 세트만 완료할 수 있어요." + ) + ) .accessibilityIdentifier("workout.set.\(index + 1).complete") } .padding(.horizontal, 4) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift index aeedd95..70634e7 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift @@ -14,6 +14,7 @@ struct WorkoutTrainModeScreen: View { let onMusic: () -> Void let onToggleGuide: () -> Void let onToggleSet: (Int) -> Void + let canToggleSet: (Int) -> Bool let onAdjustWeight: (Double) -> Void let onInputWeight: () -> Void let onAdjustRepetitions: (Int) -> Void @@ -47,6 +48,7 @@ struct WorkoutTrainModeScreen: View { exercise: exercise, currentSetIndex: currentSetIndex, onToggleSet: onToggleSet, + canToggleSet: canToggleSet, onAdjustWeight: onAdjustWeight, onInputWeight: onInputWeight, onAdjustRepetitions: onAdjustRepetitions diff --git a/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift b/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift index 3fbdc3d..482691c 100644 --- a/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift +++ b/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift @@ -84,6 +84,19 @@ protocol WorkoutStarting { func startWorkout(request: StartWorkoutRequest) async throws -> StartWorkoutResponse } +protocol WorkoutSessionProgressing { + func createSet( + workoutId: String, + request: CreateWorkoutSetRequest, + idempotencyKey: String? + ) async throws -> CreateWorkoutSetResponse + + func advanceExercise( + workoutId: String, + request: AdvanceWorkoutExerciseRequest + ) async throws -> AdvanceWorkoutExerciseResponse +} + struct WorkoutSessionAPIService { private let client: APIClient @@ -150,3 +163,4 @@ struct WorkoutSessionAPIService { } extension WorkoutSessionAPIService: WorkoutStarting {} +extension WorkoutSessionAPIService: WorkoutSessionProgressing {} diff --git a/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift b/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift index adcbdd7..b143653 100644 --- a/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift +++ b/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift @@ -79,7 +79,7 @@ final class WorkoutSessionAPIServiceTests: NetworkingTestCase { request: setRequest, idempotencyKey: "set-request-1" ) - XCTAssertEqual(requests[1].value(forHTTPHeaderField: "Idempotency-Key"), "set-request-1") + try assertProgressRequests(requests, setRequest: setRequest) XCTAssertEqual(advanced.currentExercise, .object(["exerciseId": .string("exercise-2")])) XCTAssertFalse(createdSet.isPersonalRecord) XCTAssertEqual(createdSet.nextAction, .object(["type": .string("REST")])) @@ -115,4 +115,25 @@ final class WorkoutSessionAPIServiceTests: NetworkingTestCase { XCTAssertEqual(response.status, .inProgress) XCTAssertEqual(response.routineSnapshot, .object(["routineId": .string("routine-1")])) } + + private func assertProgressRequests(_ requests: [URLRequest], setRequest: CreateWorkoutSetRequest) throws { + XCTAssertEqual(requests[0].httpMethod, "PATCH") + XCTAssertEqual(requests[0].url?.path, "/api/v1/workouts/workout-1/current-exercise") + XCTAssertEqual(requests[1].httpMethod, "POST") + XCTAssertEqual(requests[1].url?.path, "/api/v1/workouts/workout-1/sets") + XCTAssertEqual(requests[1].value(forHTTPHeaderField: "Idempotency-Key"), "set-request-1") + XCTAssertEqual( + try advanceRequest(in: requests[0]), + AdvanceWorkoutExerciseRequest(completedExerciseId: "exercise-1") + ) + XCTAssertEqual(try decodeSetRequest(in: requests[1]), setRequest) + } + + private func advanceRequest(in request: URLRequest) throws -> AdvanceWorkoutExerciseRequest { + try JSONDecoder().decode(AdvanceWorkoutExerciseRequest.self, from: XCTUnwrap(request.httpBody)) + } + + private func decodeSetRequest(in request: URLRequest) throws -> CreateWorkoutSetRequest { + try JSONDecoder().decode(CreateWorkoutSetRequest.self, from: XCTUnwrap(request.httpBody)) + } } diff --git a/GYMUP/Tests/WorkoutSessionServerProgressTests.swift b/GYMUP/Tests/WorkoutSessionServerProgressTests.swift new file mode 100644 index 0000000..9dfe2a6 --- /dev/null +++ b/GYMUP/Tests/WorkoutSessionServerProgressTests.swift @@ -0,0 +1,199 @@ +import XCTest +@testable import GYMUP + +final class WorkoutSessionServerProgressTests: XCTestCase { + @MainActor + func testServerSetFailurePreservesCompletionAndRetryReusesRequestAndIdempotencyKey() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionProgressService() + service.nextSetError = APIClientError.server( + statusCode: 503, + detail: ServerErrorDetail(code: "TEMPORARY_FAILURE", message: "서버에서 받은 오류") + ) + let viewModel = WorkoutSessionViewModel( + plan: serverBackedPlan(), + startedAt: start, + serverWorkoutID: "workout-1", + progressService: service + ) + + let firstAttempt = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + + XCTAssertFalse(firstAttempt) + XCTAssertFalse(try XCTUnwrap(viewModel.currentExercise?.sets.first).isCompleted) + XCTAssertTrue(viewModel.hasPendingServerSetSubmission) + XCTAssertEqual(viewModel.serverActionErrorMessage, "서버에서 받은 오류") + + let retry = await viewModel.retryFailedServerAction() + + XCTAssertTrue(retry) + XCTAssertTrue(try XCTUnwrap(viewModel.currentExercise?.sets.first).isCompleted) + XCTAssertFalse(viewModel.hasPendingServerSetSubmission) + XCTAssertEqual(service.setRequests.count, 2) + XCTAssertEqual(service.setRequests[0].workoutID, "workout-1") + XCTAssertEqual(service.setRequests[1].workoutID, "workout-1") + XCTAssertEqual(service.setRequests[0].request, service.setRequests[1].request) + XCTAssertEqual(service.setRequests[0].idempotencyKey, service.setRequests[1].idempotencyKey) + XCTAssertEqual(service.setRequests[0].request.exerciseId, "exercise-1") + XCTAssertEqual(service.setRequests[0].request.setIndex, 1) + XCTAssertEqual(service.setRequests[0].request.weight, 40) + XCTAssertEqual(service.setRequests[0].request.reps, 8) + XCTAssertEqual(service.setRequests[0].request.completedAt, ISO8601DateFormatter().string(from: start)) + } + + @MainActor + func testServerExerciseAdvanceFailureKeepsCurrentExerciseAndServerMessage() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionProgressService() + service.nextAdvanceError = APIClientError.server( + statusCode: 409, + detail: ServerErrorDetail(code: "SETS_INCOMPLETE", message: "서버의 세트 검증 메시지") + ) + let viewModel = WorkoutSessionViewModel( + plan: serverBackedPlan(firstSetCompleted: true), + startedAt: start, + serverWorkoutID: "workout-1", + progressService: service + ) + + let didAdvance = await viewModel.advanceToNextExercise() + + XCTAssertFalse(didAdvance) + XCTAssertEqual(viewModel.currentExerciseIndex, 0) + XCTAssertEqual(viewModel.serverActionErrorMessage, "서버의 세트 검증 메시지") + XCTAssertEqual(service.advanceRequests.count, 1) + XCTAssertEqual(service.advanceRequests[0].workoutID, "workout-1") + XCTAssertEqual(service.advanceRequests[0].request.completedExerciseId, "exercise-1") + } + + @MainActor + func testTimedServerSetSkipsSetEndpointAndDoesNotAdvanceWhenServerRejectsExerciseMove() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionProgressService() + service.nextAdvanceError = APIClientError.server( + statusCode: 409, + detail: ServerErrorDetail( + code: "SETS_INCOMPLETE", + message: "운동의 세트가 아직 완료되지 않았어요." + ) + ) + let viewModel = WorkoutSessionViewModel( + plan: serverBackedPlan(measure: .seconds), + startedAt: start, + serverWorkoutID: "workout-1", + progressService: service + ) + + let didCompleteTimedSet = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didCompleteTimedSet) + XCTAssertTrue(try XCTUnwrap(viewModel.currentExercise?.sets.first).isCompleted) + XCTAssertTrue(service.setRequests.isEmpty) + + let didAdvance = await viewModel.advanceToNextExercise() + XCTAssertFalse(didAdvance) + XCTAssertEqual(viewModel.currentExerciseIndex, 0) + XCTAssertEqual(viewModel.serverActionErrorMessage, "운동의 세트가 아직 완료되지 않았어요.") + } + + @MainActor + func testServerBackedCompletedSetCannotBeLocallyUndone() throws { + let viewModel = WorkoutSessionViewModel( + plan: serverBackedPlan(firstSetCompleted: true), + serverWorkoutID: "workout-1" + ) + + viewModel.toggleSetCompletion(exerciseIndex: 0, setIndex: 0) + + XCTAssertTrue(try XCTUnwrap(viewModel.currentExercise?.sets.first).isCompleted) + } + + private func serverBackedPlan( + measure: RoutineExerciseMeasure = .repetitions, + firstSetCompleted: Bool = false + ) -> WorkoutSessionPlan { + let exercises = [ + WorkoutSessionExercise( + id: "exercise-1", + name: "첫 번째 운동", + target: "가슴", + restDurationSeconds: 60, + formSteps: [], + beginnerTip: "", + sets: [WorkoutSessionSet(weightKilograms: 40, repetitions: 8, isCompleted: firstSetCompleted)], + measure: measure + ), + WorkoutSessionExercise( + id: "exercise-2", + name: "두 번째 운동", + target: "등", + restDurationSeconds: 60, + formSteps: [], + beginnerTip: "", + sets: [WorkoutSessionSet(weightKilograms: 30, repetitions: 10)] + ) + ] + return WorkoutSessionPlan( + id: "routine-1", + name: "루틴", + title: "루틴", + estimatedDurationMinutes: 20, + exercises: exercises + ) + } +} + +private final class MockWorkoutSessionProgressService: WorkoutSessionProgressing { + struct SetRequest { + let workoutID: String + let request: CreateWorkoutSetRequest + let idempotencyKey: String? + } + + struct AdvanceRequest { + let workoutID: String + let request: AdvanceWorkoutExerciseRequest + } + + var setRequests: [SetRequest] = [] + var advanceRequests: [AdvanceRequest] = [] + var nextSetError: Error? + var nextAdvanceError: Error? + + func createSet( + workoutId: String, + request: CreateWorkoutSetRequest, + idempotencyKey: String? + ) async throws -> CreateWorkoutSetResponse { + setRequests.append(.init(workoutID: workoutId, request: request, idempotencyKey: idempotencyKey)) + if let error = nextSetError { + nextSetError = nil + throw error + } + return CreateWorkoutSetResponse( + setId: "set-1", + workoutId: workoutId, + exerciseId: request.exerciseId, + setIndex: request.setIndex, + isPersonalRecord: false, + nextAction: .object([:]), + rest: .object([:]) + ) + } + + func advanceExercise( + workoutId: String, + request: AdvanceWorkoutExerciseRequest + ) async throws -> AdvanceWorkoutExerciseResponse { + advanceRequests.append(.init(workoutID: workoutId, request: request)) + if let error = nextAdvanceError { + nextAdvanceError = nil + throw error + } + return AdvanceWorkoutExerciseResponse( + workoutId: workoutId, + currentExercise: .object([:]), + nextExercise: .object([:]), + status: "IN_PROGRESS" + ) + } +} diff --git a/GYMUP/Tests/WorkoutSessionViewModelTests.swift b/GYMUP/Tests/WorkoutSessionViewModelTests.swift index aa03205..deeacc7 100644 --- a/GYMUP/Tests/WorkoutSessionViewModelTests.swift +++ b/GYMUP/Tests/WorkoutSessionViewModelTests.swift @@ -233,4 +233,5 @@ final class WorkoutSessionViewModelTests: XCTestCase { XCTAssertTrue(viewModel.hasStarted) XCTAssertEqual(viewModel.elapsedSeconds(at: start.addingTimeInterval(150)), 30) } + }